@paramms/chat-widget 0.1.0 → 1.0.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.
- package/README.md +138 -12
- package/dist/connection.d.ts +48 -0
- package/dist/crypto.d.ts +69 -0
- package/dist/e2e.d.ts +75 -0
- package/dist/history.d.ts +14 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +2638 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox.d.ts +20 -0
- package/dist/protocol/actions.d.ts +98 -0
- package/dist/protocol/codec.d.ts +12 -0
- package/dist/protocol/entities.d.ts +109 -0
- package/dist/protocol/frames.d.ts +248 -0
- package/dist/protocol/ids.d.ts +22 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/react.d.ts +124 -0
- package/dist/react.js +143 -0
- package/dist/react.js.map +1 -0
- package/dist/renderer.d.ts +163 -0
- package/dist/store.d.ts +48 -0
- package/package.json +31 -2
- package/build-preview.js +0 -136
- package/index.html +0 -37
- package/src/__tests__/chatlist.test.ts +0 -133
- package/src/__tests__/connection.test.ts +0 -163
- package/src/__tests__/crypto.test.ts +0 -28
- package/src/__tests__/history.test.ts +0 -91
- package/src/__tests__/ime.test.ts +0 -93
- package/src/__tests__/render.test.ts +0 -58
- package/src/__tests__/render_new.test.ts +0 -441
- package/src/__tests__/store.test.ts +0 -86
- package/src/__tests__/x3dh.test.ts +0 -204
- package/src/connection.ts +0 -133
- package/src/crypto.ts +0 -252
- package/src/e2e.ts +0 -161
- package/src/history.ts +0 -43
- package/src/index.ts +0 -380
- package/src/outbox.ts +0 -58
- package/src/protocol/actions.ts +0 -114
- package/src/protocol/codec.ts +0 -35
- package/src/protocol/entities.ts +0 -104
- package/src/protocol/frames.ts +0 -86
- package/src/protocol/ids.ts +0 -27
- package/src/protocol/index.ts +0 -5
- package/src/react.tsx +0 -37
- package/src/renderer.ts +0 -906
- package/src/store.ts +0 -207
- package/tsconfig.json +0 -33
- package/vercel.json +0 -22
- package/vite.config.ts +0 -26
- package/vitest.config.ts +0 -2
package/src/renderer.ts
DELETED
|
@@ -1,906 +0,0 @@
|
|
|
1
|
-
import type { ManifestAction, MessageContent, AnnotationStroke } from './protocol/index.js'
|
|
2
|
-
import type { ChatStore, RenderMessage } from './store.js'
|
|
3
|
-
|
|
4
|
-
export interface WidgetConfig {
|
|
5
|
-
subject?: { title?: string; subtitle?: string; tags?: string[]; status?: string; ownerLabel?: string }
|
|
6
|
-
quickReplies?: string[]
|
|
7
|
-
accent?: string
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface RendererHandlers {
|
|
11
|
-
onSend(text: string): void
|
|
12
|
-
onAttach?(file: File): void
|
|
13
|
-
onInvoke(actionId: string, inputs?: Record<string, unknown>): void
|
|
14
|
-
onTyping(isTyping: boolean): void
|
|
15
|
-
onReadUpTo(seq: number): void
|
|
16
|
-
onReact?(messageId: string, emoji: string, remove: boolean): void
|
|
17
|
-
onCsat?(score: number): void
|
|
18
|
-
onLoadMore?(): void
|
|
19
|
-
onEdit?(messageId: string, newText: string): void
|
|
20
|
-
onDelete?(messageId: string): void
|
|
21
|
-
/** Co-browsing: a freehand stroke was completed on the shared whiteboard. */
|
|
22
|
-
onAnnotate?(stroke: Omit<AnnotationStroke, 'by'>): void
|
|
23
|
-
/** Co-browsing: clear the shared whiteboard for everyone. */
|
|
24
|
-
onAnnotateClear?(): void
|
|
25
|
-
/** Translate a message's text for display. Return null if unavailable —
|
|
26
|
-
* the renderer shows a brief "unavailable" hint and leaves the original. */
|
|
27
|
-
onTranslate?(text: string): Promise<string | null>
|
|
28
|
-
/** Multi-subject chat list (e.g. a marketplace with one thread per item):
|
|
29
|
-
* fetch the guest's other conversations. Omit to hide the list button
|
|
30
|
-
* entirely — single-conversation embeds don't need this. */
|
|
31
|
-
onListChats?(): Promise<ChatListEntry[]>
|
|
32
|
-
/** Switch the active conversation to a different one from the list —
|
|
33
|
-
* effectively a re-open with a different subjectId. */
|
|
34
|
-
onSwitchChat?(entry: ChatListEntry): void
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface ChatListEntry {
|
|
38
|
-
id: string
|
|
39
|
-
subjectId?: string
|
|
40
|
-
subjectTitle?: string
|
|
41
|
-
state: string
|
|
42
|
-
updatedAt: number
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const STYLE_ID = 'objectchat-widget-styles'
|
|
46
|
-
const REACTION_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
|
47
|
-
const CSS = `
|
|
48
|
-
.ocw { --ocw-accent:#f5713c; --ocw-bg:#f3efe9; --ocw-card:#fff; --ocw-line:#ececec; --ocw-ink:#1c1b1a; --ocw-mut:#9b9690;
|
|
49
|
-
position:relative;
|
|
50
|
-
display:flex; flex-direction:column; height:100%; min-height:520px; background:var(--ocw-bg);
|
|
51
|
-
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; color:var(--ocw-ink); overflow:hidden; }
|
|
52
|
-
.ocw-head { display:flex; align-items:center; gap:10px; padding:12px 14px; background:var(--ocw-card); border-bottom:1px solid var(--ocw-line); }
|
|
53
|
-
.ocw-avatar { width:34px; height:34px; border-radius:50%; background:#ffe9d6; display:flex; align-items:center; justify-content:center; font-size:17px; flex:none; }
|
|
54
|
-
.ocw-head-main { flex:1; min-width:0; }
|
|
55
|
-
.ocw-head-name { font-weight:700; font-size:15px; }
|
|
56
|
-
.ocw-head-meta { color:var(--ocw-mut); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
57
|
-
.ocw-badge { font-size:12px; font-weight:600; color:#15803d; background:#e8f6ec; border-radius:999px; padding:3px 10px; }
|
|
58
|
-
.ocw-e2e { font-size:11px; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:999px; padding:3px 9px; align-items:center; }
|
|
59
|
-
.ocw-menu { color:var(--ocw-mut); width:30px; height:30px; border-radius:50%; border:1px solid var(--ocw-line); background:#fff; cursor:pointer; }
|
|
60
|
-
.ocw-chiprow { display:flex; gap:8px; padding:10px 12px; background:var(--ocw-card); border-bottom:1px solid var(--ocw-line); overflow-x:auto; }
|
|
61
|
-
.ocw-chip { flex:none; display:flex; align-items:center; gap:6px; border:1px solid #e3ded7; background:#fff; border-radius:999px; padding:7px 13px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
|
|
62
|
-
.ocw-chip:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
|
|
63
|
-
.ocw-scroll { flex:1; min-height:0; overflow-y:auto; padding:16px 14px; display:flex; flex-direction:column; gap:10px; }
|
|
64
|
-
.ocw-subject { background:var(--ocw-card); border:1px solid var(--ocw-line); border-radius:16px; padding:14px 16px; }
|
|
65
|
-
.ocw-subject-title { font-weight:700; font-size:16px; margin-bottom:2px; }
|
|
66
|
-
.ocw-subject-sub { color:var(--ocw-mut); font-size:13px; margin-bottom:10px; }
|
|
67
|
-
.ocw-tags { display:flex; flex-wrap:wrap; gap:6px; }
|
|
68
|
-
.ocw-tag { font-size:12px; color:#5b554e; background:#efeae3; border-radius:8px; padding:4px 10px; }
|
|
69
|
-
.ocw-row { display:flex; align-items:flex-end; gap:8px; max-width:86%; }
|
|
70
|
-
.ocw-row.mine { align-self:flex-end; flex-direction:row-reverse; }
|
|
71
|
-
.ocw-row.theirs { align-self:flex-start; }
|
|
72
|
-
.ocw-dot { width:26px; height:26px; border-radius:50%; background:var(--ocw-accent); color:#fff; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; flex:none; }
|
|
73
|
-
.ocw-bubble { padding:10px 14px; border-radius:18px; font-size:14.5px; line-height:1.4; word-wrap:break-word; }
|
|
74
|
-
.theirs .ocw-bubble { background:#fff; border:1px solid var(--ocw-line); border-bottom-left-radius:6px; }
|
|
75
|
-
.mine .ocw-bubble { background:var(--ocw-accent); color:#fff; border-bottom-right-radius:6px; }
|
|
76
|
-
.ocw-sys { align-self:center; color:var(--ocw-mut); font-size:12.5px; font-style:italic; text-align:center; max-width:90%; }
|
|
77
|
-
.ocw-bot .ocw-bubble { background:#f0fdf4; border-color:#cdebd6; }
|
|
78
|
-
.ocw-note .ocw-bubble { background:#fffbeb; border:1.5px dashed #f59e0b; color:#78350f; border-radius:12px !important; }
|
|
79
|
-
.ocw-note .ocw-bubble::before { content:'🔒 Note — '; font-size:11px; font-weight:700; color:#b45309; display:block; margin-bottom:3px; letter-spacing:.3px; }
|
|
80
|
-
.ocw-time { font-size:10.5px; color:var(--ocw-mut); margin-top:3px; }
|
|
81
|
-
.mine .ocw-meta { text-align:right; }
|
|
82
|
-
.ocw-tick { margin-left:4px; }
|
|
83
|
-
.ocw-deleted { font-style:italic; color:var(--ocw-mut); }
|
|
84
|
-
.ocw-edited { font-size:10px; color:var(--ocw-mut); margin-left:4px; }
|
|
85
|
-
.ocw-react { font-size:12px; margin-top:3px; display:flex; flex-wrap:wrap; gap:3px; }
|
|
86
|
-
.ocw-react-pill { display:inline-flex; align-items:center; gap:3px; border:1px solid #e3ded7; border-radius:999px; padding:2px 7px; background:#fff; font-size:12px; cursor:pointer; }
|
|
87
|
-
.ocw-react-pill:hover { border-color:var(--ocw-accent); }
|
|
88
|
-
.ocw-react-pill.mine { border-color:var(--ocw-accent); background:#fff8f5; }
|
|
89
|
-
.ocw-react-add { display:none; position:absolute; bottom:100%; left:0; margin-bottom:4px; background:#fff; border:1px solid #e3ded7; border-radius:14px; padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.12); display:flex; gap:4px; z-index:10; }
|
|
90
|
-
.ocw-react-wrap { position:relative; }
|
|
91
|
-
.ocw-react-wrap:not(:hover) .ocw-react-picker { display:none; }
|
|
92
|
-
.ocw-react-picker { position:absolute; bottom:calc(100% + 4px); left:0; background:#fff; border:1px solid #e3ded7; border-radius:14px; padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.12); display:flex; gap:4px; z-index:10; white-space:nowrap; }
|
|
93
|
-
.ocw-react-picker button { background:none; border:none; font-size:16px; cursor:pointer; padding:2px; border-radius:6px; }
|
|
94
|
-
.ocw-react-picker button:hover { background:#f3efe9; }
|
|
95
|
-
.ocw-react-btn { background:none; border:1px solid #e3ded7; border-radius:999px; padding:2px 7px; font-size:12px; cursor:pointer; color:var(--ocw-mut); }
|
|
96
|
-
.ocw-react-btn:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
|
|
97
|
-
.ocw-msg-menu { position:absolute; top:0; right:0; display:none; gap:3px; }
|
|
98
|
-
.ocw-row.mine:hover .ocw-msg-menu { display:flex; }
|
|
99
|
-
.ocw-row.theirs:hover .ocw-msg-menu { display:flex; left:0; right:auto; }
|
|
100
|
-
.ocw-msg-menu button { background:#fff; border:1px solid #e3ded7; border-radius:6px; font-size:11px; padding:2px 6px; cursor:pointer; color:var(--ocw-mut); }
|
|
101
|
-
.ocw-msg-menu button:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
|
|
102
|
-
.ocw-msg-menu button.del:hover { border-color:#e74c3c; color:#e74c3c; }
|
|
103
|
-
.ocw-bubble-wrap { position:relative; }
|
|
104
|
-
.ocw-seen { font-size:10.5px; color:var(--ocw-mut); }
|
|
105
|
-
.ocw-appt { background:#f0f7ff; border:1px solid #c7deff; border-radius:12px; padding:12px 14px; max-width:260px; }
|
|
106
|
-
.ocw-appt-title { font-weight:700; font-size:14px; margin-bottom:4px; }
|
|
107
|
-
.ocw-appt-time { font-size:12px; color:#1d4ed8; margin-bottom:4px; }
|
|
108
|
-
.ocw-appt-loc { font-size:12px; color:var(--ocw-mut); margin-bottom:4px; }
|
|
109
|
-
.ocw-appt-desc { font-size:12px; color:var(--ocw-mut); margin-bottom:10px; white-space:pre-wrap; }
|
|
110
|
-
.ocw-appt-links { display:flex; flex-direction:column; gap:6px; }
|
|
111
|
-
.ocw-appt-btn { display:block; text-align:center; padding:8px 12px; border-radius:8px; font-size:13px; font-weight:600; text-decoration:none; background:var(--ocw-accent); color:#fff; }
|
|
112
|
-
.ocw-appt-btn-sec { background:#fff; color:var(--ocw-accent); border:1px solid var(--ocw-accent); }
|
|
113
|
-
.ocw-conn-status { font-size:10px; color:var(--ocw-mut); margin-left:4px; }
|
|
114
|
-
.ocw-conn-status.warn { color:#e67e22; }
|
|
115
|
-
.ocw-load-more { display:block; width:100%; background:none; border:1px solid #e3ded7; border-radius:10px; padding:6px 0; font-size:12px; color:var(--ocw-mut); cursor:pointer; margin-bottom:8px; }
|
|
116
|
-
.ocw-load-more:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
|
|
117
|
-
.ocw-offline { margin:20px 14px; padding:20px; background:#fff; border:1px solid #e3ded7; border-radius:16px; text-align:center; }
|
|
118
|
-
.ocw-offline-icon { font-size:32px; margin-bottom:8px; }
|
|
119
|
-
.ocw-offline-title { font-weight:700; font-size:16px; margin-bottom:6px; }
|
|
120
|
-
.ocw-offline-msg { font-size:13px; color:var(--ocw-mut); margin-bottom:16px; }
|
|
121
|
-
.ocw-offline-form { display:flex; flex-direction:column; gap:8px; text-align:left; }
|
|
122
|
-
.ocw-offline-input { border:1px solid #e3ded7; border-radius:10px; padding:10px 12px; font-size:13px; font-family:inherit; }
|
|
123
|
-
.ocw-offline-input:focus { outline:none; border-color:var(--ocw-accent); }
|
|
124
|
-
.ocw-offline-submit { background:var(--ocw-accent); color:#fff; border:none; border-radius:10px; padding:11px; font-size:14px; font-weight:600; cursor:pointer; }
|
|
125
|
-
.ocw-offline-thanks { font-size:14px; color:#15803d; font-weight:600; }
|
|
126
|
-
.ocw-csat-title { font-size:13px; font-weight:600; margin-bottom:8px; }
|
|
127
|
-
.ocw-csat-stars { display:flex; gap:6px; }
|
|
128
|
-
.ocw-csat-star { background:none; border:none; font-size:22px; cursor:pointer; padding:2px; opacity:.4; transition:opacity .15s; }
|
|
129
|
-
.ocw-csat-star:hover, .ocw-csat-star.lit { opacity:1; }
|
|
130
|
-
.ocw-csat-done { font-size:12px; color:var(--ocw-mut); margin-top:6px; }
|
|
131
|
-
|
|
132
|
-
.ocw-typing { height:18px; padding:0 16px 4px; font-size:12px; color:var(--ocw-mut); }
|
|
133
|
-
.ocw-quick { display:flex; gap:8px; padding:8px 12px 0; overflow-x:auto; }
|
|
134
|
-
.ocw-quick button { flex:none; border:1px solid #e3ded7; background:#fff; border-radius:999px; padding:7px 14px; font-size:13px; cursor:pointer; color:#444; white-space:nowrap; }
|
|
135
|
-
.ocw-quick button:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
|
|
136
|
-
.ocw-form-host:empty { display:none; }
|
|
137
|
-
.ocw-form { margin:6px 12px 0; padding:12px; background:var(--ocw-card); border:1px solid var(--ocw-line); border-radius:14px; }
|
|
138
|
-
.ocw-form-title { font-weight:700; font-size:14px; margin-bottom:8px; }
|
|
139
|
-
.ocw-form-row { display:flex; flex-direction:column; gap:3px; margin-bottom:8px; }
|
|
140
|
-
.ocw-form-lbl { font-size:12px; color:var(--ocw-mut); }
|
|
141
|
-
.ocw-form-input { border:1px solid #e3ded7; border-radius:9px; padding:9px 11px; font:inherit; font-size:14px; outline:none; }
|
|
142
|
-
.ocw-form-input:focus { border-color:var(--ocw-accent); }
|
|
143
|
-
.ocw-form-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:4px; }
|
|
144
|
-
.ocw-form-cancel { background:none; border:none; color:var(--ocw-mut); font-size:13px; cursor:pointer; padding:8px 10px; }
|
|
145
|
-
.ocw-form-submit { background:var(--ocw-accent); color:#fff; border:none; border-radius:999px; padding:8px 18px; font-size:13px; font-weight:600; cursor:pointer; }
|
|
146
|
-
.ocw-modal { position:absolute; inset:0; background:rgba(20,18,16,.42); display:flex; align-items:center; justify-content:center; z-index:50; }
|
|
147
|
-
.ocw-modal-card { background:#fff; border-radius:16px; padding:20px; width:78%; max-width:300px; box-shadow:0 14px 44px rgba(0,0,0,.22); }
|
|
148
|
-
.ocw-modal-title { font-weight:700; font-size:16px; margin-bottom:6px; }
|
|
149
|
-
.ocw-modal-body { color:var(--ocw-mut); font-size:14px; margin-bottom:16px; }
|
|
150
|
-
.ocw-modal-actions { display:flex; justify-content:flex-end; gap:8px; }
|
|
151
|
-
.ocw-modal-cancel { background:none; border:none; color:var(--ocw-mut); font-size:14px; cursor:pointer; padding:9px 12px; }
|
|
152
|
-
.ocw-modal-ok { background:var(--ocw-accent); color:#fff; border:none; border-radius:999px; padding:9px 20px; font-size:14px; font-weight:600; cursor:pointer; }
|
|
153
|
-
.ocw-input { display:flex; align-items:center; gap:10px; padding:12px; }
|
|
154
|
-
.ocw-footer { text-align:center; font-size:11px; color:var(--ocw-mut); padding:6px 0 8px; }
|
|
155
|
-
.ocw-footer a { color:var(--ocw-mut); text-decoration:none; font-weight:600; }
|
|
156
|
-
.ocw-footer a:hover { color:var(--ocw-accent); }
|
|
157
|
-
.ocw-attach { background:none;border:none;cursor:pointer;font-size:18px;padding:4px 6px;opacity:.6;flex-none; }
|
|
158
|
-
.ocw-attach:hover { opacity:1; }
|
|
159
|
-
.ocw-input textarea { flex:1; resize:none; border:1px solid #e3ded7; border-radius:22px; padding:11px 16px; font:inherit; font-size:14px; background:#fff; outline:none; max-height:96px; }
|
|
160
|
-
.ocw-input textarea:focus { border-color:var(--ocw-accent); }
|
|
161
|
-
.ocw-sendbtn { width:42px; height:42px; border-radius:50%; border:none; background:var(--ocw-accent); color:#fff; font-size:18px; cursor:pointer; flex:none; display:flex; align-items:center; justify-content:center; }
|
|
162
|
-
.ocw-sendbtn:disabled { opacity:.5; cursor:default; }
|
|
163
|
-
.ocw-cobrowse-btn { display:none; }
|
|
164
|
-
.ocw-cobrowse-btn.show { display:inline-flex; }
|
|
165
|
-
.ocw-cobrowse-btn.on { color:var(--ocw-accent); border-color:var(--ocw-accent); }
|
|
166
|
-
.ocw-cobrowse-canvas { position:absolute; inset:0; z-index:40; touch-action:none; display:none; }
|
|
167
|
-
.ocw-cobrowse-canvas.active { display:block; cursor:crosshair; }
|
|
168
|
-
.ocw-cobrowse-toolbar { position:absolute; top:8px; right:8px; z-index:41; display:none; gap:6px; background:rgba(255,255,255,.92); border-radius:999px; padding:5px 8px; box-shadow:0 2px 10px rgba(0,0,0,.12); }
|
|
169
|
-
.ocw-cobrowse-toolbar.active { display:flex; align-items:center; }
|
|
170
|
-
.ocw-cobrowse-swatch { width:18px; height:18px; border-radius:50%; border:2px solid transparent; cursor:pointer; padding:0; }
|
|
171
|
-
.ocw-cobrowse-swatch.sel { border-color:#1c1b1a; }
|
|
172
|
-
.ocw-cobrowse-clear { background:none; border:1px solid #e3ded7; border-radius:999px; font-size:11px; padding:3px 8px; cursor:pointer; color:var(--ocw-mut); }
|
|
173
|
-
.ocw-cobrowse-clear:hover { border-color:#e74c3c; color:#e74c3c; }
|
|
174
|
-
.ocw-cobrowse-hint { position:absolute; bottom:8px; left:8px; z-index:41; font-size:11px; color:var(--ocw-mut); background:rgba(255,255,255,.9); border-radius:8px; padding:3px 8px; display:none; }
|
|
175
|
-
.ocw-cobrowse-hint.active { display:block; }
|
|
176
|
-
.ocw-chatlist-btn { background:none; border:none; cursor:pointer; font-size:18px; color:var(--ocw-mut); padding:4px 6px; border-radius:8px; }
|
|
177
|
-
.ocw-chatlist-btn:hover { background:var(--ocw-bg); }
|
|
178
|
-
.ocw-chatlist-panel { position:absolute; inset:0; z-index:50; background:var(--ocw-card); display:flex; flex-direction:column; transform:translateX(100%); transition:transform .2s ease; }
|
|
179
|
-
.ocw-chatlist-panel.open { transform:translateX(0); }
|
|
180
|
-
.ocw-chatlist-head { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--ocw-line); }
|
|
181
|
-
.ocw-chatlist-back { background:none; border:none; cursor:pointer; font-size:18px; color:var(--ocw-ink); padding:2px 6px; }
|
|
182
|
-
.ocw-chatlist-title { font-weight:700; font-size:15px; flex:1; }
|
|
183
|
-
.ocw-chatlist-body { flex:1; overflow-y:auto; }
|
|
184
|
-
.ocw-chatlist-empty { padding:40px 20px; text-align:center; color:var(--ocw-mut); font-size:13px; }
|
|
185
|
-
.ocw-chatlist-item { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--ocw-line); cursor:pointer; background:none; border-left:none; border-right:none; width:100%; text-align:left; }
|
|
186
|
-
.ocw-chatlist-item:hover { background:var(--ocw-bg); }
|
|
187
|
-
.ocw-chatlist-item.active { background:var(--ocw-bg); }
|
|
188
|
-
.ocw-chatlist-ic { width:36px; height:36px; border-radius:50%; background:var(--ocw-bg); display:flex; align-items:center; justify-content:center; font-size:16px; flex:none; }
|
|
189
|
-
.ocw-chatlist-main { flex:1; min-width:0; }
|
|
190
|
-
.ocw-chatlist-name { font-weight:600; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
191
|
-
.ocw-chatlist-state { font-size:11px; color:var(--ocw-mut); margin-top:2px; }
|
|
192
|
-
.ocw-chatlist-time { font-size:11px; color:var(--ocw-mut); flex:none; }
|
|
193
|
-
.ocw-translate-btn { position:absolute; bottom:2px; right:-26px; background:#fff; border:1px solid #e3ded7; border-radius:50%; width:22px; height:22px; font-size:11px; cursor:pointer; color:var(--ocw-mut); display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity .15s; padding:0; }
|
|
194
|
-
.ocw-row.theirs .ocw-translate-btn { right:auto; left:-26px; }
|
|
195
|
-
.ocw-bubble-wrap:hover .ocw-translate-btn { opacity:1; }
|
|
196
|
-
.ocw-translated-tag { font-size:10px; color:var(--ocw-mut); margin-top:2px; }
|
|
197
|
-
`
|
|
198
|
-
|
|
199
|
-
function injectStyles(): void {
|
|
200
|
-
if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
|
|
201
|
-
const s = document.createElement('style'); s.id = STYLE_ID; s.textContent = CSS; document.head.appendChild(s)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?: string): HTMLElementTagNameMap[K] {
|
|
205
|
-
const n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n
|
|
206
|
-
}
|
|
207
|
-
function fmtTime(ts: number): string {
|
|
208
|
-
try { return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } catch { return '' }
|
|
209
|
-
}
|
|
210
|
-
function timeAgo(ts: number): string {
|
|
211
|
-
const sec = Math.floor((Date.now() - ts) / 1000)
|
|
212
|
-
if (sec < 60) return 'now'
|
|
213
|
-
if (sec < 3600) return `${Math.floor(sec / 60)}m`
|
|
214
|
-
if (sec < 86400) return `${Math.floor(sec / 3600)}h`
|
|
215
|
-
return `${Math.floor(sec / 86400)}d`
|
|
216
|
-
}
|
|
217
|
-
function contentText(c: MessageContent): string {
|
|
218
|
-
switch (c.kind) {
|
|
219
|
-
case 'text': return c.text
|
|
220
|
-
case 'system': return typeof c.data?.['message'] === 'string' ? String(c.data['message']) : c.event
|
|
221
|
-
case 'card': return [c.title, c.body].filter(Boolean).join(' — ')
|
|
222
|
-
case 'attachment': return c.name ?? c.url
|
|
223
|
-
case 'form': return c.prompt
|
|
224
|
-
case 'appointment': return `📅 ${c.title} — ${new Date(c.startIso).toLocaleString()}`
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Renders a ChatStore into a host element in the Image-1 layout: header →
|
|
229
|
-
* subject card → action chips → chat → quick replies → input. Self-injects its
|
|
230
|
-
* stylesheet so it looks right wherever it mounts. Accent comes from the
|
|
231
|
-
* dashboard-authored profile theme (via the manifest), falling back to config. */
|
|
232
|
-
export class Renderer {
|
|
233
|
-
private readonly scroll: HTMLElement
|
|
234
|
-
private readonly chips: HTMLElement
|
|
235
|
-
private readonly typing: HTMLElement
|
|
236
|
-
private readonly quick: HTMLElement
|
|
237
|
-
private readonly formHost: HTMLElement
|
|
238
|
-
private readonly csatPanel: HTMLElement
|
|
239
|
-
private readonly offlinePanel: HTMLElement
|
|
240
|
-
private readonly footer: HTMLElement
|
|
241
|
-
private readonly input: HTMLTextAreaElement
|
|
242
|
-
private readonly subjectCard: HTMLElement
|
|
243
|
-
private readonly e2eBadge: HTMLElement
|
|
244
|
-
private readonly statusBadge: HTMLElement
|
|
245
|
-
private readonly headerName: HTMLElement
|
|
246
|
-
private readonly connStatus: HTMLElement
|
|
247
|
-
private typingTimer: ReturnType<typeof setTimeout> | null = null
|
|
248
|
-
private csatSubmitted = false
|
|
249
|
-
|
|
250
|
-
// ── Co-browsing (shared whiteboard) ─────────────────────────────────────
|
|
251
|
-
private readonly cobrowseBtn: HTMLButtonElement
|
|
252
|
-
private readonly cobrowseCanvas: HTMLCanvasElement
|
|
253
|
-
private readonly cobrowseToolbar: HTMLElement
|
|
254
|
-
private readonly cobrowseHint: HTMLElement
|
|
255
|
-
private cobrowseActive = false
|
|
256
|
-
private cobrowseColor = '#f5713c'
|
|
257
|
-
private lastAnnotationVersion = -1
|
|
258
|
-
private storeRef: ChatStore | null = null
|
|
259
|
-
// ── Live translation ──────────────────────────────────────────────────-
|
|
260
|
-
private readonly translationCache = new Map<string, string>()
|
|
261
|
-
private readonly showingTranslation = new Set<string>()
|
|
262
|
-
|
|
263
|
-
// ── Multi-subject chat list (e.g. marketplace: one thread per listing) ──
|
|
264
|
-
private readonly chatListPanel: HTMLElement
|
|
265
|
-
private readonly chatListBody: HTMLElement
|
|
266
|
-
private chatListBtn: HTMLButtonElement | null = null
|
|
267
|
-
private activeChatId: string | null = null
|
|
268
|
-
|
|
269
|
-
constructor(
|
|
270
|
-
private readonly root: HTMLElement,
|
|
271
|
-
private readonly me: string,
|
|
272
|
-
private readonly h: RendererHandlers,
|
|
273
|
-
private readonly cfg: WidgetConfig = {},
|
|
274
|
-
) {
|
|
275
|
-
injectStyles()
|
|
276
|
-
root.classList.add('ocw')
|
|
277
|
-
if (cfg.accent) root.style.setProperty('--ocw-accent', cfg.accent)
|
|
278
|
-
|
|
279
|
-
// Header
|
|
280
|
-
const head = el('div', 'ocw-head')
|
|
281
|
-
head.append(el('div', 'ocw-avatar', '🧑'))
|
|
282
|
-
const hm = el('div', 'ocw-head-main')
|
|
283
|
-
this.headerName = el('div', 'ocw-head-name', cfg.subject?.ownerLabel ?? cfg.subject?.title ?? '')
|
|
284
|
-
hm.append(this.headerName)
|
|
285
|
-
if (cfg.subject?.subtitle) hm.append(el('div', 'ocw-head-meta', cfg.subject.subtitle))
|
|
286
|
-
head.append(hm)
|
|
287
|
-
this.statusBadge = el('span', 'ocw-badge', cfg.subject?.status ?? '')
|
|
288
|
-
if (!cfg.subject?.status) this.statusBadge.style.display = 'none'
|
|
289
|
-
head.append(this.statusBadge)
|
|
290
|
-
this.e2eBadge = el('span', 'ocw-e2e', '🔒 E2E'); this.e2eBadge.style.display = 'none'; head.append(this.e2eBadge)
|
|
291
|
-
this.connStatus = el('span', 'ocw-conn-status'); this.connStatus.style.display = 'none'; head.append(this.connStatus)
|
|
292
|
-
this.cobrowseBtn = el('button', 'ocw-menu ocw-cobrowse-btn', '🖍') as HTMLButtonElement
|
|
293
|
-
this.cobrowseBtn.title = 'Shared whiteboard — draw to point things out together'
|
|
294
|
-
this.cobrowseBtn.addEventListener('click', () => { this.cobrowseActive = !this.cobrowseActive; this.updateCobrowseUI() })
|
|
295
|
-
head.append(this.cobrowseBtn)
|
|
296
|
-
if (this.h.onListChats) {
|
|
297
|
-
this.chatListBtn = el('button', 'ocw-chatlist-btn', '💬') as HTMLButtonElement
|
|
298
|
-
this.chatListBtn.title = 'Your conversations'
|
|
299
|
-
this.chatListBtn.addEventListener('click', () => this.openChatList())
|
|
300
|
-
head.append(this.chatListBtn)
|
|
301
|
-
}
|
|
302
|
-
head.append(el('button', 'ocw-menu', '⋯'))
|
|
303
|
-
|
|
304
|
-
// Action chips (filled in render)
|
|
305
|
-
this.chips = el('div', 'ocw-chiprow')
|
|
306
|
-
|
|
307
|
-
// Scroll area with optional subject card + messages
|
|
308
|
-
this.scroll = el('div', 'ocw-scroll')
|
|
309
|
-
this.subjectCard = el('div', 'ocw-subject')
|
|
310
|
-
this.typing = el('div', 'ocw-typing')
|
|
311
|
-
this.quick = el('div', 'ocw-quick')
|
|
312
|
-
for (const q of cfg.quickReplies ?? []) {
|
|
313
|
-
const b = el('button', undefined, q); b.addEventListener('click', () => this.h.onSend(q)); this.quick.append(b)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Input
|
|
317
|
-
this.formHost = el('div', 'ocw-form-host')
|
|
318
|
-
this.csatPanel = el('div', 'ocw-csat'); this.csatPanel.style.display = 'none'
|
|
319
|
-
this.offlinePanel = el('div', 'ocw-offline'); this.offlinePanel.style.display = 'none'
|
|
320
|
-
this.input = el('textarea', undefined); this.input.rows = 1; this.input.placeholder = 'Message…'
|
|
321
|
-
const sendBtn = el('button', 'ocw-sendbtn', '➤')
|
|
322
|
-
sendBtn.addEventListener('click', () => this.flushSend())
|
|
323
|
-
this.input.addEventListener('keydown', (e) => {
|
|
324
|
-
// Guard against IME composition (Korean/Japanese/Chinese input): while
|
|
325
|
-
// the user is selecting a candidate from the IME's suggestion list,
|
|
326
|
-
// pressing Enter to CONFIRM the candidate also fires a keydown with
|
|
327
|
-
// key === 'Enter'. Without this check, that confirmation keystroke was
|
|
328
|
-
// being treated as "send the message" — firing early with a partial
|
|
329
|
-
// composition, and then firing again on the real Enter press with
|
|
330
|
-
// whatever text was left, producing two bubbles for one message
|
|
331
|
-
// (e.g. typing "음식" sends "음식" then "식").
|
|
332
|
-
// e.isComposing covers most browsers; keyCode 229 is the long-standing
|
|
333
|
-
// fallback for browsers/IMEs that don't set isComposing reliably.
|
|
334
|
-
if (e.isComposing || e.keyCode === 229) return
|
|
335
|
-
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.flushSend() } else this.signalTyping()
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
const attachBtn = el('button', 'ocw-attach', '📎'); attachBtn.title = 'Attach image or file'
|
|
339
|
-
const fileInput = document.createElement('input'); fileInput.type = 'file'
|
|
340
|
-
fileInput.accept = 'image/*,.pdf,.txt,.doc,.docx'; fileInput.style.display = 'none'
|
|
341
|
-
attachBtn.addEventListener('click', () => fileInput.click())
|
|
342
|
-
fileInput.addEventListener('change', () => { if (fileInput.files?.[0] && this.h.onAttach) this.h.onAttach(fileInput.files[0]); fileInput.value = '' })
|
|
343
|
-
|
|
344
|
-
const inputRow = el('div', 'ocw-input'); inputRow.append(attachBtn, fileInput, this.input, sendBtn)
|
|
345
|
-
|
|
346
|
-
const footer = el('div', 'ocw-footer')
|
|
347
|
-
footer.innerHTML = 'Powered by <a href="https://relay.paramms.com" target="_blank" rel="noopener">Relay</a>'
|
|
348
|
-
this.footer = footer
|
|
349
|
-
|
|
350
|
-
root.append(head, this.chips, this.scroll, this.typing, this.quick, this.formHost, this.csatPanel, this.offlinePanel, inputRow, this.footer)
|
|
351
|
-
|
|
352
|
-
// Co-browsing overlay: a freehand canvas layered over the whole widget.
|
|
353
|
-
this.cobrowseCanvas = el('canvas', 'ocw-cobrowse-canvas') as HTMLCanvasElement
|
|
354
|
-
this.cobrowseToolbar = el('div', 'ocw-cobrowse-toolbar')
|
|
355
|
-
for (const c of ['#f5713c', '#1c1b1a', '#2563eb', '#16a34a', '#dc2626']) {
|
|
356
|
-
const sw = el('button', 'ocw-cobrowse-swatch') as HTMLButtonElement
|
|
357
|
-
sw.style.background = c
|
|
358
|
-
sw.type = 'button'
|
|
359
|
-
if (c === this.cobrowseColor) sw.classList.add('sel')
|
|
360
|
-
sw.addEventListener('click', () => {
|
|
361
|
-
this.cobrowseColor = c
|
|
362
|
-
for (const n of this.cobrowseToolbar.querySelectorAll('.ocw-cobrowse-swatch')) n.classList.remove('sel')
|
|
363
|
-
sw.classList.add('sel')
|
|
364
|
-
})
|
|
365
|
-
this.cobrowseToolbar.append(sw)
|
|
366
|
-
}
|
|
367
|
-
const cobrowseClear = el('button', 'ocw-cobrowse-clear', 'Clear')
|
|
368
|
-
cobrowseClear.addEventListener('click', () => this.h.onAnnotateClear?.())
|
|
369
|
-
this.cobrowseToolbar.append(cobrowseClear)
|
|
370
|
-
this.cobrowseHint = el('div', 'ocw-cobrowse-hint', '🖍 Draw to point things out — visible to both sides')
|
|
371
|
-
root.append(this.cobrowseCanvas, this.cobrowseToolbar, this.cobrowseHint)
|
|
372
|
-
this.bindCobrowsePointerEvents()
|
|
373
|
-
|
|
374
|
-
// Multi-subject chat list overlay — slides in from the right, same
|
|
375
|
-
// layer as the co-browse canvas. Built once; populated on open.
|
|
376
|
-
this.chatListPanel = el('div', 'ocw-chatlist-panel')
|
|
377
|
-
const clHead = el('div', 'ocw-chatlist-head')
|
|
378
|
-
const clBack = el('button', 'ocw-chatlist-back', '←')
|
|
379
|
-
clBack.addEventListener('click', () => this.closeChatList())
|
|
380
|
-
clHead.append(clBack, el('div', 'ocw-chatlist-title', 'Your conversations'))
|
|
381
|
-
this.chatListBody = el('div', 'ocw-chatlist-body')
|
|
382
|
-
this.chatListPanel.append(clHead, this.chatListBody)
|
|
383
|
-
root.append(this.chatListPanel)
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
private openChatList(): void {
|
|
387
|
-
this.chatListPanel.classList.add('open')
|
|
388
|
-
this.chatListBody.replaceChildren(el('div', 'ocw-chatlist-empty', 'Loading…'))
|
|
389
|
-
this.h.onListChats?.().then((entries) => {
|
|
390
|
-
this.chatListBody.replaceChildren()
|
|
391
|
-
if (!entries.length) {
|
|
392
|
-
this.chatListBody.append(el('div', 'ocw-chatlist-empty', 'No conversations yet.'))
|
|
393
|
-
return
|
|
394
|
-
}
|
|
395
|
-
for (const entry of entries) {
|
|
396
|
-
const item = el('button', `ocw-chatlist-item${entry.id === this.activeChatId ? ' active' : ''}`)
|
|
397
|
-
item.append(el('div', 'ocw-chatlist-ic', '💬'))
|
|
398
|
-
const main = el('div', 'ocw-chatlist-main')
|
|
399
|
-
main.append(el('div', 'ocw-chatlist-name', entry.subjectTitle ?? 'General enquiry'))
|
|
400
|
-
main.append(el('div', 'ocw-chatlist-state', entry.state))
|
|
401
|
-
item.append(main)
|
|
402
|
-
item.append(el('div', 'ocw-chatlist-time', timeAgo(entry.updatedAt)))
|
|
403
|
-
item.addEventListener('click', () => {
|
|
404
|
-
this.activeChatId = entry.id
|
|
405
|
-
this.closeChatList()
|
|
406
|
-
this.h.onSwitchChat?.(entry)
|
|
407
|
-
})
|
|
408
|
-
this.chatListBody.append(item)
|
|
409
|
-
}
|
|
410
|
-
}).catch(() => {
|
|
411
|
-
this.chatListBody.replaceChildren(el('div', 'ocw-chatlist-empty', 'Could not load your conversations.'))
|
|
412
|
-
})
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
private closeChatList(): void {
|
|
416
|
-
this.chatListPanel.classList.remove('open')
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
private flushSend(): void {
|
|
420
|
-
const text = this.input.value.trim()
|
|
421
|
-
if (!text) return
|
|
422
|
-
this.input.value = ''
|
|
423
|
-
this.h.onTyping(false)
|
|
424
|
-
this.h.onSend(text)
|
|
425
|
-
}
|
|
426
|
-
private signalTyping(): void {
|
|
427
|
-
this.h.onTyping(true)
|
|
428
|
-
if (this.typingTimer) clearTimeout(this.typingTimer)
|
|
429
|
-
this.typingTimer = setTimeout(() => this.h.onTyping(false), 2000)
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
render(store: ChatStore): void {
|
|
433
|
-
this.storeRef = store
|
|
434
|
-
// Co-browsing toggle is only meaningful for subject-anchored conversations.
|
|
435
|
-
this.cobrowseBtn.classList.toggle('show', !!store.subject)
|
|
436
|
-
if (!store.subject && this.cobrowseActive) { this.cobrowseActive = false; this.updateCobrowseUI() }
|
|
437
|
-
if (this.cobrowseActive && store.annotationVersion !== this.lastAnnotationVersion) {
|
|
438
|
-
this.lastAnnotationVersion = store.annotationVersion
|
|
439
|
-
this.resizeCobrowseCanvas()
|
|
440
|
-
this.redrawCobrowse()
|
|
441
|
-
}
|
|
442
|
-
if (store.accent) this.root.style.setProperty('--ocw-accent', store.accent)
|
|
443
|
-
this.e2eBadge.style.display = store.e2e ? 'inline-flex' : 'none'
|
|
444
|
-
this.buildSubjectCard(store)
|
|
445
|
-
// Header: when a subject is attached, show ownerLabel ("Seller", "Host")
|
|
446
|
-
// or nothing — the subject card below carries the identity.
|
|
447
|
-
// Without a subject, the header is already set to cfg.subject?.ownerLabel
|
|
448
|
-
// or "Chat" from the constructor — don't overwrite it with the domain name
|
|
449
|
-
// which would duplicate the subject card title or clutter a plain chat.
|
|
450
|
-
if (store.subject) {
|
|
451
|
-
const ownerLabel = this.cfg.subject?.ownerLabel
|
|
452
|
-
if (ownerLabel) this.headerName.textContent = ownerLabel
|
|
453
|
-
// else leave constructor default ("Chat")
|
|
454
|
-
}
|
|
455
|
-
// Without a subject: leave header as-is (set once in constructor)
|
|
456
|
-
|
|
457
|
-
// Action chips from the manifest (filtered by state in the store)
|
|
458
|
-
this.chips.replaceChildren()
|
|
459
|
-
const actions = store.visibleActions()
|
|
460
|
-
this.chips.style.display = actions.length ? 'flex' : 'none'
|
|
461
|
-
for (const a of actions) this.chips.append(this.chipEl(a))
|
|
462
|
-
|
|
463
|
-
// Messages
|
|
464
|
-
// Preserve scroll anchor when history is prepended: capture height before
|
|
465
|
-
// replaceChildren so we can restore relative position after.
|
|
466
|
-
const prevScrollHeight = this.scroll.scrollHeight
|
|
467
|
-
const prevScrollTop = this.scroll.scrollTop
|
|
468
|
-
|
|
469
|
-
this.scroll.replaceChildren()
|
|
470
|
-
if (this.subjectCard.childNodes.length) this.scroll.append(this.subjectCard)
|
|
471
|
-
if (store.hasMoreHistory && this.h.onLoadMore) {
|
|
472
|
-
const btn = el('button', 'ocw-load-more', '↑ Load earlier messages')
|
|
473
|
-
btn.addEventListener('click', () => this.h.onLoadMore!())
|
|
474
|
-
this.scroll.append(btn)
|
|
475
|
-
}
|
|
476
|
-
let maxOther = 0
|
|
477
|
-
for (const m of store.messages()) {
|
|
478
|
-
this.scroll.append(this.messageEl(m, store))
|
|
479
|
-
if (m.senderId !== this.me && m.seq > maxOther) maxOther = m.seq
|
|
480
|
-
}
|
|
481
|
-
// Auto-scroll to bottom only for new messages; restore anchor when history was prepended.
|
|
482
|
-
if (prevScrollTop > 20) {
|
|
483
|
-
this.scroll.scrollTop = this.scroll.scrollHeight - prevScrollHeight + prevScrollTop
|
|
484
|
-
} else {
|
|
485
|
-
this.scroll.scrollTop = this.scroll.scrollHeight
|
|
486
|
-
}
|
|
487
|
-
if (maxOther > 0) this.h.onReadUpTo(maxOther)
|
|
488
|
-
|
|
489
|
-
this.typing.textContent = store.typing.size > 0 ? 'typing…' : ''
|
|
490
|
-
this.footer.style.display = store.whiteLabel ? 'none' : 'block'
|
|
491
|
-
|
|
492
|
-
// Offline mode: show form instead of chat input
|
|
493
|
-
if (store.offline) {
|
|
494
|
-
if (this.offlinePanel.style.display === 'none') this.buildOfflinePanel(store.offlineMessage)
|
|
495
|
-
this.offlinePanel.style.display = 'block'
|
|
496
|
-
;(this.root.querySelector('.ocw-input') as HTMLElement | null)?.style.setProperty('display', 'none')
|
|
497
|
-
} else {
|
|
498
|
-
this.offlinePanel.style.display = 'none'
|
|
499
|
-
;(this.root.querySelector('.ocw-input') as HTMLElement | null)?.style.removeProperty('display')
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// CSAT: show star-rating panel when conversation reaches a terminal state
|
|
503
|
-
// and the user hasn't yet rated. Terminal states are heuristic: 'resolved',
|
|
504
|
-
// 'closed', 'sold', 'issued', 'checked_out'. The panel self-dismisses on submit.
|
|
505
|
-
const terminalStates = ['resolved', 'closed', 'sold', 'issued', 'checked_out']
|
|
506
|
-
if (this.h.onCsat && !this.csatSubmitted && terminalStates.includes(store.state) && store.messages().length > 0) {
|
|
507
|
-
if (this.csatPanel.style.display === 'none') this.buildCsatPanel()
|
|
508
|
-
this.csatPanel.style.display = 'block'
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
setConnStatus(status: 'connecting' | 'open' | 'reconnecting'): void {
|
|
513
|
-
if (status === 'open') { this.connStatus.style.display = 'none'; return }
|
|
514
|
-
this.connStatus.style.display = ''
|
|
515
|
-
this.connStatus.className = `ocw-conn-status${status === 'reconnecting' ? ' warn' : ''}`
|
|
516
|
-
this.connStatus.textContent = status === 'reconnecting' ? '↻ reconnecting…' : '● connecting…'
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private buildOfflinePanel(offlineMessage?: string): void {
|
|
520
|
-
this.offlinePanel.replaceChildren()
|
|
521
|
-
this.offlinePanel.append(el('div', 'ocw-offline-icon', '🌙'))
|
|
522
|
-
this.offlinePanel.append(el('div', 'ocw-offline-title', "We're offline right now"))
|
|
523
|
-
this.offlinePanel.append(el('div', 'ocw-offline-msg', offlineMessage || "Leave your details and we'll get back to you soon."))
|
|
524
|
-
const form = el('div', 'ocw-offline-form')
|
|
525
|
-
const nameIn = el('input', 'ocw-offline-input') as HTMLInputElement; nameIn.placeholder = 'Your name'; nameIn.type = 'text'
|
|
526
|
-
const emailIn = el('input', 'ocw-offline-input') as HTMLInputElement; emailIn.placeholder = 'Your email'; emailIn.type = 'email'
|
|
527
|
-
const msgIn = el('textarea', 'ocw-offline-input') as HTMLTextAreaElement; msgIn.placeholder = 'Your message'; msgIn.rows = 3
|
|
528
|
-
const submit = el('button', 'ocw-offline-submit', 'Send message')
|
|
529
|
-
submit.addEventListener('click', () => {
|
|
530
|
-
if (!emailIn.value.trim() || !msgIn.value.trim()) return
|
|
531
|
-
// Post as a regular message (offline form is stored as a conversation once submitted)
|
|
532
|
-
this.h.onSend(`[Offline form]\nName: ${nameIn.value || 'Anonymous'}\nEmail: ${emailIn.value}\nMessage: ${msgIn.value}`)
|
|
533
|
-
this.offlinePanel.replaceChildren(el('div', 'ocw-offline-thanks', '✓ Message sent! We\'ll reply to your email.'))
|
|
534
|
-
})
|
|
535
|
-
form.append(nameIn, emailIn, msgIn, submit)
|
|
536
|
-
this.offlinePanel.append(form)
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
private buildCsatPanel(): void {
|
|
540
|
-
this.csatPanel.replaceChildren()
|
|
541
|
-
this.csatPanel.append(el('div', 'ocw-csat-title', 'How did we do?'))
|
|
542
|
-
const stars = el('div', 'ocw-csat-stars')
|
|
543
|
-
const btns: HTMLButtonElement[] = []
|
|
544
|
-
for (let i = 1; i <= 5; i++) {
|
|
545
|
-
const b = el('button', 'ocw-csat-star', '★')
|
|
546
|
-
b.dataset['score'] = String(i)
|
|
547
|
-
b.addEventListener('mouseenter', () => btns.forEach((bb, idx) => bb.classList.toggle('lit', idx < i)))
|
|
548
|
-
b.addEventListener('mouseleave', () => btns.forEach(bb => bb.classList.remove('lit')))
|
|
549
|
-
b.addEventListener('click', () => {
|
|
550
|
-
this.csatSubmitted = true
|
|
551
|
-
this.csatPanel.replaceChildren(el('div', 'ocw-csat-done', `Thanks for your ${i}★ rating!`))
|
|
552
|
-
this.h.onCsat?.(i)
|
|
553
|
-
})
|
|
554
|
-
btns.push(b); stars.append(b)
|
|
555
|
-
}
|
|
556
|
-
this.csatPanel.append(stars)
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
private subjectBuilt = false
|
|
560
|
-
/** Subject card from the server's Subject entity (per chatroom), with the
|
|
561
|
-
* mount config as a fallback. Built once when data first arrives. */
|
|
562
|
-
// ── Co-browsing (shared whiteboard) ──────────────────────────────────────
|
|
563
|
-
private updateCobrowseUI(): void {
|
|
564
|
-
this.cobrowseCanvas.classList.toggle('active', this.cobrowseActive)
|
|
565
|
-
this.cobrowseToolbar.classList.toggle('active', this.cobrowseActive)
|
|
566
|
-
this.cobrowseHint.classList.toggle('active', this.cobrowseActive)
|
|
567
|
-
this.cobrowseBtn.classList.toggle('on', this.cobrowseActive)
|
|
568
|
-
if (this.cobrowseActive) {
|
|
569
|
-
this.resizeCobrowseCanvas()
|
|
570
|
-
this.redrawCobrowse()
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private resizeCobrowseCanvas(): void {
|
|
575
|
-
this.cobrowseCanvas.width = this.root.clientWidth || 1
|
|
576
|
-
this.cobrowseCanvas.height = this.root.clientHeight || 1
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/** Draw a stroke whose points are normalized to 0..1, scaled to the current
|
|
580
|
-
* canvas size — so strokes line up across different viewport sizes. */
|
|
581
|
-
private drawStroke(stroke: { points: { x: number; y: number }[]; color: string; width: number }): void {
|
|
582
|
-
const ctx = this.cobrowseCanvas.getContext('2d')
|
|
583
|
-
if (!ctx || stroke.points.length < 2) return
|
|
584
|
-
const w = this.cobrowseCanvas.width, h = this.cobrowseCanvas.height
|
|
585
|
-
ctx.strokeStyle = stroke.color
|
|
586
|
-
ctx.lineWidth = Math.max(1, stroke.width * Math.min(w, h))
|
|
587
|
-
ctx.lineJoin = 'round'
|
|
588
|
-
ctx.lineCap = 'round'
|
|
589
|
-
ctx.beginPath()
|
|
590
|
-
ctx.moveTo(stroke.points[0]!.x * w, stroke.points[0]!.y * h)
|
|
591
|
-
for (const p of stroke.points.slice(1)) ctx.lineTo(p.x * w, p.y * h)
|
|
592
|
-
ctx.stroke()
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
private redrawCobrowse(): void {
|
|
596
|
-
const ctx = this.cobrowseCanvas.getContext('2d')
|
|
597
|
-
if (!ctx) return
|
|
598
|
-
ctx.clearRect(0, 0, this.cobrowseCanvas.width, this.cobrowseCanvas.height)
|
|
599
|
-
for (const s of this.storeRef?.annotations ?? []) this.drawStroke(s)
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
private bindCobrowsePointerEvents(): void {
|
|
603
|
-
let drawing = false
|
|
604
|
-
let current: { x: number; y: number }[] = []
|
|
605
|
-
const posFromEvent = (e: PointerEvent): { x: number; y: number } => {
|
|
606
|
-
const rect = this.cobrowseCanvas.getBoundingClientRect()
|
|
607
|
-
const x = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0
|
|
608
|
-
const y = rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0
|
|
609
|
-
return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) }
|
|
610
|
-
}
|
|
611
|
-
const STROKE_WIDTH = 0.006 // normalized — ~6px on a 1000px-wide canvas
|
|
612
|
-
this.cobrowseCanvas.addEventListener('pointerdown', (e) => {
|
|
613
|
-
if (!this.cobrowseActive) return
|
|
614
|
-
drawing = true
|
|
615
|
-
current = [posFromEvent(e)]
|
|
616
|
-
this.cobrowseCanvas.setPointerCapture(e.pointerId)
|
|
617
|
-
})
|
|
618
|
-
this.cobrowseCanvas.addEventListener('pointermove', (e) => {
|
|
619
|
-
if (!drawing) return
|
|
620
|
-
current.push(posFromEvent(e))
|
|
621
|
-
this.redrawCobrowse()
|
|
622
|
-
this.drawStroke({ points: current, color: this.cobrowseColor, width: STROKE_WIDTH })
|
|
623
|
-
})
|
|
624
|
-
const finish = (e: PointerEvent): void => {
|
|
625
|
-
if (!drawing) return
|
|
626
|
-
drawing = false
|
|
627
|
-
if (current.length > 1) {
|
|
628
|
-
this.h.onAnnotate?.({ id: `an_${Date.now()}_${Math.random().toString(36).slice(2)}`, points: current, color: this.cobrowseColor, width: STROKE_WIDTH })
|
|
629
|
-
}
|
|
630
|
-
current = []
|
|
631
|
-
try { this.cobrowseCanvas.releasePointerCapture(e.pointerId) } catch { /* not captured */ }
|
|
632
|
-
}
|
|
633
|
-
this.cobrowseCanvas.addEventListener('pointerup', finish)
|
|
634
|
-
this.cobrowseCanvas.addEventListener('pointercancel', finish)
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
private buildSubjectCard(store: ChatStore): void {
|
|
638
|
-
if (this.subjectBuilt) return
|
|
639
|
-
const s = store.subject
|
|
640
|
-
const cfg = this.cfg.subject
|
|
641
|
-
// Only show the subject card when there is actual subject data (from the
|
|
642
|
-
// server) or an explicit subject config passed by the embedder (title,
|
|
643
|
-
// tags, status). Never fall back to store.name — that's the domain/profile
|
|
644
|
-
// name and is already shown in the header; rendering it here as well is
|
|
645
|
-
// what caused the duplication seen in the Hotel front desk screenshot.
|
|
646
|
-
const title = s?.title ?? cfg?.title
|
|
647
|
-
if (!title) return
|
|
648
|
-
this.subjectBuilt = true
|
|
649
|
-
this.subjectCard.replaceChildren()
|
|
650
|
-
this.subjectCard.append(el('div', 'ocw-subject-title', title))
|
|
651
|
-
if (cfg?.subtitle) this.subjectCard.append(el('div', 'ocw-subject-sub', cfg.subtitle))
|
|
652
|
-
const tags = el('div', 'ocw-tags')
|
|
653
|
-
if (s) for (const [k, v] of Object.entries(s.fields)) tags.append(el('span', 'ocw-tag', `${k}: ${v}`))
|
|
654
|
-
else for (const t of cfg?.tags ?? []) tags.append(el('span', 'ocw-tag', t))
|
|
655
|
-
if (tags.childNodes.length) this.subjectCard.append(tags)
|
|
656
|
-
const status = s?.state ?? cfg?.status
|
|
657
|
-
if (status) { this.statusBadge.textContent = status; this.statusBadge.style.display = 'inline-flex' }
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
private chipEl(a: ManifestAction): HTMLButtonElement {
|
|
661
|
-
const btn = el('button', 'ocw-chip', a.icon ? `${a.icon} ${a.label}` : a.label)
|
|
662
|
-
btn.dataset['actionId'] = a.id
|
|
663
|
-
btn.addEventListener('click', async () => {
|
|
664
|
-
if (a.confirm && !(await this.confirm(a.label))) return
|
|
665
|
-
if (a.input?.length) this.openForm(a)
|
|
666
|
-
else this.h.onInvoke(a.id)
|
|
667
|
-
})
|
|
668
|
-
return btn
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/** In-widget confirmation modal (replaces window.confirm). */
|
|
672
|
-
private confirm(label: string): Promise<boolean> {
|
|
673
|
-
return new Promise((resolve) => {
|
|
674
|
-
const overlay = el('div', 'ocw-modal')
|
|
675
|
-
const card = el('div', 'ocw-modal-card')
|
|
676
|
-
card.append(el('div', 'ocw-modal-title', label))
|
|
677
|
-
card.append(el('div', 'ocw-modal-body', `Confirm “${label}”?`))
|
|
678
|
-
const row = el('div', 'ocw-modal-actions')
|
|
679
|
-
const cancel = el('button', 'ocw-modal-cancel', 'Cancel')
|
|
680
|
-
const ok = el('button', 'ocw-modal-ok', 'Confirm')
|
|
681
|
-
const close = (v: boolean) => { overlay.remove(); resolve(v) }
|
|
682
|
-
cancel.addEventListener('click', () => close(false))
|
|
683
|
-
ok.addEventListener('click', () => close(true))
|
|
684
|
-
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false) })
|
|
685
|
-
row.append(cancel, ok); card.append(row); overlay.append(card)
|
|
686
|
-
this.root.append(overlay)
|
|
687
|
-
ok.focus()
|
|
688
|
-
})
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/** Inline form for a form-effect action: typed inputs (date picker, number,
|
|
692
|
-
* text) rendered above the composer — no browser prompts. */
|
|
693
|
-
private openForm(a: ManifestAction): void {
|
|
694
|
-
this.formHost.replaceChildren()
|
|
695
|
-
const panel = el('div', 'ocw-form')
|
|
696
|
-
panel.append(el('div', 'ocw-form-title', a.icon ? `${a.icon} ${a.label}` : a.label))
|
|
697
|
-
const inputs = new Map<string, HTMLInputElement>()
|
|
698
|
-
for (const f of a.input ?? []) {
|
|
699
|
-
const row = el('label', 'ocw-form-row'); row.append(el('span', 'ocw-form-lbl', f.label))
|
|
700
|
-
if (f.type === 'select' && f.options?.length) {
|
|
701
|
-
const sel = el('select', 'ocw-form-input')
|
|
702
|
-
if (!f.required) sel.append(el('option', undefined, '— select —'))
|
|
703
|
-
for (const opt of f.options) { const o = el('option'); o.value = opt; o.textContent = opt; sel.append(o) }
|
|
704
|
-
if (f.required) sel.required = true
|
|
705
|
-
row.append(sel)
|
|
706
|
-
inputs.set(f.name, sel as unknown as HTMLInputElement)
|
|
707
|
-
} else {
|
|
708
|
-
const inp = el('input', 'ocw-form-input')
|
|
709
|
-
inp.type = f.type === 'number' ? 'number' : f.type === 'date' ? 'datetime-local' : 'text'
|
|
710
|
-
if (f.required) inp.required = true
|
|
711
|
-
row.append(inp); inputs.set(f.name, inp)
|
|
712
|
-
}
|
|
713
|
-
panel.append(row)
|
|
714
|
-
}
|
|
715
|
-
const actions = el('div', 'ocw-form-actions')
|
|
716
|
-
const cancel = el('button', 'ocw-form-cancel', 'Cancel')
|
|
717
|
-
const submit = el('button', 'ocw-form-submit', 'Send')
|
|
718
|
-
cancel.addEventListener('click', () => this.formHost.replaceChildren())
|
|
719
|
-
submit.addEventListener('click', () => {
|
|
720
|
-
const out: Record<string, unknown> = {}
|
|
721
|
-
for (const [name, inp] of inputs) {
|
|
722
|
-
if (inp.required && !inp.value) { inp.style.borderColor = '#e5484d'; return }
|
|
723
|
-
out[name] = inp.type === 'number' ? Number(inp.value) : inp.value
|
|
724
|
-
}
|
|
725
|
-
this.formHost.replaceChildren()
|
|
726
|
-
this.h.onInvoke(a.id, out)
|
|
727
|
-
})
|
|
728
|
-
actions.append(cancel, submit); panel.append(actions)
|
|
729
|
-
this.formHost.append(panel)
|
|
730
|
-
inputs.values().next().value?.focus()
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
private messageEl(m: RenderMessage, store: ChatStore): HTMLElement {
|
|
734
|
-
if (m.senderRole === 'system') {
|
|
735
|
-
const sys = el('div', 'ocw-sys'); sys.textContent = m.deletedAt ? 'message deleted' : contentText(m.content); return sys
|
|
736
|
-
}
|
|
737
|
-
const mine = m.senderId === this.me
|
|
738
|
-
const isNote = !!m.internal
|
|
739
|
-
const row = el('div', `ocw-row ${isNote ? 'ocw-note mine' : mine ? 'mine' : 'theirs'} ${m.senderRole === 'bot' ? 'ocw-bot' : ''}`)
|
|
740
|
-
if (!mine && !isNote) row.append(el('div', 'ocw-dot', m.senderRole === 'bot' ? '🤖' : '🧑'))
|
|
741
|
-
const col = el('div')
|
|
742
|
-
const bubbleWrap = el('div', 'ocw-bubble-wrap')
|
|
743
|
-
// Reply-to context if present
|
|
744
|
-
if (m.replyToId) {
|
|
745
|
-
const replyCtx = el('div', 'ocw-reply-to', '↩ replying to a message')
|
|
746
|
-
replyCtx.style.cssText = 'font-size:11px;color:var(--ocw-mut);margin-bottom:2px;font-style:italic'
|
|
747
|
-
col.append(replyCtx)
|
|
748
|
-
}
|
|
749
|
-
const bubble = el('div', 'ocw-bubble')
|
|
750
|
-
let textNode: Text | null = null
|
|
751
|
-
if (m.deletedAt) bubble.append(el('span', 'ocw-deleted', 'message deleted'))
|
|
752
|
-
else if (m.content.kind === 'attachment') {
|
|
753
|
-
const c = m.content
|
|
754
|
-
if (c.mime?.startsWith('image/')) {
|
|
755
|
-
const img = document.createElement('img')
|
|
756
|
-
img.src = c.url; img.alt = c.name ?? 'image'
|
|
757
|
-
img.style.cssText = 'max-width:220px;max-height:160px;border-radius:10px;display:block;cursor:pointer'
|
|
758
|
-
img.addEventListener('click', () => window.open(c.url, '_blank'))
|
|
759
|
-
bubble.append(img)
|
|
760
|
-
} else {
|
|
761
|
-
const a = document.createElement('a')
|
|
762
|
-
a.href = c.url; a.target = '_blank'; a.rel = 'noopener'
|
|
763
|
-
a.style.cssText = 'display:flex;align-items:center;gap:8px;color:inherit;text-decoration:none'
|
|
764
|
-
a.append(el('span', undefined, '📄'), el('span', undefined, c.name ?? 'file'))
|
|
765
|
-
bubble.append(a)
|
|
766
|
-
}
|
|
767
|
-
} else {
|
|
768
|
-
if (m.content.kind === 'appointment') {
|
|
769
|
-
const ap = m.content
|
|
770
|
-
const card = el('div', 'ocw-appt')
|
|
771
|
-
card.append(el('div', 'ocw-appt-title', `\u{1F4C5} ${ap.title}`))
|
|
772
|
-
card.append(el('div', 'ocw-appt-time', new Date(ap.startIso).toLocaleString() + ' \u2013 ' + new Date(ap.endIso).toLocaleTimeString()))
|
|
773
|
-
if (ap.location) card.append(el('div', 'ocw-appt-loc', `\u{1F4CD} ${ap.location}`))
|
|
774
|
-
if (ap.description) card.append(el('div', 'ocw-appt-desc', ap.description))
|
|
775
|
-
const links = el('div', 'ocw-appt-links')
|
|
776
|
-
const gLink = document.createElement('a'); gLink.href = ap.googleUrl; gLink.target = '_blank'; gLink.rel = 'noopener'; gLink.className = 'ocw-appt-btn'; gLink.textContent = '\u{1F4C5} Add to Google Calendar'
|
|
777
|
-
const iLink = document.createElement('a'); iLink.href = ap.icalUrl; iLink.download = `${ap.title}.ics`; iLink.className = 'ocw-appt-btn ocw-appt-btn-sec'; iLink.textContent = '\u{1F34E} Apple / iCal'
|
|
778
|
-
links.append(gLink, iLink); card.append(links); bubble.append(card)
|
|
779
|
-
} else {
|
|
780
|
-
textNode = document.createTextNode(contentText(m.content))
|
|
781
|
-
bubble.append(textNode)
|
|
782
|
-
if (m.editedAt) bubble.append(el('span', 'ocw-edited', '(edited)'))
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
bubbleWrap.append(bubble)
|
|
786
|
-
|
|
787
|
-
// Live translation: only for the other party's plain-text messages (not notes).
|
|
788
|
-
if (!mine && !isNote && this.h.onTranslate && m.content.kind === 'text' && !m.deletedAt && m.seq > 0 && textNode) {
|
|
789
|
-
const original = m.content.text
|
|
790
|
-
if (original.trim()) {
|
|
791
|
-
const translateBtn = el('button', 'ocw-translate-btn', '🌐')
|
|
792
|
-
translateBtn.type = 'button'
|
|
793
|
-
translateBtn.title = 'Translate'
|
|
794
|
-
translateBtn.addEventListener('click', (e) => {
|
|
795
|
-
e.stopPropagation()
|
|
796
|
-
if (this.showingTranslation.has(m.id)) {
|
|
797
|
-
this.showingTranslation.delete(m.id)
|
|
798
|
-
textNode!.textContent = original
|
|
799
|
-
translateBtn.textContent = '🌐'
|
|
800
|
-
translateBtn.title = 'Translate'
|
|
801
|
-
return
|
|
802
|
-
}
|
|
803
|
-
const cached = this.translationCache.get(m.id)
|
|
804
|
-
if (cached !== undefined) {
|
|
805
|
-
this.showingTranslation.add(m.id)
|
|
806
|
-
textNode!.textContent = cached
|
|
807
|
-
translateBtn.textContent = '↩'
|
|
808
|
-
translateBtn.title = 'Show original'
|
|
809
|
-
return
|
|
810
|
-
}
|
|
811
|
-
translateBtn.textContent = '⏳'
|
|
812
|
-
void this.h.onTranslate!(original).then((result) => {
|
|
813
|
-
if (result === null) {
|
|
814
|
-
translateBtn.textContent = '⚠️'
|
|
815
|
-
translateBtn.title = 'Translation unavailable'
|
|
816
|
-
setTimeout(() => { translateBtn.textContent = '🌐'; translateBtn.title = 'Translate' }, 1500)
|
|
817
|
-
return
|
|
818
|
-
}
|
|
819
|
-
this.translationCache.set(m.id, result)
|
|
820
|
-
this.showingTranslation.add(m.id)
|
|
821
|
-
textNode!.textContent = result
|
|
822
|
-
translateBtn.textContent = '↩'
|
|
823
|
-
translateBtn.title = 'Show original'
|
|
824
|
-
})
|
|
825
|
-
})
|
|
826
|
-
bubbleWrap.append(translateBtn)
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
// Edit/delete context menu on own non-deleted messages
|
|
830
|
-
if (mine && !m.deletedAt && m.seq > 0 && (this.h.onEdit ?? this.h.onDelete)) {
|
|
831
|
-
const menu = el('div', 'ocw-msg-menu')
|
|
832
|
-
if (this.h.onEdit) {
|
|
833
|
-
const editBtn = el('button', undefined, '✏️')
|
|
834
|
-
editBtn.title = 'Edit'
|
|
835
|
-
editBtn.addEventListener('click', (e) => {
|
|
836
|
-
e.stopPropagation()
|
|
837
|
-
const text = contentText(m.content)
|
|
838
|
-
const newText = prompt('Edit message:', text)
|
|
839
|
-
if (newText !== null && newText.trim() && newText !== text) this.h.onEdit!(m.id, newText.trim())
|
|
840
|
-
})
|
|
841
|
-
menu.append(editBtn)
|
|
842
|
-
}
|
|
843
|
-
if (this.h.onDelete) {
|
|
844
|
-
const delBtn = el('button', 'del', '🗑')
|
|
845
|
-
delBtn.title = 'Delete'
|
|
846
|
-
delBtn.addEventListener('click', (e) => { e.stopPropagation(); this.h.onDelete!(m.id) })
|
|
847
|
-
menu.append(delBtn)
|
|
848
|
-
}
|
|
849
|
-
bubbleWrap.append(menu)
|
|
850
|
-
}
|
|
851
|
-
col.append(bubbleWrap)
|
|
852
|
-
|
|
853
|
-
// Reactions: existing pills + add-reaction picker (hover-revealed)
|
|
854
|
-
if (this.h.onReact && !m.deletedAt && m.seq > 0) {
|
|
855
|
-
const reactWrap = el('div', 'ocw-react-wrap')
|
|
856
|
-
const reactRow = el('div', 'ocw-react')
|
|
857
|
-
// Existing reaction pills
|
|
858
|
-
if (m.reactions && Object.keys(m.reactions).length) {
|
|
859
|
-
for (const [emoji, users] of Object.entries(m.reactions)) {
|
|
860
|
-
const pill = el('button', `ocw-react-pill${(users as string[]).includes(this.me) ? ' mine' : ''}`, `${emoji} ${(users as string[]).length}`)
|
|
861
|
-
pill.addEventListener('click', () => this.h.onReact?.(m.id, emoji, (users as string[]).includes(this.me)))
|
|
862
|
-
reactRow.append(pill)
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
// Add-reaction button + picker
|
|
866
|
-
const addBtn = el('button', 'ocw-react-btn', '+')
|
|
867
|
-
const picker = el('div', 'ocw-react-picker')
|
|
868
|
-
for (const emoji of REACTION_EMOJIS) {
|
|
869
|
-
const pb = el('button', undefined, emoji)
|
|
870
|
-
pb.addEventListener('click', (e) => {
|
|
871
|
-
e.stopPropagation()
|
|
872
|
-
const alreadyReacted = m.reactions?.[emoji]?.includes(this.me as never)
|
|
873
|
-
this.h.onReact?.(m.id, emoji, !!alreadyReacted)
|
|
874
|
-
picker.style.display = 'none'
|
|
875
|
-
})
|
|
876
|
-
picker.append(pb)
|
|
877
|
-
}
|
|
878
|
-
picker.style.display = 'none'
|
|
879
|
-
addBtn.addEventListener('click', (e) => {
|
|
880
|
-
e.stopPropagation()
|
|
881
|
-
picker.style.display = picker.style.display === 'none' ? 'flex' : 'none'
|
|
882
|
-
})
|
|
883
|
-
document.addEventListener('click', () => { picker.style.display = 'none' }, { once: true })
|
|
884
|
-
reactRow.append(addBtn)
|
|
885
|
-
reactWrap.append(reactRow, picker)
|
|
886
|
-
col.append(reactWrap)
|
|
887
|
-
} else if (m.reactions && Object.keys(m.reactions).length) {
|
|
888
|
-
col.append(el('div', 'ocw-react', Object.entries(m.reactions).map(([e, u]) => `${e}${(u as string[]).length}`).join(' ')))
|
|
889
|
-
}
|
|
890
|
-
void store // suppress unused warning — store available for future use
|
|
891
|
-
|
|
892
|
-
const meta = el('div', 'ocw-time ocw-meta', fmtTime(m.ts))
|
|
893
|
-
if (mine && m.status) { const t = el('span', 'ocw-tick', tick(m.status)); meta.append(t) }
|
|
894
|
-
// "Seen" indicator when agent has read past this message
|
|
895
|
-
if (mine && m.seq > 0 && store.lastReadByOthers >= m.seq) {
|
|
896
|
-
meta.append(el('span', 'ocw-seen', ' · Seen'))
|
|
897
|
-
}
|
|
898
|
-
col.append(meta)
|
|
899
|
-
row.append(col)
|
|
900
|
-
return row
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function tick(s: NonNullable<RenderMessage['status']>): string {
|
|
905
|
-
switch (s) { case 'read': case 'delivered': return '✓✓'; case 'sent': return '✓'; default: return '🕓' }
|
|
906
|
-
}
|