@adatechnology/conversations-ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -334,6 +334,7 @@ import { ConversationThread } from '@adatechnology/conversations-ui/participant'
334
334
  | `status?` | `'open' \| 'closed'` | `closed` tira o compositor e mostra `labels.closedNotice` |
335
335
  | `counterpartLabel?` | `string` | linha curta sob o título, montada pelo host |
336
336
  | `headerActions?` | `ReactNode` | slot à direita do cabeçalho; ausente não desenha nada. Espera botões curtos ou só ícone, cada um com `aria-label`: com ações, a barra quebra de linha e o título mantém no mínimo `6rem` |
337
+ | `renderComposerLock?` | `(conversation) => ReactNode \| undefined` | devolver um nó troca o compositor por ele (aviso + ações do host), dentro de `cv-p-thread__lock`; `undefined` mantém o compositor. `closed` continua tendo prioridade. Avaliado a cada render; o rascunho fica no controlador e volta ao destravar. Também existe em `ParticipantConversations` |
337
338
  | `onBack?` | `() => void` | ausente = sem seta (o diálogo do host já tem o próprio fechar) |
338
339
  | `quickReplies?` | `QuickReply[]` | chips acima do campo; tocar **preenche**, nunca envia |
339
340
  | `renderSubjectIcon?`, `subjectGroups?` | | ícone do assunto no cabeçalho, como na lista |
@@ -244,6 +244,8 @@ type ParticipantConversationsProps = {
244
244
  tail?: boolean;
245
245
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
246
246
  audioRecording?: ParticipantAudioRecordingOptions;
247
+ /** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
248
+ renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined;
247
249
  };
248
250
  declare function ParticipantConversations(props: ParticipantConversationsProps): react.JSX.Element;
249
251
 
@@ -287,6 +289,8 @@ type ConversationThreadProps = {
287
289
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer;
288
290
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
289
291
  readonly audioRecording?: ParticipantAudioRecordingOptions;
292
+ /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
293
+ readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined;
290
294
  };
291
295
  /** One conversation about one subject, drawn like the participant app and seen from either side. */
292
296
  declare function ConversationThread(props: ConversationThreadProps): react.JSX.Element;
@@ -2908,6 +2908,10 @@ function Footer({ props }) {
2908
2908
  if (conversation.status === "closed") {
2909
2909
  return /* @__PURE__ */ jsx33("p", { className: "cv-p-thread__closed", children: labels.closedNotice });
2910
2910
  }
2911
+ const lock = props.renderComposerLock?.(conversation);
2912
+ if (lock !== void 0 && lock !== null) {
2913
+ return /* @__PURE__ */ jsx33("div", { className: "cv-p-thread__lock", role: "region", "aria-live": "polite", children: lock });
2914
+ }
2911
2915
  return /* @__PURE__ */ jsx33(
2912
2916
  ParticipantComposer,
2913
2917
  {
@@ -3675,7 +3679,8 @@ function LoadedThread(props) {
3675
3679
  avatars: props.avatars,
3676
3680
  renderAuthorAvatar: props.renderAuthorAvatar,
3677
3681
  tail: props.tail,
3678
- audioRecording: props.audioRecording
3682
+ audioRecording: props.audioRecording,
3683
+ renderComposerLock: props.renderComposerLock
3679
3684
  }
3680
3685
  );
3681
3686
  }
@@ -4043,7 +4048,8 @@ function ConversationThreadBody(props) {
4043
4048
  tail: props.tail,
4044
4049
  counterpartLabel: props.counterpartLabel,
4045
4050
  headerActions: props.headerActions,
4046
- audioRecording: props.audioRecording
4051
+ audioRecording: props.audioRecording,
4052
+ renderComposerLock: props.renderComposerLock
4047
4053
  }
4048
4054
  ) })
4049
4055
  }
package/dist/styles.css CHANGED
@@ -1812,6 +1812,11 @@
1812
1812
  white-space: nowrap;
1813
1813
  border: 0;
1814
1814
  }
