@adatechnology/conversations-ui 0.1.1 → 0.2.1

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.
Files changed (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2181 -507
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +467 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +328 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +169 -0
  49. package/src/workspace/useComposerQueue.ts +229 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * "Tentar de novo" de um item avulso da fila do composer, extraído de `useComposerQueue`: local
3
+ * volta por `onSendAttachments` sem legenda (o texto, se havia, já saiu); guardado vai sozinho por
4
+ * `retryStoredAttachments`, com sua própria chave de idempotência (M3) — nunca a mesma do envio do
5
+ * rascunho, porque o conjunto de `uploadId` de um retry solo é sempre outro.
6
+ *
7
+ * Guarda de troca de conversa (H2): `ConversationPane` fica montado ao trocar de conversa, então um
8
+ * retry que resolve depois da troca não pode gravar na fila/estado da conversa nova com dado da
9
+ * antiga. O id da conversa é capturado antes do primeiro `await` e comparado com
10
+ * `currentConversationIdRef` (espelhado por `useComposerQueue`) depois; troca no meio, o resultado é
11
+ * descartado. Trava por chave (não a trava global do rascunho) impede duplo clique de reenviar o
12
+ * mesmo item duas vezes.
13
+ */
14
+
15
+ import { useCallback, useRef, useState } from 'react'
16
+ import {
17
+ attachmentKey,
18
+ resolveIdempotencyKey,
19
+ resolveRetryOutcome,
20
+ retryStoredAttachments,
21
+ shouldResetRetryKey,
22
+ type AttachmentSendStatus,
23
+ type IdempotencyKeyState,
24
+ } from '../quickReplies/quickReplyAttachments'
25
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
26
+ import type { ConversationsApi } from '../providers/types'
27
+
28
+ export type UseComposerAttachmentRetryParams = {
29
+ readonly conversationId: string
30
+ readonly currentConversationIdRef: { readonly current: string }
31
+ readonly queue: readonly QueuedAttachment[]
32
+ readonly setQueue: (updater: (current: readonly QueuedAttachment[]) => readonly QueuedAttachment[]) => void
33
+ readonly setAttachmentStatus: (
34
+ updater: (current: Record<string, AttachmentSendStatus>) => Record<string, AttachmentSendStatus>,
35
+ ) => void
36
+ readonly setSendFailure: (message: string | undefined) => void
37
+ readonly api: Pick<ConversationsApi, 'sendStoredAttachments'>
38
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
39
+ readonly labels: { readonly attachFailure: string }
40
+ /** Espelha o envio completo do rascunho (`useComposerQueue`): um retry avulso não pode disparar
41
+ * enquanto o composer inteiro está em voo — as duas chaves de idempotência colidiriam no mesmo item. */
42
+ readonly sendInFlightRef: { readonly current: boolean }
43
+ }
44
+
45
+ export type UseComposerAttachmentRetryResult = {
46
+ readonly retryQueuedAttachment: (item: QueuedAttachment) => void
47
+ /** Chamado por `useComposerQueue` ao trocar de conversa: descarta chave de idempotência e travas. */
48
+ readonly resetRetryState: () => void
49
+ /** Chaves com retry avulso em voo — `useComposerQueue` exclui estas de um envio completo (evita
50
+ * duplo envio do mesmo item), e a UI desabilita o botão "Tentar de novo" enquanto durar. */
51
+ readonly retryingKeys: ReadonlySet<string>
52
+ }
53
+
54
+ export function useComposerAttachmentRetry(params: UseComposerAttachmentRetryParams): UseComposerAttachmentRetryResult {
55
+ const {
56
+ conversationId,
57
+ currentConversationIdRef,
58
+ queue,
59
+ setQueue,
60
+ setAttachmentStatus,
61
+ setSendFailure,
62
+ api,
63
+ onSendAttachments,
64
+ labels,
65
+ sendInFlightRef,
66
+ } = params
67
+ const retryIdempotencyKeyRef = useRef<IdempotencyKeyState | undefined>(undefined)
68
+ /** Chaves com retry em voo — impede duplo clique de reenviar o mesmo item duas vezes. */
69
+ const retryingKeysRef = useRef<Set<string>>(new Set())
70
+ /** Espelho reativo de `retryingKeysRef` — o ref sozinho não repinta o botão desabilitado. */
71
+ const [retryingKeys, setRetryingKeys] = useState<ReadonlySet<string>>(new Set())
72
+
73
+ function markRetrying(key: string): void {
74
+ retryingKeysRef.current.add(key)
75
+ setRetryingKeys(new Set(retryingKeysRef.current))
76
+ }
77
+
78
+ function unmarkRetrying(key: string): void {
79
+ retryingKeysRef.current.delete(key)
80
+ setRetryingKeys(new Set(retryingKeysRef.current))
81
+ }
82
+
83
+ async function retryLocalAttachment(item: Extract<QueuedAttachment, { kind: 'local' }>): Promise<void> {
84
+ if (!onSendAttachments) return
85
+ const key = attachmentKey(item)
86
+ const conversationIdAtRetry = conversationId
87
+ const isSameConversation = (): boolean => currentConversationIdRef.current === conversationIdAtRetry
88
+ setAttachmentStatus((current) => ({ ...current, [key]: 'sending' }))
89
+ try {
90
+ await onSendAttachments([item.file], '')
91
+ if (!isSameConversation()) return
92
+ setAttachmentStatus((current) => ({ ...current, [key]: 'sent' }))
93
+ setQueue((current) => current.filter((queued) => attachmentKey(queued) !== key))
94
+ } catch (error: unknown) {
95
+ if (!isSameConversation()) return
96
+ setAttachmentStatus((current) => ({ ...current, [key]: 'failed' }))
97
+ setSendFailure(error instanceof Error ? error.message : labels.attachFailure)
98
+ }
99
+ }
100
+
101
+ async function retryStoredAttachment(item: Extract<QueuedAttachment, { kind: 'stored' }>): Promise<void> {
102
+ const sendStoredAttachmentsApi = api.sendStoredAttachments
103
+ if (!sendStoredAttachmentsApi) return
104
+ const conversationIdAtRetry = conversationId
105
+ const isSameConversation = (): boolean => currentConversationIdRef.current === conversationIdAtRetry
106
+ const uploadIds = [item.uploadId]
107
+ const idempotencyState = resolveIdempotencyKey(retryIdempotencyKeyRef.current, uploadIds)
108
+ retryIdempotencyKeyRef.current = idempotencyState
109
+ try {
110
+ const result = await retryStoredAttachments({
111
+ queue,
112
+ uploadIds,
113
+ idempotencyKey: idempotencyState.key,
114
+ sendStoredAttachments: (uploadParams) =>
115
+ sendStoredAttachmentsApi({ conversationId: conversationIdAtRetry, ...uploadParams }),
116
+ onAttachmentStatus: (statusKey, status) => {
117
+ if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [statusKey]: status }))
118
+ },
119
+ })
120
+ // Descarta a chave só se este retry a enviou E nenhum retry mais novo do mesmo `uploadId`
121
+ // já trocou o ref por identidade (ver `shouldResetRetryKey`) — senão um segundo retry rápido
122
+ // do mesmo anexo (mensagens prontas reusam `uploadId`) reenviaria com chave já consumida.
123
+ if (
124
+ shouldResetRetryKey({
125
+ current: retryIdempotencyKeyRef.current,
126
+ attempted: idempotencyState,
127
+ sentAttachmentKeys: result.sentAttachmentKeys,
128
+ uploadId: item.uploadId,
129
+ })
130
+ ) {
131
+ retryIdempotencyKeyRef.current = undefined
132
+ }
133
+ setQueue(
134
+ (current) =>
135
+ resolveRetryOutcome({
136
+ conversationIdAtRetry,
137
+ currentConversationId: currentConversationIdRef.current,
138
+ sentAttachmentKeys: result.sentAttachmentKeys,
139
+ queue: current,
140
+ }) ?? current,
141
+ )
142
+ } catch (error: unknown) {
143
+ if (isSameConversation()) setSendFailure(error instanceof Error ? error.message : labels.attachFailure)
144
+ }
145
+ }
146
+
147
+ function retryQueuedAttachment(item: QueuedAttachment): void {
148
+ // Um envio completo já está em voo — deixá-lo terminar antes de aceitar retry avulso, senão as
149
+ // duas chaves de idempotência disputam o mesmo item.
150
+ if (sendInFlightRef.current) return
151
+ const key = attachmentKey(item)
152
+ if (retryingKeysRef.current.has(key)) return
153
+ markRetrying(key)
154
+ const release = (): void => unmarkRetrying(key)
155
+ if (item.kind === 'local') {
156
+ void retryLocalAttachment(item).finally(release)
157
+ return
158
+ }
159
+ void retryStoredAttachment(item).finally(release)
160
+ }
161
+
162
+ const resetRetryState = useCallback((): void => {
163
+ retryIdempotencyKeyRef.current = undefined
164
+ retryingKeysRef.current.clear()
165
+ setRetryingKeys(new Set())
166
+ }, [])
167
+
168
+ return { retryQueuedAttachment, resetRetryState, retryingKeys }
169
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Fila de anexos do composer rico, extraída de `ConversationPane`: texto -> guardados -> locais
3
+ * (QR-34/QR-43). Reseta tudo ao trocar de conversa (H2).
4
+ */
5
+
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import {
8
+ attachmentKey,
9
+ excludeRetryingItems,
10
+ hasSentEveryStoredUpload,
11
+ resolveIdempotencyKey,
12
+ sendQueuedMessage,
13
+ type AttachmentSendStatus,
14
+ type IdempotencyKeyState,
15
+ } from '../quickReplies/quickReplyAttachments'
16
+ import { useComposerAttachmentRetry } from './useComposerAttachmentRetry'
17
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
18
+ import type { ConversationsApi } from '../providers/types'
19
+
20
+ export type UseComposerQueueParams = {
21
+ readonly conversationId: string
22
+ readonly draft: string
23
+ readonly setDraft: (value: string) => void
24
+ readonly api: Pick<ConversationsApi, 'sendMessage' | 'sendStoredAttachments'>
25
+ readonly labels: { readonly sendFailure: string; readonly attachFailure: string }
26
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
27
+ readonly refetch: () => Promise<void>
28
+ readonly setSendFailure: (message: string | undefined) => void
29
+ }
30
+
31
+ export type UseComposerQueueResult = {
32
+ readonly queue: readonly QueuedAttachment[]
33
+ readonly enqueueAttachments: (items: readonly QueuedAttachment[]) => void
34
+ readonly attachmentStatus: Record<string, AttachmentSendStatus>
35
+ readonly isSendingDraft: boolean
36
+ readonly handleRichSend: () => Promise<void>
37
+ readonly removeQueuedAttachment: (item: QueuedAttachment) => void
38
+ readonly retryQueuedAttachment: (item: QueuedAttachment) => void
39
+ /** Chaves com retry avulso em voo — a UI desabilita o botão "Tentar de novo" desses itens. */
40
+ readonly retryingKeys: ReadonlySet<string>
41
+ }
42
+
43
+ export function useComposerQueue(params: UseComposerQueueParams): UseComposerQueueResult {
44
+ const { conversationId, draft, setDraft, api, labels, onSendAttachments, refetch, setSendFailure } = params
45
+
46
+ const [queue, setQueue] = useState<readonly QueuedAttachment[]>([])
47
+ const [attachmentStatus, setAttachmentStatus] = useState<Record<string, AttachmentSendStatus>>({})
48
+ const [isSendingDraft, setIsSendingDraft] = useState(false)
49
+ /** Ref, não estado: entre dois cliques seguidos o React ainda não teria repintado a trava. */
50
+ const sendInFlightRef = useRef(false)
51
+ /** Uma por conjunto de `uploadId` guardado em voo (QR-38, M3); muda, `resolveIdempotencyKey` troca. */
52
+ const idempotencyKeyRef = useRef<IdempotencyKeyState | undefined>(undefined)
53
+ /** Espelha `conversationId` sem esperar o repaint (H2) — lido depois do `await` de um envio. */
54
+ const currentConversationIdRef = useRef(conversationId)
55
+
56
+ const { retryQueuedAttachment, resetRetryState, retryingKeys } = useComposerAttachmentRetry({
57
+ conversationId,
58
+ currentConversationIdRef,
59
+ queue,
60
+ setQueue,
61
+ setAttachmentStatus,
62
+ setSendFailure,
63
+ api,
64
+ labels,
65
+ sendInFlightRef,
66
+ ...(onSendAttachments ? { onSendAttachments } : {}),
67
+ })
68
+
69
+ // Trocar de conversa abandona o envio em andamento — senão a resposta tardia reabilitaria o
70
+ // composer errado ou reusaria a chave de idempotência de outra thread.
71
+ useEffect(() => {
72
+ currentConversationIdRef.current = conversationId
73
+ setQueue([])
74
+ setAttachmentStatus({})
75
+ idempotencyKeyRef.current = undefined
76
+ sendInFlightRef.current = false
77
+ setIsSendingDraft(false)
78
+ resetRetryState()
79
+ }, [conversationId, resetRetryState])
80
+
81
+ const enqueueAttachments = useCallback((items: readonly QueuedAttachment[]) => {
82
+ if (items.length === 0) return
83
+ setQueue((current) => [...current, ...items])
84
+ }, [])
85
+
86
+ const removeQueuedAttachment = useCallback((item: QueuedAttachment) => {
87
+ setQueue((current) => current.filter((queued) => attachmentKey(queued) !== attachmentKey(item)))
88
+ }, [])
89
+
90
+ /** Devolve se o envio passou: limpar o rascunho depois de uma falha apagaria o texto do operador. */
91
+ async function runSend(action: () => Promise<unknown>, fallback: string): Promise<boolean> {
92
+ setSendFailure(undefined)
93
+ try {
94
+ await action()
95
+ await refetch()
96
+ return true
97
+ } catch (error: unknown) {
98
+ setSendFailure(error instanceof Error ? error.message : fallback)
99
+ return false
100
+ }
101
+ }
102
+
103
+ /** Fila só com `local`, como antes da feature de guardados: uma chamada só, rascunho como legenda. */
104
+ async function sendLocalOnlyAttachments(isSameConversation: () => boolean): Promise<void> {
105
+ if (!onSendAttachments) return
106
+ const files = excludeRetryingItems(queue, retryingKeys)
107
+ .filter((item): item is Extract<QueuedAttachment, { kind: 'local' }> => item.kind === 'local')
108
+ .map((item) => item.file)
109
+ try {
110
+ const didSend = await runSend(() => onSendAttachments(files, draft), labels.attachFailure)
111
+ if (!isSameConversation()) return
112
+ if (didSend) {
113
+ // Preserva item com retry avulso em voo (excluído deste envio) — ele ainda não foi resolvido.
114
+ setQueue((current) => current.filter((item) => retryingKeys.has(attachmentKey(item))))
115
+ setAttachmentStatus((current) =>
116
+ Object.fromEntries(Object.entries(current).filter(([key]) => retryingKeys.has(key))),
117
+ )
118
+ idempotencyKeyRef.current = undefined
119
+ setDraft('')
120
+ }
121
+ } finally {
122
+ if (isSameConversation()) {
123
+ sendInFlightRef.current = false
124
+ setIsSendingDraft(false)
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Texto -> guardados (lote, um resultado por arquivo) -> locais. Só o que não saiu continua na fila. */
130
+ async function sendQueuedDraft(isSameConversation: () => boolean): Promise<void> {
131
+ const sendStoredAttachmentsApi = api.sendStoredAttachments
132
+ // Item com retry avulso em voo fica de fora deste envio (chave de idempotência diferente) —
133
+ // senão o servidor recebe o mesmo anexo em dois lotes sem jeito de deduplicar.
134
+ const sendableQueue = excludeRetryingItems(queue, retryingKeys)
135
+ const storedUploadIds = sendableQueue
136
+ .filter((item): item is Extract<QueuedAttachment, { kind: 'stored' }> => item.kind === 'stored')
137
+ .map((item) => item.uploadId)
138
+ const idempotencyState = resolveIdempotencyKey(idempotencyKeyRef.current, storedUploadIds)
139
+ idempotencyKeyRef.current = idempotencyState
140
+ try {
141
+ const result = await sendQueuedMessage({
142
+ text: draft,
143
+ queue: sendableQueue,
144
+ idempotencyKey: idempotencyState.key,
145
+ sendText: (text) => runSend(() => api.sendMessage(conversationId, text), labels.sendFailure),
146
+ ...(sendStoredAttachmentsApi
147
+ ? {
148
+ sendStoredAttachments: (uploadParams: { uploadIds: readonly string[]; idempotencyKey: string }) =>
149
+ sendStoredAttachmentsApi({ conversationId, ...uploadParams }),
150
+ }
151
+ : {}),
152
+ ...(onSendAttachments
153
+ ? { sendLocalAttachments: (files: readonly File[]) => onSendAttachments(files, '') }
154
+ : {}),
155
+ onAttachmentStatus: (key, status) => {
156
+ if (isSameConversation()) setAttachmentStatus((current) => ({ ...current, [key]: status }))
157
+ },
158
+ })
159
+ if (!isSameConversation()) return
160
+ // `runSend` já gravou o erro específico do texto (`setSendFailure`) — não sobrescrever com o
161
+ // rótulo genérico.
162
+ if (!result.textSent) return
163
+ // Filtra por chave sobre a fila CORRENTE, não sobrescreve com `result.remainingQueue` (que foi
164
+ // calculado sobre a fila capturada antes do `await` e perderia item adicionado durante o envio).
165
+ const sentKeys = new Set(result.sentAttachmentKeys)
166
+ setQueue((current) => current.filter((item) => !sentKeys.has(attachmentKey(item))))
167
+ // Decide sobre os `uploadId` desta TENTATIVA (`idempotencyState.uploadIds`), nunca sobre o
168
+ // `setQueue` acima: o updater de `setQueue` não roda de forma síncrona (só no próximo render),
169
+ // então ler uma variável escrita por ele aqui sempre pegaria o valor antigo (bug real em
170
+ // produção — a chave nunca era descartada e o segundo envio do mesmo anexo era recusado pelo
171
+ // servidor como replay).
172
+ setAttachmentStatus((current) => {
173
+ const next = { ...current }
174
+ for (const key of sentKeys) delete next[key]
175
+ return next
176
+ })
177
+ // Descarta a chave só se ninguém a trocou por identidade desde o `await` (mesmo cuidado do
178
+ // retry avulso em `shouldResetRetryKey`) — senão um envio concorrente que já trocou o ref
179
+ // teria sua chave nova apagada por esta tentativa mais antiga.
180
+ if (
181
+ idempotencyKeyRef.current === idempotencyState &&
182
+ hasSentEveryStoredUpload(idempotencyState.uploadIds, result.sentAttachmentKeys)
183
+ ) {
184
+ idempotencyKeyRef.current = undefined
185
+ }
186
+ if (draft.trim()) setDraft('')
187
+ await refetch()
188
+ } catch (error: unknown) {
189
+ if (isSameConversation()) setSendFailure(error instanceof Error ? error.message : labels.attachFailure)
190
+ } finally {
191
+ if (isSameConversation()) {
192
+ sendInFlightRef.current = false
193
+ setIsSendingDraft(false)
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Texto primeiro; se falhar, nenhum anexo sai (QR-34, QR-43). Só o que não saiu continua na fila. */
199
+ async function handleRichSend(): Promise<void> {
200
+ // Trava contra clique duplo / Enter impaciente enquanto o upload está em voo.
201
+ if (sendInFlightRef.current) return
202
+ if (!draft.trim() && queue.length === 0) return
203
+ sendInFlightRef.current = true
204
+ setIsSendingDraft(true)
205
+ setSendFailure(undefined)
206
+ // Capturado antes do primeiro `await` (H2) — ver `currentConversationIdRef`.
207
+ const conversationIdAtSend = conversationId
208
+ const isSameConversation = (): boolean => currentConversationIdRef.current === conversationIdAtSend
209
+
210
+ // QR-32/QR-33: só `stored` (mensagem pronta) vai pelo pipeline novo; só `local` fica no antigo.
211
+ const hasStoredItems = queue.some((item) => item.kind === 'stored')
212
+ if (onSendAttachments && queue.length > 0 && !hasStoredItems) {
213
+ await sendLocalOnlyAttachments(isSameConversation)
214
+ return
215
+ }
216
+ await sendQueuedDraft(isSameConversation)
217
+ }
218
+
219
+ return {
220
+ queue,
221
+ enqueueAttachments,
222
+ attachmentStatus,
223
+ isSendingDraft,
224
+ handleRichSend,
225
+ removeQueuedAttachment,
226
+ retryQueuedAttachment,
227
+ retryingKeys,
228
+ }
229
+ }