@adatechnology/conversations-ui 0.1.0-rc.8 → 0.1.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/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -678
- package/dist/index.d.ts +921 -17
- package/dist/index.js +3677 -678
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-73MW5HNT.js +0 -1717
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estado de listagem espelhado na URL — ordenação, filtros e paginação (regra `web.md` §7).
|
|
3
|
+
*
|
|
4
|
+
* Escrito sobre `history.replaceState` e não sobre um router: o pacote roda em três produtos com
|
|
5
|
+
* routers diferentes, e exigir um deles arrastaria dependência de framework para dentro do módulo.
|
|
6
|
+
* `replaceState` também é o comportamento certo aqui — filtrar não é navegar, e cada tecla digitada
|
|
7
|
+
* na busca não deve virar uma entrada no botão "voltar".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
+
|
|
12
|
+
const LIST_SEPARATOR = ','
|
|
13
|
+
|
|
14
|
+
function readParams(): URLSearchParams {
|
|
15
|
+
if (typeof window === 'undefined') return new URLSearchParams()
|
|
16
|
+
return new URLSearchParams(window.location.search)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeParam(key: string, value: string | undefined): void {
|
|
20
|
+
if (typeof window === 'undefined') return
|
|
21
|
+
const params = readParams()
|
|
22
|
+
if (value === undefined || value === '') params.delete(key)
|
|
23
|
+
else params.set(key, value)
|
|
24
|
+
const query = params.toString()
|
|
25
|
+
window.history.replaceState(null, '', query ? `${window.location.pathname}?${query}` : window.location.pathname)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `enabled: false` mantém o mesmo contrato de estado sem tocar na URL — para uso em preview e teste. */
|
|
29
|
+
export interface UrlStateOptions {
|
|
30
|
+
readonly enabled?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useUrlStringState(
|
|
34
|
+
key: string,
|
|
35
|
+
initial: string,
|
|
36
|
+
{ enabled = true }: UrlStateOptions = {},
|
|
37
|
+
): [string, (next: string) => void] {
|
|
38
|
+
const [value, setValue] = useState(() => (enabled ? (readParams().get(key) ?? initial) : initial))
|
|
39
|
+
|
|
40
|
+
const update = useCallback(
|
|
41
|
+
(next: string) => {
|
|
42
|
+
setValue(next)
|
|
43
|
+
if (enabled) writeParam(key, next === initial ? undefined : next)
|
|
44
|
+
},
|
|
45
|
+
[enabled, key, initial],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return [value, update]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useUrlNumberState(
|
|
52
|
+
key: string,
|
|
53
|
+
initial: number,
|
|
54
|
+
{ enabled = true }: UrlStateOptions = {},
|
|
55
|
+
): [number, (next: number) => void] {
|
|
56
|
+
const [value, setValue] = useState(() => {
|
|
57
|
+
if (!enabled) return initial
|
|
58
|
+
const raw = Number(readParams().get(key))
|
|
59
|
+
return Number.isFinite(raw) && raw > 0 ? raw : initial
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const update = useCallback(
|
|
63
|
+
(next: number) => {
|
|
64
|
+
setValue(next)
|
|
65
|
+
if (enabled) writeParam(key, next === initial ? undefined : String(next))
|
|
66
|
+
},
|
|
67
|
+
[enabled, key, initial],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return [value, update]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useUrlArrayState(
|
|
74
|
+
key: string,
|
|
75
|
+
{ enabled = true }: UrlStateOptions = {},
|
|
76
|
+
): [readonly string[], (next: readonly string[]) => void] {
|
|
77
|
+
const [value, setValue] = useState<readonly string[]>(() => {
|
|
78
|
+
if (!enabled) return []
|
|
79
|
+
const raw = readParams().get(key)
|
|
80
|
+
return raw ? raw.split(LIST_SEPARATOR).filter(Boolean) : []
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const update = useCallback(
|
|
84
|
+
(next: readonly string[]) => {
|
|
85
|
+
setValue(next)
|
|
86
|
+
if (enabled) writeParam(key, next.length > 0 ? next.join(LIST_SEPARATOR) : undefined)
|
|
87
|
+
},
|
|
88
|
+
[enabled, key],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return [value, update]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Espera o usuário parar de digitar antes de deixar o valor chegar à query. Sem isto, cada tecla
|
|
96
|
+
* na busca vira uma chamada de rede e uma reescrita de URL.
|
|
97
|
+
*/
|
|
98
|
+
export function useDebouncedValue<TValue>(value: TValue, delayMs = 300): TValue {
|
|
99
|
+
const [debounced, setDebounced] = useState(value)
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const timer = setTimeout(() => setDebounced(value), delayMs)
|
|
103
|
+
return () => clearTimeout(timer)
|
|
104
|
+
}, [value, delayMs])
|
|
105
|
+
|
|
106
|
+
return debounced
|
|
107
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tamanhos de ícone da UI de conversas, em px, na escala de 4 da grade.
|
|
3
|
+
*
|
|
4
|
+
* Ícone vem da biblioteca (lucide), nunca emoji. Emoji desenha diferente em cada sistema
|
|
5
|
+
* operacional, não herda `currentColor` — então não acompanha o estado do controle (ativo,
|
|
6
|
+
* desabilitado, variante destrutiva) — e não escala com o token de tipografia. Numa mesma faixa,
|
|
7
|
+
* metade emoji e metade SVG lê como duas famílias de botão.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const ICON_SIZE_PILL = 12
|
|
11
|
+
export const ICON_SIZE_INLINE = 14
|
|
12
|
+
export const ICON_SIZE_ACTION = 16
|
package/src/index.ts
CHANGED
|
@@ -3,15 +3,32 @@ export { InteractiveMessage, DEFAULT_INTERACTIVE_MESSAGE_LABELS } from './Intera
|
|
|
3
3
|
export { ConversationWallpaper } from './Wallpaper'
|
|
4
4
|
export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
export { AudioPlayer } from './AudioPlayer'
|
|
6
|
+
export { AudioTranscription } from './AudioTranscription'
|
|
6
7
|
export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
|
|
7
8
|
export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
|
|
8
9
|
export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
10
|
+
export { RichMessageComposer, RICH_COMPOSER_ACTION, DEFAULT_RICH_COMPOSER_TOOLTIPS } from './RichMessageComposer'
|
|
11
|
+
export type {
|
|
12
|
+
RichMessageComposerProps,
|
|
13
|
+
RichComposerAction,
|
|
14
|
+
RichComposerTooltips,
|
|
15
|
+
RichComposerQuickReply,
|
|
16
|
+
RichComposerVariable,
|
|
17
|
+
RichMessageComposerHandle,
|
|
18
|
+
} from './RichMessageComposer'
|
|
19
|
+
export {
|
|
20
|
+
AudioRecorderButton,
|
|
21
|
+
DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
|
|
22
|
+
DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
23
|
+
} from './AudioRecorderButton'
|
|
24
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from './AudioRecorderButton'
|
|
9
25
|
export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
|
|
10
26
|
export { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
11
27
|
export { DateDivider } from './DateDivider'
|
|
12
28
|
export { Avatar, DEFAULT_AVATAR_LABELS } from './Avatar'
|
|
13
29
|
export { ConversationListItem, DEFAULT_CONVERSATION_LIST_ITEM_LABELS } from './ConversationListItem'
|
|
14
30
|
export { ToastProvider, useToast, toast } from './Toast'
|
|
31
|
+
export { TooltipLayer, TOOLTIP_ATTRIBUTE } from './Tooltip'
|
|
15
32
|
|
|
16
33
|
export { StatusTicks } from './StatusTicks'
|
|
17
34
|
export { Lightbox, DEFAULT_LIGHTBOX_LABELS } from './Lightbox'
|
|
@@ -25,6 +42,17 @@ export { MessageTail } from './MessageTail'
|
|
|
25
42
|
// plataforma e do ofício de atender, não de um produto — por isso moram aqui.
|
|
26
43
|
export { CONVERSATION_WINDOW, WINDOW_FILTERS, windowOf, formatStalledFor } from './conversationWindow'
|
|
27
44
|
export type { WindowOfParams } from './conversationWindow'
|
|
45
|
+
|
|
46
|
+
// Tempo sem resposta (SLA), distinto da janela de sessão acima: o host usa isto para o alerta e
|
|
47
|
+
// para filtrar quem está esperando demais.
|
|
48
|
+
export {
|
|
49
|
+
REPLY_LATENCY,
|
|
50
|
+
REPLY_LATENCY_LATE_HOURS,
|
|
51
|
+
REPLY_LATENCY_CRITICAL_HOURS,
|
|
52
|
+
replyLatencyOf,
|
|
53
|
+
isReplyOverdue,
|
|
54
|
+
} from './replyLatency'
|
|
55
|
+
export type { ReplyLatency, ReplyLatencyParams } from './replyLatency'
|
|
28
56
|
// Canal de origem: capacidades por plataforma (janela de sessão, reabertura, tipo de identificador).
|
|
29
57
|
export {
|
|
30
58
|
CONVERSATION_CHANNEL,
|
|
@@ -62,6 +90,7 @@ export { ConversationContextPanel, DEFAULT_CONVERSATION_CONTEXT_LABELS } from '.
|
|
|
62
90
|
export type {
|
|
63
91
|
ConversationContextPanelProps,
|
|
64
92
|
ConversationContextEntry,
|
|
93
|
+
ConversationContextStatus,
|
|
65
94
|
ConversationContextPanelLabels,
|
|
66
95
|
ConversationContextPanelClassNames,
|
|
67
96
|
} from './ConversationContextPanel'
|
|
@@ -69,6 +98,24 @@ export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS }
|
|
|
69
98
|
export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
|
|
70
99
|
export { DocumentsLibrary, DEFAULT_DOCUMENTS_LIBRARY_LABELS } from './DocumentsLibrary'
|
|
71
100
|
export type { DocumentsLibraryProps, DocumentsLibraryLabels, DocumentsLibraryClassNames } from './DocumentsLibrary'
|
|
101
|
+
export { DocumentsWorkspace, DEFAULT_DOCUMENTS_WORKSPACE_LABELS } from './documents'
|
|
102
|
+
export type {
|
|
103
|
+
DocumentsWorkspaceProps,
|
|
104
|
+
DocumentsWorkspaceLabels,
|
|
105
|
+
DocumentsWorkspaceClassNames,
|
|
106
|
+
DocumentsFiltersContext,
|
|
107
|
+
} from './documents'
|
|
108
|
+
export { SortableHead, MultiSelectFilter, BulkActionBar, ListingPagination } from './listing'
|
|
109
|
+
export type {
|
|
110
|
+
SortableHeadProps,
|
|
111
|
+
MultiSelectFilterProps,
|
|
112
|
+
BulkActionBarProps,
|
|
113
|
+
ListingPaginationProps,
|
|
114
|
+
FilterOption,
|
|
115
|
+
SortDirection,
|
|
116
|
+
} from './listing'
|
|
117
|
+
export { useUrlStringState, useUrlNumberState, useUrlArrayState, useDebouncedValue } from './hooks/useUrlFilterState'
|
|
118
|
+
export type { UrlStateOptions } from './hooks/useUrlFilterState'
|
|
72
119
|
export { DOCUMENT_SOURCE_FILTER } from './ConversationDocumentsPanel'
|
|
73
120
|
export type { DocumentSourceFilter } from './ConversationDocumentsPanel'
|
|
74
121
|
export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
|
|
@@ -78,6 +125,8 @@ export { buildTranscriptText, buildTranscriptFilename, downloadTextFile } from '
|
|
|
78
125
|
export type { BuildTranscriptTextParams } from './conversationTranscript'
|
|
79
126
|
|
|
80
127
|
export { useDarkMode, useIsDarkTheme } from './useDarkMode'
|
|
128
|
+
export { DarkModeToggle, DEFAULT_DARK_MODE_TOGGLE_LABELS } from './DarkModeToggle'
|
|
129
|
+
export type { DarkModeToggleProps, DarkModeToggleLabels } from './DarkModeToggle'
|
|
81
130
|
export { useIsNarrow, NARROW_MAX_WIDTH_PX } from './useIsNarrow'
|
|
82
131
|
export { useWaitingNotifications } from './useWaitingNotifications'
|
|
83
132
|
export type {
|
|
@@ -93,6 +142,7 @@ export { ConversationsProvider, useConversations } from './providers/Conversatio
|
|
|
93
142
|
export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
|
|
94
143
|
export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
|
|
95
144
|
export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
|
|
145
|
+
export { TranscriptionSettingsForm } from './settings/TranscriptionSettingsForm'
|
|
96
146
|
export {
|
|
97
147
|
WhatsAppTemplatesSettings,
|
|
98
148
|
TEMPLATE_SETTINGS_TAB,
|
|
@@ -100,9 +150,23 @@ export {
|
|
|
100
150
|
} from './settings/WhatsAppTemplatesSettings'
|
|
101
151
|
export { TopicsForm } from './settings/TopicsForm'
|
|
102
152
|
|
|
153
|
+
// Tela composta de Mensagens: junta os formulários acima com abas, estado e salvamento, para o
|
|
154
|
+
// host não remontar essa mesma colagem em cada produto (foi assim que eles divergiram).
|
|
155
|
+
export { MessagesWorkspace } from './settings/MessagesWorkspace'
|
|
156
|
+
export type {
|
|
157
|
+
MessagesWorkspaceProps,
|
|
158
|
+
MessagesWorkspaceApi,
|
|
159
|
+
MessagesWorkspaceLabels,
|
|
160
|
+
MessagesWorkspaceTemplateRole,
|
|
161
|
+
BotMessages,
|
|
162
|
+
TemplateSettings,
|
|
163
|
+
TranscriptionSettings,
|
|
164
|
+
} from './settings/MessagesWorkspace'
|
|
165
|
+
|
|
103
166
|
// Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
|
|
104
167
|
// produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
|
|
105
168
|
export { useConversationMessages } from './hooks/useConversationMessages'
|
|
169
|
+
export { useScrollToLatestMessage } from './hooks/useScrollToLatestMessage'
|
|
106
170
|
export { useConversationList } from './hooks/useConversationList'
|
|
107
171
|
export { useConversationContext } from './hooks/useConversationContext'
|
|
108
172
|
export { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
@@ -117,6 +181,7 @@ export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './li
|
|
|
117
181
|
|
|
118
182
|
export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
|
|
119
183
|
export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
|
|
184
|
+
export type { MessageTranscription, TranscriptionStatus, TranscriptionMode } from './types'
|
|
120
185
|
export type {
|
|
121
186
|
ConversationsApi,
|
|
122
187
|
SSEProvider,
|
|
@@ -137,6 +202,7 @@ export type { MessageBubbleProps } from './MessageBubble'
|
|
|
137
202
|
export type { ConversationWallpaperProps } from './Wallpaper'
|
|
138
203
|
export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
|
|
139
204
|
export type { AudioPlayerProps } from './AudioPlayer'
|
|
205
|
+
export type { AudioTranscriptionProps } from './AudioTranscription'
|
|
140
206
|
export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
|
|
141
207
|
export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
|
|
142
208
|
export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
|
|
@@ -166,6 +232,10 @@ export type {
|
|
|
166
232
|
WhatsAppCreateTemplateFormLabels,
|
|
167
233
|
} from './settings/WhatsAppCreateTemplateForm'
|
|
168
234
|
export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
|
|
235
|
+
export type {
|
|
236
|
+
TranscriptionSettingsFormProps,
|
|
237
|
+
TranscriptionSettingsFormLabels,
|
|
238
|
+
} from './settings/TranscriptionSettingsForm'
|
|
169
239
|
export type {
|
|
170
240
|
WhatsAppTemplatesSettingsProps,
|
|
171
241
|
WhatsAppTemplatesSettingsLabels,
|
|
@@ -176,8 +246,38 @@ export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/To
|
|
|
176
246
|
export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
|
|
177
247
|
export type { UseConversationListParams, UseConversationListResult } from './hooks/useConversationList'
|
|
178
248
|
export type { UseConversationContextResult } from './hooks/useConversationContext'
|
|
249
|
+
export type { UseScrollToLatestMessageParams, UseScrollToLatestMessageResult } from './hooks/useScrollToLatestMessage'
|
|
179
250
|
export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
|
|
180
251
|
export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
|
|
181
252
|
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
182
253
|
export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
183
254
|
export type { ConversationHeaderUtility } from './ConversationHeader'
|
|
255
|
+
export { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
|
|
256
|
+
export type { QuickReply } from './MessageComposer'
|
|
257
|
+
|
|
258
|
+
// Tela de atendimento completa. Fica no export principal — e não num subpath — porque é a
|
|
259
|
+
// composição padrão do pacote: quem instala conversas quer esta tela, e as peças continuam
|
|
260
|
+
// exportadas ao lado para quem precisar montar outra.
|
|
261
|
+
export { ConversationsWorkspace, ConversationPane, ConversationsInboxList } from './workspace'
|
|
262
|
+
export { useConversationsInbox, CONVERSATIONS_PER_PAGE, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './workspace'
|
|
263
|
+
export type {
|
|
264
|
+
ConversationsWorkspaceProps,
|
|
265
|
+
ConversationsWorkspaceSimulator,
|
|
266
|
+
SimulatorTransportFactory,
|
|
267
|
+
SimulatorTransportParams,
|
|
268
|
+
ConversationsWorkspaceLabels,
|
|
269
|
+
ConversationPaneProps,
|
|
270
|
+
ConversationsInboxListProps,
|
|
271
|
+
UseConversationsInboxParams,
|
|
272
|
+
UseConversationsInboxResult,
|
|
273
|
+
} from './workspace'
|
|
274
|
+
|
|
275
|
+
// A porta do simulador entra no export principal como TIPO: `simulator.transports` é tipado por ela,
|
|
276
|
+
// e obrigar o host a abrir o subpath `/preview` só para declarar a fábrica seria pedir que ele
|
|
277
|
+
// importasse fixtures para escrever uma assinatura. `export type` é apagado no build — o `preview/`
|
|
278
|
+
// continua fora do bundle de quem só declara o transporte.
|
|
279
|
+
export type {
|
|
280
|
+
ConversationSimulatorClient,
|
|
281
|
+
SendSimulatorMediaParams,
|
|
282
|
+
SimulatorMediaKind,
|
|
283
|
+
} from './preview/ConversationSimulatorClient'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FORMATTING_ACTION,
|
|
5
|
+
FORMATTING_SEPARATOR,
|
|
6
|
+
activeFormattingIn,
|
|
7
|
+
canExecuteFormattingCommand,
|
|
8
|
+
isFormattingActive,
|
|
9
|
+
toggleFormattingCommand,
|
|
10
|
+
} from './composer-formatting'
|
|
11
|
+
import { ZERO_WIDTH_SPACE, htmlToWA, waToHTML } from './whatsapp-formatting'
|
|
12
|
+
|
|
13
|
+
describe('isFormattingActive', () => {
|
|
14
|
+
it('reconhece cada formatação dentro do conjunto ativo', () => {
|
|
15
|
+
const active = [FORMATTING_ACTION.BOLD, FORMATTING_ACTION.MONOSPACE].join(FORMATTING_SEPARATOR)
|
|
16
|
+
|
|
17
|
+
expect(isFormattingActive({ active, action: FORMATTING_ACTION.BOLD })).toBe(true)
|
|
18
|
+
expect(isFormattingActive({ active, action: FORMATTING_ACTION.MONOSPACE })).toBe(true)
|
|
19
|
+
expect(isFormattingActive({ active, action: FORMATTING_ACTION.ITALIC })).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('não acende nada quando o conjunto está vazio', () => {
|
|
23
|
+
expect(isFormattingActive({ active: '', action: FORMATTING_ACTION.BOLD })).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('activeFormattingIn', () => {
|
|
28
|
+
it('não acende nada quando não há seleção no documento', () => {
|
|
29
|
+
const editor = { contains: () => false } as unknown as HTMLElement
|
|
30
|
+
|
|
31
|
+
expect(activeFormattingIn(editor)).toBe('')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('toggleFormattingCommand', () => {
|
|
36
|
+
it('avisa que não executou onde o comando não existe, para o campo cair no wrap manual', () => {
|
|
37
|
+
if (canExecuteFormattingCommand()) return
|
|
38
|
+
expect(toggleFormattingCommand(FORMATTING_ACTION.BOLD)).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('não tem comando nativo para monoespaçado', () => {
|
|
42
|
+
expect(toggleFormattingCommand(FORMATTING_ACTION.MONOSPACE)).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('waToHTML dentro do campo editável', () => {
|
|
47
|
+
it('escreve o tachado como <s>, que é o que o navegador consegue desfazer', () => {
|
|
48
|
+
expect(waToHTML('~riscado~')).toBe('<s>riscado</s>')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('mantém o ida e volta do tachado', () => {
|
|
52
|
+
expect(htmlToWA(waToHTML('~riscado~'))).toBe('~riscado~')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('htmlToWA e a âncora do cursor', () => {
|
|
57
|
+
it('descarta o código que só tem a âncora, para não sair um par de crases vazio', () => {
|
|
58
|
+
expect(htmlToWA(`oi <code>${ZERO_WIDTH_SPACE}</code>`)).toBe('oi')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('tira a âncora de dentro do código que o operador chegou a preencher', () => {
|
|
62
|
+
expect(htmlToWA(`<code>${ZERO_WIDTH_SPACE}npm run dev</code>`)).toBe('`npm run dev`')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('não deixa a âncora vazar para o texto enviado', () => {
|
|
66
|
+
expect(htmlToWA(`oi${ZERO_WIDTH_SPACE} tudo bem`)).toBe('oi tudo bem')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('htmlToWA com a marcação do navegador', () => {
|
|
71
|
+
it('converte o <strike> do execCommand para o tachado do WhatsApp', () => {
|
|
72
|
+
expect(htmlToWA('<strike>cancelado</strike>')).toBe('~cancelado~')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('converte o <b> e o <i> do navegador junto com as nossas tags', () => {
|
|
76
|
+
expect(htmlToWA('<b>oi</b> <i>tudo</i> <strong>bem</strong>')).toBe('*oi* _tudo_ *bem*')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estado e alternância da formatação dentro do campo rico.
|
|
3
|
+
*
|
|
4
|
+
* O botão precisa responder duas perguntas que o wrap manual não respondia: o cursor está dentro de
|
|
5
|
+
* um negrito? e, se está, como saio dele? `execCommand` é depreciado mas continua sendo o único
|
|
6
|
+
* caminho que alterna a formatação do cursor vazio — sem ele, ligar o negrito antes de digitar não
|
|
7
|
+
* existe, e era por isso que os botões não pareciam acender nem apagar.
|
|
8
|
+
*
|
|
9
|
+
* `queryCommandState` responde pelo estilo computado, então acende igual para `<strong>` (nosso) e
|
|
10
|
+
* `<b>` (do navegador). Onde o ambiente não tem as duas APIs — jsdom, teste — as funções avisam, e
|
|
11
|
+
* quem chama volta ao wrap manual.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { COMPOSER_MONOSPACE_CLASS } from '../composer.constant'
|
|
15
|
+
import { ZERO_WIDTH_SPACE } from './whatsapp-formatting'
|
|
16
|
+
|
|
17
|
+
export const FORMATTING_ACTION = {
|
|
18
|
+
BOLD: 'bold',
|
|
19
|
+
ITALIC: 'italic',
|
|
20
|
+
STRIKETHROUGH: 'strikethrough',
|
|
21
|
+
MONOSPACE: 'monospace',
|
|
22
|
+
} as const
|
|
23
|
+
export type FormattingAction = (typeof FORMATTING_ACTION)[keyof typeof FORMATTING_ACTION]
|
|
24
|
+
|
|
25
|
+
/** Monoespaçado fica de fora: não existe comando nativo, o campo trata à mão. */
|
|
26
|
+
const EXEC_COMMAND_BY_ACTION = {
|
|
27
|
+
[FORMATTING_ACTION.BOLD]: 'bold',
|
|
28
|
+
[FORMATTING_ACTION.ITALIC]: 'italic',
|
|
29
|
+
[FORMATTING_ACTION.STRIKETHROUGH]: 'strikeThrough',
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* O conjunto ativo viaja como string separada para o React comparar por valor: um `Set` novo a cada
|
|
34
|
+
* movimento do cursor rerenderizaria a barra inteira sem nada ter mudado.
|
|
35
|
+
*/
|
|
36
|
+
export const FORMATTING_SEPARATOR = '|'
|
|
37
|
+
|
|
38
|
+
interface EditorNodeParams {
|
|
39
|
+
readonly node: Node
|
|
40
|
+
readonly editor: HTMLElement
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** O `<code>` que envolve o cursor, se houver — a marcação de monoespaçado que damos ao texto. */
|
|
44
|
+
export function codeAncestorOf({ node, editor }: EditorNodeParams): HTMLElement | undefined {
|
|
45
|
+
let current: Node | null = node
|
|
46
|
+
|
|
47
|
+
while (current && current !== editor) {
|
|
48
|
+
if (current instanceof HTMLElement && current.tagName === 'CODE') return current
|
|
49
|
+
current = current.parentNode
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canQueryCommandState(): boolean {
|
|
56
|
+
return typeof document !== 'undefined' && typeof document.queryCommandState === 'function'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function canExecuteFormattingCommand(): boolean {
|
|
60
|
+
return typeof document !== 'undefined' && typeof document.execCommand === 'function'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isCommandActive(command: string): boolean {
|
|
64
|
+
if (!canQueryCommandState()) return false
|
|
65
|
+
try {
|
|
66
|
+
return document.queryCommandState(command)
|
|
67
|
+
} catch {
|
|
68
|
+
// Fora de um campo editável o navegador lança em vez de responder `false`.
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function activeFormattingIn(editor: HTMLElement): string {
|
|
74
|
+
const selection = typeof window !== 'undefined' ? window.getSelection() : null
|
|
75
|
+
const anchor = selection?.anchorNode
|
|
76
|
+
// Cursor fora do campo: nenhum botão aceso, senão a barra descreveria a seleção de outro lugar.
|
|
77
|
+
if (!anchor || !editor.contains(anchor)) return ''
|
|
78
|
+
|
|
79
|
+
const active: FormattingAction[] = []
|
|
80
|
+
for (const [action, command] of Object.entries(EXEC_COMMAND_BY_ACTION)) {
|
|
81
|
+
if (isCommandActive(command)) active.push(action as FormattingAction)
|
|
82
|
+
}
|
|
83
|
+
if (codeAncestorOf({ node: anchor, editor })) active.push(FORMATTING_ACTION.MONOSPACE)
|
|
84
|
+
|
|
85
|
+
return active.join(FORMATTING_SEPARATOR)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isFormattingActive({ active, action }: { active: string; action: FormattingAction }): boolean {
|
|
89
|
+
return active.split(FORMATTING_SEPARATOR).includes(action)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Liga o monoespaçado no cursor vago: abre o `<code>` e põe o cursor lá dentro, ancorado no espaço
|
|
94
|
+
* de largura zero — sem a âncora não há onde o cursor pousar, e o próximo caractere sai de fora.
|
|
95
|
+
* A âncora some na conversão para a notação do WhatsApp.
|
|
96
|
+
*/
|
|
97
|
+
export function startMonospaceAt(range: Range): void {
|
|
98
|
+
const code = document.createElement('code')
|
|
99
|
+
code.className = COMPOSER_MONOSPACE_CLASS
|
|
100
|
+
code.textContent = ZERO_WIDTH_SPACE
|
|
101
|
+
range.deleteContents()
|
|
102
|
+
range.insertNode(code)
|
|
103
|
+
placeCaretAfter(code.firstChild as Text, ZERO_WIDTH_SPACE.length)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Desliga o monoespaçado do cursor vago. Só mover o cursor para depois do `</code>` não basta — o
|
|
108
|
+
* navegador o traz de volta para dentro; é preciso uma âncora do lado de fora para ele pousar.
|
|
109
|
+
*/
|
|
110
|
+
export function exitMonospaceAfter(code: HTMLElement): void {
|
|
111
|
+
const anchor = document.createTextNode(ZERO_WIDTH_SPACE)
|
|
112
|
+
code.after(anchor)
|
|
113
|
+
placeCaretAfter(anchor, ZERO_WIDTH_SPACE.length)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Tira a marcação e devolve o texto ao redor — o que o clique com seleção dentro do código faz. */
|
|
117
|
+
export function unwrapMonospace(code: HTMLElement): void {
|
|
118
|
+
code.replaceWith(document.createTextNode(code.textContent?.replace(ZERO_WIDTH_SPACE, '') ?? ''))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function placeCaretAfter(node: Text, offset: number): void {
|
|
122
|
+
const caret = document.createRange()
|
|
123
|
+
caret.setStart(node, offset)
|
|
124
|
+
caret.collapse(true)
|
|
125
|
+
const selection = window.getSelection()
|
|
126
|
+
selection?.removeAllRanges()
|
|
127
|
+
selection?.addRange(caret)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Alterna a formatação pelo navegador. Devolve `false` quando o comando não existe no ambiente,
|
|
132
|
+
* para o campo cair no wrap manual em vez de engolir o clique.
|
|
133
|
+
*/
|
|
134
|
+
export function toggleFormattingCommand(action: FormattingAction): boolean {
|
|
135
|
+
const command = EXEC_COMMAND_BY_ACTION[action as keyof typeof EXEC_COMMAND_BY_ACTION]
|
|
136
|
+
if (!command || !canExecuteFormattingCommand()) return false
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// Sem isto o Chrome escreve `<span style="font-weight:bold">`, que não sobrevive ao htmlToWA.
|
|
140
|
+
document.execCommand('styleWithCSS', false, 'false')
|
|
141
|
+
return document.execCommand(command)
|
|
142
|
+
} catch {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { parseWhatsAppFormatting } from './whatsapp-formatting'
|
|
5
|
+
|
|
6
|
+
function render(text: string): string {
|
|
7
|
+
return renderToStaticMarkup(<>{parseWhatsAppFormatting(text)}</>)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('parseWhatsAppFormatting com marcador sem par', () => {
|
|
11
|
+
// Estes casos travavam a aba: o laço reprocessava a mesma string sem consumir o marcador.
|
|
12
|
+
it('trata o sublinhado solto de um nome de arquivo como texto', () => {
|
|
13
|
+
expect(render('IMG_2026.jpg')).toBe('<span>IMG</span><span>_</span><span>2026.jpg</span>')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('não trava com asterisco, til ou crase sem fechamento', () => {
|
|
17
|
+
expect(render('3 * 4')).toContain('*')
|
|
18
|
+
expect(render('mais ou menos ~10')).toContain('~')
|
|
19
|
+
expect(render('use ` para código')).toContain('`')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('sobrevive a uma linha só de marcadores', () => {
|
|
23
|
+
expect(render('*_~`')).toBe('<span>*</span><span>_</span><span>~</span><span>`</span>')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('parseWhatsAppFormatting com marcador fechado', () => {
|
|
28
|
+
it('continua formatando o par completo depois do marcador solto', () => {
|
|
29
|
+
expect(render('IMG_2026 *urgente*')).toContain('<strong>urgente</strong>')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('mantém negrito, itálico e tachado', () => {
|
|
33
|
+
expect(render('*a* _b_ ~c~')).toBe(
|
|
34
|
+
'<strong>a</strong><span> </span><em>b</em><span> </span><del>c</del>',
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -90,9 +90,15 @@ function parseInlineTokens(text: string): FormatToken[] {
|
|
|
90
90
|
if (remaining) result.push({ type: 'text', content: remaining })
|
|
91
91
|
break
|
|
92
92
|
}
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// Marcador sem par — o `_` de `IMG_2026.jpg` é o caso comum. Nenhuma das regras casou e o
|
|
94
|
+
// marcador está na posição zero: sem consumir esse caractere o laço reprocessa a mesma string
|
|
95
|
+
// para sempre e trava a aba inteira, porque isto roda no render de cada mensagem.
|
|
96
|
+
if (nextSpecial === 0) {
|
|
97
|
+
result.push({ type: 'text', content: remaining.slice(0, 1) })
|
|
98
|
+
remaining = remaining.slice(1)
|
|
99
|
+
continue
|
|
95
100
|
}
|
|
101
|
+
result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
|
|
96
102
|
remaining = remaining.slice(nextSpecial)
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -137,6 +143,15 @@ function unescapeHtml(text: string): string {
|
|
|
137
143
|
const CODE_TOKEN_MARK = String.fromCharCode(0xe000)
|
|
138
144
|
const CODE_TOKEN_REGEX = new RegExp(`${CODE_TOKEN_MARK}(\\d+)${CODE_TOKEN_MARK}`, 'g')
|
|
139
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Âncora invisível que o campo rico usa para pôr o cursor dentro (ou fora) de um `<code>` — não
|
|
148
|
+
* existe comando de navegador para monoespaçado, e o cursor sozinho não fica onde não há texto.
|
|
149
|
+
* Ela é do editor, nunca da mensagem: sai toda aqui, na conversão de volta.
|
|
150
|
+
*/
|
|
151
|
+
export const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b)
|
|
152
|
+
const EMPTY_CODE_REGEX = new RegExp(`<code[^>]*>[${ZERO_WIDTH_SPACE}\\s]*</code>`, 'gi')
|
|
153
|
+
const ZERO_WIDTH_SPACE_REGEX = new RegExp(ZERO_WIDTH_SPACE, 'g')
|
|
154
|
+
|
|
140
155
|
// waToHTML/htmlToWA formam um par round-trip: todo texto que sai de waToHTML deve
|
|
141
156
|
// reconstruir exatamente o original ao passar por htmlToWA (inclusive blocos de
|
|
142
157
|
// código multi-linha, que exigem distinguir ``` de ` via atributo data-wa).
|
|
@@ -156,7 +171,10 @@ export function waToHTML(text: string): string {
|
|
|
156
171
|
let html = escapeHtml(working)
|
|
157
172
|
html = html.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
|
|
158
173
|
html = html.replace(/_([^_\n]+)_/g, '<em>$1</em>')
|
|
159
|
-
|
|
174
|
+
// `<s>` e não `<del>`: o navegador se recusa a tirar o tachado de um `<del>` — o botão acendia,
|
|
175
|
+
// o clique não fazia nada e o texto continuava riscado. Com `<s>` a alternância funciona, e a
|
|
176
|
+
// linha na tela é a mesma. `<del>` continua certo na exibição da mensagem, que não é editável.
|
|
177
|
+
html = html.replace(/~([^~\n]+)~/g, '<s>$1</s>')
|
|
160
178
|
html = html.replace(/\n/g, '<br>')
|
|
161
179
|
|
|
162
180
|
html = html.replace(CODE_TOKEN_REGEX, (_match, indexStr: string) => {
|
|
@@ -175,6 +193,9 @@ export function htmlToWA(html: string): string {
|
|
|
175
193
|
if (!html) return ''
|
|
176
194
|
|
|
177
195
|
let text = html
|
|
196
|
+
// O `<code>` que o cursor abre nasce só com a âncora de largura zero. Sem nada digitado dentro,
|
|
197
|
+
// ele viraria um par de crases vazias no texto enviado.
|
|
198
|
+
text = text.replace(EMPTY_CODE_REGEX, '')
|
|
178
199
|
text = text.replace(/<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
|
|
179
200
|
`\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``
|
|
180
201
|
))
|
|
@@ -195,11 +216,15 @@ export function htmlToWA(html: string): string {
|
|
|
195
216
|
text = text.replace(/<i>(.*?)<\/i>/gi, '_$1_')
|
|
196
217
|
text = text.replace(/<del>(.*?)<\/del>/gi, '~$1~')
|
|
197
218
|
text = text.replace(/<s>(.*?)<\/s>/gi, '~$1~')
|
|
219
|
+
// O `strikeThrough` do navegador escreve `<strike>`, que não é o que geramos mas chega no campo.
|
|
220
|
+
text = text.replace(/<strike>(.*?)<\/strike>/gi, '~$1~')
|
|
198
221
|
// Compat: HTML sem os marcadores data-wa (ex: vindo de outro editor) ainda vira código inline.
|
|
199
222
|
text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
|
|
200
223
|
|
|
201
224
|
text = text.replace(/<[^>]+>/g, '')
|
|
202
225
|
text = unescapeHtml(text)
|
|
226
|
+
// A âncora do cursor sobrevive dentro do `<code>` que ganhou texto; ela é do editor, não da mensagem.
|
|
227
|
+
text = text.replace(ZERO_WIDTH_SPACE_REGEX, '')
|
|
203
228
|
return text.trim()
|
|
204
229
|
}
|
|
205
230
|
|