1815
+ .cv-p-thread__lock {
1816
+ padding: 1rem;
1817
+ color: var(--cv-p-i-text-muted);
1818
+ border-top: 1px solid var(--cv-p-i-border);
1819
+ }
1815
1820
  .cv-p-thread__closed {
1816
1821
  margin: 0;
1817
1822
  padding: 1rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -53,6 +53,8 @@ export type ConversationThreadProps = {
53
53
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
54
54
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
55
55
  readonly audioRecording?: ParticipantAudioRecordingOptions
56
+ /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
57
+ readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
56
58
  }
57
59
 
58
60
  function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
@@ -109,6 +111,7 @@ function ConversationThreadBody(props: ConversationThreadProps) {
109
111
  counterpartLabel={props.counterpartLabel}
110
112
  headerActions={props.headerActions}
111
113
  audioRecording={props.audioRecording}
114
+ renderComposerLock={props.renderComposerLock}
112
115
  />
113
116
  </div>
114
117
  </div>
@@ -44,6 +44,8 @@ export type ParticipantConversationsProps = {
44
44
  tail?: boolean
45
45
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
46
46
  audioRecording?: ParticipantAudioRecordingOptions
47
+ /** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
48
+ renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
47
49
  }
48
50
 
49
51
  export function ParticipantConversations(props: ParticipantConversationsProps) {
@@ -128,6 +128,52 @@ describe('ParticipantThread', () => {
128
128
  expect(markup).not.toContain('<textarea')
129
129
  })
130
130
 
131
+ it('replaces the composer with the lock node inside the lock region', () => {
132
+ const markup = render({ renderComposerLock: () => <p>Assumed by Ana</p> })
133
+
134
+ expect(markup).toContain(
135
+ '<div class="cv-p-thread__lock" role="region" aria-live="polite"><p>Assumed by Ana</p></div>',
136
+ )
137
+ expect(markup).not.toContain('<form')
138
+ expect(markup).not.toContain('<textarea')
139
+ })
140
+
141
+ it('keeps the composer when renderComposerLock returns undefined or null', () => {
142
+ expect(render({ renderComposerLock: () => undefined })).toContain('<form')
143
+ expect(render({ renderComposerLock: () => null })).toContain('<form')
144
+ expect(render({ renderComposerLock: () => undefined })).not.toContain('cv-p-thread__lock')
145
+ })
146
+
147
+ it('lets the closed notice win over the lock', () => {
148
+ const markup = render({
149
+ conversation: buildConversation({ subjectId: '1', status: 'closed' }),
150
+ renderComposerLock: () => <p>Assumed by Ana</p>,
151
+ })
152
+
153
+ expect(markup).toContain('This conversation is closed')
154
+ expect(markup).not.toContain('Assumed by Ana')
155
+ })
156
+
157
+ it('passes the conversation to renderComposerLock and brings the draft back when it unlocks', () => {
158
+ const draft = { value: 'half typed', onChange: () => undefined, files: [], onFilesChange: () => undefined }
159
+ const conversation = buildConversation({ subjectId: '7' })
160
+ const seen: string[] = []
161
+
162
+ const locked = render({
163
+ draft,
164
+ conversation,
165
+ renderComposerLock: (received) => {
166
+ seen.push(received.subjectId)
167
+ return <p>Locked</p>
168
+ },
169
+ })
170
+ const unlocked = render({ draft, conversation, renderComposerLock: () => undefined })
171
+
172
+ expect(seen).toEqual(['7'])
173
+ expect(locked).not.toContain('half typed')
174
+ expect(unlocked).toContain('half typed')
175
+ })
176
+
131
177
  it('shows the composer when the conversation is open', () => {
132
178
  const markup = render()
133
179
 
@@ -76,6 +76,8 @@ export type ParticipantThreadProps = {
76
76
  readonly audioRecording?: ParticipantAudioRecordingOptions
77
77
  /** Receives the recorded audio; the controller sends it as a message of its own. */
78
78
  readonly onSendAudio?: (file: File) => void
79
+ /** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. Evaluated every render. */
80
+ readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
79
81
  }
80
82
 
81
83
  function Footer({ props }: { readonly props: ParticipantThreadProps }) {
@@ -83,6 +85,14 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
83
85
  if (conversation.status === 'closed') {
84
86
  return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
85
87
  }
88
+ const lock = props.renderComposerLock?.(conversation)
89
+ if (lock !== undefined && lock !== null) {
90
+ return (
91
+ <div className="cv-p-thread__lock" role="region" aria-live="polite">
92
+ {lock}
93
+ </div>
94
+ )
95
+ }
86
96
  return (
87
97
  <ParticipantComposer
88
98
  value={draft.value}
@@ -105,6 +105,7 @@ function LoadedThread(props: LoadedThreadProps) {
105
105
  renderAuthorAvatar={props.renderAuthorAvatar}
106
106
  tail={props.tail}
107
107
  audioRecording={props.audioRecording}
108
+ renderComposerLock={props.renderComposerLock}
108
109
  />
109
110
  )
110
111
  }
package/src/styles.css CHANGED
@@ -1275,6 +1275,7 @@
1275
1275
  .cv-p-thread__messages { display: flex; flex-direction: column; gap: 0.5rem; }
1276
1276
  .cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); background: var(--cv-p-i-surface-raised); }
1277
1277
  .cv-p-thread__live:empty { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1278
+ .cv-p-thread__lock { padding: 1rem; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
1278
1279
  .cv-p-thread__closed { margin: 0; padding: 1rem; text-align: center; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
1279
1280
  .cv-p-day { align-self: center; margin: 0.5rem 0; padding: 0.25rem 0.75rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); font: 0.65rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.06em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
1280
1281