@antzsoft/chat-core 1.0.2 → 1.0.3
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 +454 -39
- package/dist/chat.store-PY3YVYGN.js +7 -0
- package/dist/chat.store-PY3YVYGN.js.map +1 -0
- package/dist/chunk-TB52RCSF.js +54 -0
- package/dist/chunk-TB52RCSF.js.map +1 -0
- package/dist/index.cjs +173 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +155 -12
- package/dist/index.d.ts +155 -12
- package/dist/index.js +108 -55
- package/dist/index.js.map +1 -1
- package/docs/integration-guide.html +2076 -0
- package/package.json +3 -2
|
@@ -0,0 +1,2076 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
6
|
+
<title>@antzsoft/chat-core — Integration Guide</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg:#0f1117; --surface:#1a1d27; --surface2:#22263a; --border:#2e3250;
|
|
10
|
+
--accent:#6c8cff; --accent2:#a78bfa; --green:#34d399; --yellow:#fbbf24;
|
|
11
|
+
--red:#f87171; --text:#e2e8f0; --muted:#8892b0; --code-bg:#0d1117;
|
|
12
|
+
--radius:8px;
|
|
13
|
+
--font-mono:'JetBrains Mono','Fira Code',monospace;
|
|
14
|
+
--font-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
15
|
+
}
|
|
16
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
17
|
+
html{scroll-behavior:smooth}
|
|
18
|
+
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);font-size:15px;line-height:1.7;display:flex;min-height:100vh}
|
|
19
|
+
|
|
20
|
+
/* ── Sidebar ── */
|
|
21
|
+
nav{width:260px;min-width:260px;background:var(--surface);border-right:1px solid var(--border);position:fixed;top:0;left:0;bottom:0;overflow-y:auto;padding:24px 0;z-index:100}
|
|
22
|
+
nav .logo{padding:0 20px 16px;border-bottom:1px solid var(--border);margin-bottom:12px}
|
|
23
|
+
nav .logo h2{font-size:14px;color:var(--accent);font-weight:700;letter-spacing:.5px}
|
|
24
|
+
nav .logo p{font-size:11px;color:var(--muted);margin-top:2px}
|
|
25
|
+
nav ul{list-style:none}
|
|
26
|
+
nav ul li a{display:block;padding:5px 20px;color:var(--muted);text-decoration:none;font-size:12.5px;border-left:2px solid transparent;transition:all .15s}
|
|
27
|
+
nav ul li a:hover,nav ul li a.active{color:var(--text);border-left-color:var(--accent);background:rgba(108,140,255,.06)}
|
|
28
|
+
nav .section-label{padding:12px 20px 3px;font-size:10px;font-weight:700;letter-spacing:1.2px;color:var(--muted);text-transform:uppercase}
|
|
29
|
+
nav li[data-nav]{display:none}
|
|
30
|
+
nav li[data-nav].show{display:block}
|
|
31
|
+
|
|
32
|
+
/* ── Main ── */
|
|
33
|
+
main{margin-left:260px;flex:1;max-width:920px;padding:40px 52px 80px}
|
|
34
|
+
|
|
35
|
+
/* ── Typography ── */
|
|
36
|
+
h1{font-size:30px;font-weight:800;color:#fff;margin-bottom:10px}
|
|
37
|
+
h1 span{color:var(--accent)}
|
|
38
|
+
h2{font-size:21px;font-weight:700;color:#fff;margin:52px 0 14px;padding-bottom:10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
|
39
|
+
h2 .step{background:var(--accent);color:#fff;font-size:11px;font-weight:800;padding:2px 8px;border-radius:20px;letter-spacing:.5px}
|
|
40
|
+
h3{font-size:15px;font-weight:700;color:var(--accent2);margin:24px 0 8px}
|
|
41
|
+
h4{font-size:13px;font-weight:700;color:var(--muted);margin:18px 0 6px;text-transform:uppercase;letter-spacing:.5px}
|
|
42
|
+
p{margin-bottom:10px;color:#c8d0e0}
|
|
43
|
+
strong{color:var(--text)}
|
|
44
|
+
|
|
45
|
+
/* ── Platform switcher ── */
|
|
46
|
+
.platform-bar{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:4px;display:flex;gap:4px;margin-bottom:32px;width:fit-content}
|
|
47
|
+
.platform-btn{padding:8px 20px;border-radius:6px;border:none;background:transparent;color:var(--muted);font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap}
|
|
48
|
+
.platform-btn:hover{color:var(--text)}
|
|
49
|
+
.platform-btn.active{background:var(--accent);color:#fff}
|
|
50
|
+
.platform-btn .icon{margin-right:6px;font-size:14px}
|
|
51
|
+
|
|
52
|
+
/* ── Auth mode switcher ── */
|
|
53
|
+
.mode-bar{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:32px}
|
|
54
|
+
.mode-bar .ml{font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
|
|
55
|
+
.mode-opts{display:flex;gap:10px}
|
|
56
|
+
.mode-opt{flex:1;border:2px solid var(--border);border-radius:var(--radius);padding:12px 16px;cursor:pointer;transition:all .15s;background:var(--surface)}
|
|
57
|
+
.mode-opt:hover{border-color:var(--accent)}
|
|
58
|
+
.mode-opt.sel{border-color:var(--accent);background:rgba(108,140,255,.08)}
|
|
59
|
+
.mode-opt .mt{font-weight:700;font-size:13px;color:var(--text);margin-bottom:3px;display:flex;align-items:center;gap:7px}
|
|
60
|
+
.mode-opt .dot{width:8px;height:8px;border-radius:50%;border:2px solid var(--muted);display:inline-block;flex-shrink:0;transition:all .15s}
|
|
61
|
+
.mode-opt.sel .dot{background:var(--accent);border-color:var(--accent)}
|
|
62
|
+
.mode-opt .md{font-size:12px;color:var(--muted);line-height:1.4}
|
|
63
|
+
.mode-note{margin-top:12px;font-size:12px;color:var(--muted);padding-top:12px;border-top:1px solid var(--border)}
|
|
64
|
+
.mode-note strong{color:var(--green)}
|
|
65
|
+
|
|
66
|
+
/* ── Platform / auth-mode gating ── */
|
|
67
|
+
[data-p]{display:none}
|
|
68
|
+
[data-p].pshow{display:block}
|
|
69
|
+
[data-m]{display:none}
|
|
70
|
+
[data-m].mshow{display:block}
|
|
71
|
+
[data-pm]{display:none}
|
|
72
|
+
[data-pm].pmshow{display:block}
|
|
73
|
+
|
|
74
|
+
/* ── Code ── */
|
|
75
|
+
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:var(--radius);padding:18px 22px;overflow-x:auto;margin:10px 0 18px;position:relative}
|
|
76
|
+
pre code{font-family:var(--font-mono);font-size:12.5px;line-height:1.75;color:#cdd6f4}
|
|
77
|
+
.kw{color:#cba6f7}.fn{color:#89b4fa}.str{color:#a6e3a1}.num{color:#fab387}
|
|
78
|
+
.cm{color:#585b70;font-style:italic}.tp{color:#f9e2af}.at{color:#89dceb}
|
|
79
|
+
|
|
80
|
+
/* ── Callouts ── */
|
|
81
|
+
.callout{border-radius:var(--radius);padding:13px 17px;margin:14px 0;border-left:3px solid;font-size:13.5px}
|
|
82
|
+
.callout.info{background:rgba(108,140,255,.08);border-color:var(--accent);color:#aab4d4}
|
|
83
|
+
.callout.warn{background:rgba(251,191,36,.07);border-color:var(--yellow);color:#d4c08a}
|
|
84
|
+
.callout.tip{background:rgba(52,211,153,.07);border-color:var(--green);color:#8acdb0}
|
|
85
|
+
.callout.danger{background:rgba(248,113,113,.07);border-color:var(--red);color:#d4908a}
|
|
86
|
+
.callout strong{color:inherit;display:block;margin-bottom:3px}
|
|
87
|
+
|
|
88
|
+
/* ── Tables ── */
|
|
89
|
+
table{width:100%;border-collapse:collapse;margin:14px 0 20px;font-size:13px}
|
|
90
|
+
th{background:var(--surface2);color:var(--muted);font-weight:700;text-align:left;padding:9px 13px;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
|
|
91
|
+
td{padding:9px 13px;border-bottom:1px solid var(--border);color:#c8d0e0;vertical-align:top}
|
|
92
|
+
tr:last-child td{border-bottom:none}
|
|
93
|
+
td code,th code{font-family:var(--font-mono);font-size:11.5px;color:var(--accent2);background:rgba(167,139,250,.1);padding:1px 5px;border-radius:3px}
|
|
94
|
+
p code,li code{font-family:var(--font-mono);font-size:12px;color:var(--accent2);background:rgba(167,139,250,.12);padding:1px 5px;border-radius:3px}
|
|
95
|
+
|
|
96
|
+
/* ── Flow ── */
|
|
97
|
+
.flow{display:flex;align-items:center;flex-wrap:wrap;gap:0;margin:16px 0;font-size:11.5px}
|
|
98
|
+
.flow-step{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:9px 14px;text-align:center;min-width:110px}
|
|
99
|
+
.flow-step .num{font-size:9px;color:var(--accent);font-weight:700;display:block}
|
|
100
|
+
.flow-step .label{color:var(--text);font-weight:600;font-size:11.5px}
|
|
101
|
+
.flow-arrow{color:var(--muted);padding:0 6px;font-size:16px}
|
|
102
|
+
|
|
103
|
+
/* ── Misc ── */
|
|
104
|
+
ul,ol{padding-left:20px;margin-bottom:12px}
|
|
105
|
+
li{margin-bottom:4px;color:#c8d0e0}
|
|
106
|
+
.divider{border:none;border-top:1px solid var(--border);margin:44px 0}
|
|
107
|
+
.toc-progress{height:2px;background:var(--accent);position:fixed;top:0;left:0;z-index:200;transition:width .1s}
|
|
108
|
+
::-webkit-scrollbar{width:5px;height:5px}
|
|
109
|
+
::-webkit-scrollbar-track{background:transparent}
|
|
110
|
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
111
|
+
|
|
112
|
+
/* ── Hero badges ── */
|
|
113
|
+
.hero{background:linear-gradient(135deg,rgba(108,140,255,.12) 0%,rgba(167,139,250,.08) 100%);border:1px solid var(--border);border-radius:var(--radius);padding:28px 32px;margin-bottom:24px}
|
|
114
|
+
.hero .sub{color:var(--muted);font-size:15px;margin-top:6px}
|
|
115
|
+
.badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:14px}
|
|
116
|
+
.badge{font-size:10.5px;font-weight:600;padding:3px 9px;border-radius:20px;border:1px solid}
|
|
117
|
+
.badge.blue{color:var(--accent);border-color:rgba(108,140,255,.4);background:rgba(108,140,255,.1)}
|
|
118
|
+
.badge.purple{color:var(--accent2);border-color:rgba(167,139,250,.4);background:rgba(167,139,250,.1)}
|
|
119
|
+
.badge.green{color:var(--green);border-color:rgba(52,211,153,.4);background:rgba(52,211,153,.1)}
|
|
120
|
+
.badge.orange{color:var(--yellow);border-color:rgba(251,191,36,.4);background:rgba(251,191,36,.1)}
|
|
121
|
+
</style>
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<div class="toc-progress" id="prog"></div>
|
|
125
|
+
|
|
126
|
+
<!-- ══ SIDEBAR ══════════════════════════════════════════════════════════ -->
|
|
127
|
+
<nav>
|
|
128
|
+
<div class="logo">
|
|
129
|
+
<h2>@antzsoft/chat-core</h2>
|
|
130
|
+
<p id="nav-platform-label">Integration Guide</p>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div class="section-label">Getting Started</div>
|
|
134
|
+
<ul>
|
|
135
|
+
<li><a href="#overview">Overview</a></li>
|
|
136
|
+
<li><a href="#install">Installation</a></li>
|
|
137
|
+
<li><a href="#lifecycle">Lifecycle Map</a></li>
|
|
138
|
+
</ul>
|
|
139
|
+
|
|
140
|
+
<div class="section-label">Setup</div>
|
|
141
|
+
<ul>
|
|
142
|
+
<li><a href="#step1">1. Create the Client</a></li>
|
|
143
|
+
</ul>
|
|
144
|
+
|
|
145
|
+
<div class="section-label">Auth & Connection</div>
|
|
146
|
+
<ul>
|
|
147
|
+
<li data-nav="builtin"><a href="#step-auth-builtin">2. Login / Register</a></li>
|
|
148
|
+
<li data-nav="external"><a href="#step-auth-external">2. Connect Auth</a></li>
|
|
149
|
+
<li><a href="#step-socket">3. Connect Socket</a></li>
|
|
150
|
+
<li><a href="#step-status">4. Socket Status</a></li>
|
|
151
|
+
<li><a href="#step-disconnect">5. Disconnect</a></li>
|
|
152
|
+
</ul>
|
|
153
|
+
|
|
154
|
+
<div class="section-label">Conversations</div>
|
|
155
|
+
<ul>
|
|
156
|
+
<li><a href="#step-convs">6. Conversations</a></li>
|
|
157
|
+
<li><a href="#step-rooms">7. Join & Leave Room</a></li>
|
|
158
|
+
</ul>
|
|
159
|
+
|
|
160
|
+
<div class="section-label">Messaging</div>
|
|
161
|
+
<ul>
|
|
162
|
+
<li><a href="#step-load">8. Load Messages</a></li>
|
|
163
|
+
<li><a href="#step-send">9. Send a Message</a></li>
|
|
164
|
+
<li><a href="#step-realtime">10. Real-time Events</a></li>
|
|
165
|
+
<li><a href="#step-typing">11. Typing Indicators</a></li>
|
|
166
|
+
<li><a href="#step-read">12. Read Receipts & Last Seen</a></li>
|
|
167
|
+
<li><a href="#step-edit">13. Edit & Delete</a></li>
|
|
168
|
+
<li><a href="#step-search">14. Search Messages</a></li>
|
|
169
|
+
<li><a href="#step-reactions">15. Reactions, Pin, Star</a></li>
|
|
170
|
+
</ul>
|
|
171
|
+
|
|
172
|
+
<div class="section-label">Files & Advanced</div>
|
|
173
|
+
<ul>
|
|
174
|
+
<li><a href="#step-upload">16. Upload Files</a></li>
|
|
175
|
+
<li data-nav="rn-web"><a href="#step-push">17. Push Notifications</a></li>
|
|
176
|
+
<li><a href="#step-prefs">17.5 Notification Preferences</a></li>
|
|
177
|
+
<li><a href="#step-example">18. Full Example</a></li>
|
|
178
|
+
<li><a href="#step-unread">19. Unread Counts</a></li>
|
|
179
|
+
</ul>
|
|
180
|
+
</nav>
|
|
181
|
+
|
|
182
|
+
<!-- ══ MAIN ═════════════════════════════════════════════════════════════ -->
|
|
183
|
+
<main>
|
|
184
|
+
|
|
185
|
+
<!-- Hero -->
|
|
186
|
+
<div class="hero">
|
|
187
|
+
<h1><span>@antzsoft/chat-core</span></h1>
|
|
188
|
+
<p class="sub">Integration guide — React Native · React / Next.js · Node.js</p>
|
|
189
|
+
<div class="badges">
|
|
190
|
+
<span class="badge blue">TypeScript</span>
|
|
191
|
+
<span class="badge purple">Socket.IO</span>
|
|
192
|
+
<span class="badge green">React Native</span>
|
|
193
|
+
<span class="badge blue">React / Next.js</span>
|
|
194
|
+
<span class="badge orange">Node.js</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- Platform Switcher -->
|
|
199
|
+
<div class="platform-bar" id="platform-bar">
|
|
200
|
+
<button class="platform-btn active" data-platform="rn" onclick="setPlatform('rn')">
|
|
201
|
+
<span class="icon">📱</span>React Native
|
|
202
|
+
</button>
|
|
203
|
+
<button class="platform-btn" data-platform="web" onclick="setPlatform('web')">
|
|
204
|
+
<span class="icon">🌐</span>React / Next.js
|
|
205
|
+
</button>
|
|
206
|
+
<button class="platform-btn" data-platform="node" onclick="setPlatform('node')">
|
|
207
|
+
<span class="icon">⚙️</span>Node.js
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<!-- ─── OVERVIEW ──────────────────────────────────────────────────────── -->
|
|
212
|
+
<section id="overview">
|
|
213
|
+
<h2>Overview</h2>
|
|
214
|
+
<p><code>@antzsoft/chat-core</code> is a platform-agnostic headless SDK — no UI, just the API, socket, auth, and state. You build the UI on top.</p>
|
|
215
|
+
<table>
|
|
216
|
+
<thead><tr><th>Layer</th><th>What it gives you</th><th>Import</th></tr></thead>
|
|
217
|
+
<tbody>
|
|
218
|
+
<tr><td><strong>Client</strong></td><td>Create once, reuse everywhere</td><td><code>AntzChatClient</code></td></tr>
|
|
219
|
+
<tr>
|
|
220
|
+
<td><strong>API modules</strong></td>
|
|
221
|
+
<td>
|
|
222
|
+
<span data-pm="rn-builtin web-builtin node-builtin"><code>authApi</code>, <code>messagesApi</code>, <code>conversationsApi</code>, <code>usersApi</code>, <code>storageApi</code>, <code>devicesApi</code></span>
|
|
223
|
+
<span data-pm="rn-external web-external node-external"><code>messagesApi</code>, <code>conversationsApi</code>, <code>usersApi</code>, <code>storageApi</code>, <code>devicesApi</code> — <code>authApi</code> not used (your auth system handles login)</span>
|
|
224
|
+
</td>
|
|
225
|
+
<td>named exports</td>
|
|
226
|
+
</tr>
|
|
227
|
+
<tr><td><strong>Socket layer</strong></td><td><code>connectSocket</code>, <code>socketEmit</code>, <code>onSocketStatus</code>, raw events</td><td>named exports</td></tr>
|
|
228
|
+
</tbody>
|
|
229
|
+
</table>
|
|
230
|
+
</section>
|
|
231
|
+
|
|
232
|
+
<!-- ─── INSTALL ───────────────────────────────────────────────────────── -->
|
|
233
|
+
<section id="install">
|
|
234
|
+
<h2>Installation</h2>
|
|
235
|
+
<pre><code>npm install @antzsoft/chat-core</code></pre>
|
|
236
|
+
<p><code>axios</code>, <code>socket.io-client</code>, and <code>zustand</code> are bundled — npm installs them automatically.</p>
|
|
237
|
+
|
|
238
|
+
<!-- RN extra -->
|
|
239
|
+
<div data-p="rn">
|
|
240
|
+
<p>On React Native you also need the storage adapter (requires native linking):</p>
|
|
241
|
+
<pre><code><span class="cm"># Expo</span>
|
|
242
|
+
npx expo install @react-native-async-storage/async-storage
|
|
243
|
+
|
|
244
|
+
<span class="cm"># Bare React Native</span>
|
|
245
|
+
npm install @react-native-async-storage/async-storage
|
|
246
|
+
npx pod-install</code></pre>
|
|
247
|
+
<div class="callout info">
|
|
248
|
+
<strong>Why separately?</strong>
|
|
249
|
+
<code>AsyncStorage</code> contains native iOS/Android code compiled into your app binary — it can't be pre-bundled inside an npm package. The SDK accepts any storage object so it stays platform-agnostic.
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<!-- Web extra -->
|
|
254
|
+
<div data-p="web">
|
|
255
|
+
<p>No extra packages needed. The SDK uses <code>localStorage</code> for persistence on web — it's available natively in all browsers.</p>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<!-- Node extra -->
|
|
259
|
+
<div data-p="node">
|
|
260
|
+
<p>No extra packages needed. Pass an in-memory store or a file-backed adapter for <code>persistStorage</code>.</p>
|
|
261
|
+
</div>
|
|
262
|
+
</section>
|
|
263
|
+
|
|
264
|
+
<!-- ─── AUTH MODES ────────────────────────────────────────────────────── -->
|
|
265
|
+
<section id="lifecycle">
|
|
266
|
+
<h2>Auth Mode & Lifecycle</h2>
|
|
267
|
+
<p>Pick your auth mode first — it affects client setup and Step 2. Everything from Step 3 (Connect Socket) is identical across modes.</p>
|
|
268
|
+
|
|
269
|
+
<!-- Mode switcher -->
|
|
270
|
+
<div class="mode-bar">
|
|
271
|
+
<div class="ml">Choose your auth mode</div>
|
|
272
|
+
<div class="mode-opts">
|
|
273
|
+
<div class="mode-opt sel" onclick="setMode('builtin')">
|
|
274
|
+
<div class="mt"><span class="dot"></span> Built-in Auth</div>
|
|
275
|
+
<div class="md">Antz Chat server handles login. Use <code>authApi.login()</code>.</div>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="mode-opt" onclick="setMode('external')">
|
|
278
|
+
<div class="mt"><span class="dot"></span> External Auth</div>
|
|
279
|
+
<div class="md">Firebase, Auth0, your own backend. You provide the token.</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="mode-note"><strong>Steps 3–18 are identical for both modes.</strong> Only client config and Step 2 differ.</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<h3>Lifecycle — <span data-m="builtin">Built-in Auth</span><span data-m="external">External Auth</span></h3>
|
|
286
|
+
|
|
287
|
+
<div data-m="builtin">
|
|
288
|
+
<div class="flow">
|
|
289
|
+
<div class="flow-step"><span class="num">1</span><span class="label">Create Client</span></div><div class="flow-arrow">→</div>
|
|
290
|
+
<div class="flow-step"><span class="num">2</span><span class="label">authApi.login()</span></div><div class="flow-arrow">→</div>
|
|
291
|
+
<div class="flow-step"><span class="num">3</span><span class="label">Connect Socket</span></div><div class="flow-arrow">→</div>
|
|
292
|
+
<div class="flow-step"><span class="num">4</span><span class="label">Join Room</span></div><div class="flow-arrow">→</div>
|
|
293
|
+
<div class="flow-step"><span class="num">5</span><span class="label">Send / Receive</span></div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="flow" style="margin-top:6px">
|
|
296
|
+
<div class="flow-step"><span class="num">↓</span><span class="label">Leave Room</span></div><div class="flow-arrow">→</div>
|
|
297
|
+
<div class="flow-step"><span class="num">↓</span><span class="label">Disconnect</span></div><div class="flow-arrow">→</div>
|
|
298
|
+
<div class="flow-step"><span class="num">↓</span><span class="label">authApi.logout()</span></div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div data-m="external">
|
|
303
|
+
<div class="flow">
|
|
304
|
+
<div class="flow-step"><span class="num">1</span><span class="label">Create Client<br/><small style="color:var(--muted);font-weight:400">with authProvider</small></span></div><div class="flow-arrow">→</div>
|
|
305
|
+
<div class="flow-step"><span class="num">2</span><span class="label">Your login<br/><small style="color:var(--muted);font-weight:400">store token</small></span></div><div class="flow-arrow">→</div>
|
|
306
|
+
<div class="flow-step"><span class="num">3</span><span class="label">Connect Socket</span></div><div class="flow-arrow">→</div>
|
|
307
|
+
<div class="flow-step"><span class="num">4</span><span class="label">Join Room</span></div><div class="flow-arrow">→</div>
|
|
308
|
+
<div class="flow-step"><span class="num">5</span><span class="label">Send / Receive</span></div>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="flow" style="margin-top:6px">
|
|
311
|
+
<div class="flow-step"><span class="num">↓</span><span class="label">Leave Room</span></div><div class="flow-arrow">→</div>
|
|
312
|
+
<div class="flow-step"><span class="num">↓</span><span class="label">Disconnect</span></div><div class="flow-arrow">→</div>
|
|
313
|
+
<div class="flow-step"><span class="num">↓</span><span class="label">Your logout</span></div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</section>
|
|
317
|
+
|
|
318
|
+
<hr class="divider"/>
|
|
319
|
+
|
|
320
|
+
<!-- ─── STEP 1: CREATE CLIENT ─────────────────────────────────────────── -->
|
|
321
|
+
<section id="step1">
|
|
322
|
+
<h2><span class="step">STEP 1</span> Create the Client</h2>
|
|
323
|
+
<p>Create <strong>one</strong> <code>AntzChatClient</code> instance for the lifetime of your app — module level, outside any component or request handler.</p>
|
|
324
|
+
|
|
325
|
+
<!-- persistStorage explainer -->
|
|
326
|
+
<h3>persistStorage — why you provide it</h3>
|
|
327
|
+
<p>The SDK stores auth tokens across restarts. It can't pick storage itself (runs on RN, web, and Node), so you pass whichever adapter fits your platform.</p>
|
|
328
|
+
|
|
329
|
+
<div data-p="rn">
|
|
330
|
+
<pre><code><span class="cm">// AsyncStorage already matches the PersistStorage interface — pass directly</span>
|
|
331
|
+
<span class="kw">import</span> AsyncStorage <span class="kw">from</span> <span class="str">'@react-native-async-storage/async-storage'</span>;
|
|
332
|
+
<span class="at">persistStorage</span>: AsyncStorage</code></pre>
|
|
333
|
+
</div>
|
|
334
|
+
<div data-p="web">
|
|
335
|
+
<pre><code><span class="cm">// Use localStorage — it already matches PersistStorage</span>
|
|
336
|
+
<span class="at">persistStorage</span>: localStorage</code></pre>
|
|
337
|
+
</div>
|
|
338
|
+
<div data-p="node">
|
|
339
|
+
<pre><code><span class="cm">// In-memory store for Node.js (tokens lost on restart)</span>
|
|
340
|
+
<span class="kw">const</span> _store: Record<<span class="tp">string</span>, <span class="tp">string</span>> = {};
|
|
341
|
+
<span class="kw">const</span> <span class="at">memStorage</span> = {
|
|
342
|
+
<span class="fn">getItem</span>: (key: <span class="tp">string</span>) => _store[key] ?? <span class="kw">null</span>,
|
|
343
|
+
<span class="fn">setItem</span>: (key: <span class="tp">string</span>, val: <span class="tp">string</span>) => { _store[key] = val; },
|
|
344
|
+
<span class="fn">removeItem</span>: (key: <span class="tp">string</span>) => { <span class="kw">delete</span> _store[key]; },
|
|
345
|
+
};</code></pre>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<!-- platformUploadFn explainer -->
|
|
349
|
+
<h3>platformUploadFn — why you provide it</h3>
|
|
350
|
+
<p>The SDK orchestrates file uploads (presigned URL → binary upload → confirm) but the binary upload step differs per platform. You provide the function; the SDK calls it.</p>
|
|
351
|
+
|
|
352
|
+
<div data-p="rn">
|
|
353
|
+
<pre><code><span class="kw">import</span> { <span class="tp">PlatformUploadFn</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
354
|
+
|
|
355
|
+
<span class="kw">export const</span> <span class="at">rnUploadFn</span>: <span class="tp">PlatformUploadFn</span> = <span class="kw">async</span> (presigned, file, onProgress) => {
|
|
356
|
+
<span class="kw">let</span> body: <span class="tp">Blob</span> | <span class="tp">FormData</span>;
|
|
357
|
+
<span class="kw">if</span> (presigned.method === <span class="str">'PUT'</span>) {
|
|
358
|
+
<span class="kw">const</span> res = <span class="kw">await</span> <span class="fn">fetch</span>(file.uri);
|
|
359
|
+
body = <span class="kw">await</span> res.<span class="fn">blob</span>();
|
|
360
|
+
} <span class="kw">else</span> {
|
|
361
|
+
<span class="kw">const</span> form = <span class="kw">new</span> <span class="fn">FormData</span>();
|
|
362
|
+
<span class="kw">if</span> (presigned.fields) Object.<span class="fn">entries</span>(presigned.fields).<span class="fn">forEach</span>(([k,v]) => form.<span class="fn">append</span>(k,v <span class="kw">as</span> <span class="tp">string</span>));
|
|
363
|
+
form.<span class="fn">append</span>(<span class="str">'file'</span>, { <span class="at">uri</span>: file.uri, <span class="at">type</span>: file.type, <span class="at">name</span>: file.name } <span class="kw">as any</span>);
|
|
364
|
+
body = form;
|
|
365
|
+
}
|
|
366
|
+
<span class="kw">await</span> <span class="fn">fetch</span>(presigned.uploadUrl, { <span class="at">method</span>: presigned.method, <span class="at">headers</span>: presigned.headers, body });
|
|
367
|
+
onProgress?.(<span class="num">100</span>);
|
|
368
|
+
};</code></pre>
|
|
369
|
+
</div>
|
|
370
|
+
<div data-p="web">
|
|
371
|
+
<pre><code><span class="kw">import</span> { <span class="tp">PlatformUploadFn</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
372
|
+
|
|
373
|
+
<span class="kw">export const</span> <span class="at">webUploadFn</span>: <span class="tp">PlatformUploadFn</span> = (presigned, file, onProgress) =>
|
|
374
|
+
<span class="kw">new</span> <span class="fn">Promise</span>((resolve, reject) => {
|
|
375
|
+
<span class="kw">const</span> xhr = <span class="kw">new</span> <span class="fn">XMLHttpRequest</span>();
|
|
376
|
+
xhr.upload.onprogress = (e) => { <span class="kw">if</span> (e.lengthComputable) onProgress?.(Math.<span class="fn">round</span>(e.loaded/e.total*<span class="num">100</span>)); };
|
|
377
|
+
xhr.<span class="fn">open</span>(presigned.method, presigned.uploadUrl);
|
|
378
|
+
Object.<span class="fn">entries</span>(presigned.headers ?? {}).<span class="fn">forEach</span>(([k,v]) => xhr.<span class="fn">setRequestHeader</span>(k, v));
|
|
379
|
+
xhr.onload = () => xhr.status < <span class="num">300</span> ? <span class="fn">resolve</span>() : <span class="fn">reject</span>(<span class="kw">new</span> <span class="fn">Error</span>(`Upload failed: ${xhr.status}`));
|
|
380
|
+
xhr.onerror = () => <span class="fn">reject</span>(<span class="kw">new</span> <span class="fn">Error</span>(<span class="str">'Upload network error'</span>));
|
|
381
|
+
<span class="kw">if</span> (presigned.method === <span class="str">'PUT'</span>) {
|
|
382
|
+
xhr.<span class="fn">send</span>(file <span class="kw">as any</span>);
|
|
383
|
+
} <span class="kw">else</span> {
|
|
384
|
+
<span class="kw">const</span> form = <span class="kw">new</span> <span class="fn">FormData</span>();
|
|
385
|
+
<span class="kw">if</span> (presigned.fields) Object.<span class="fn">entries</span>(presigned.fields).<span class="fn">forEach</span>(([k,v]) => form.<span class="fn">append</span>(k,v));
|
|
386
|
+
form.<span class="fn">append</span>(<span class="str">'file'</span>, file <span class="kw">as any</span>);
|
|
387
|
+
xhr.<span class="fn">send</span>(form);
|
|
388
|
+
}
|
|
389
|
+
});</code></pre>
|
|
390
|
+
</div>
|
|
391
|
+
<div data-p="node">
|
|
392
|
+
<pre><code><span class="kw">import</span> { createReadStream } <span class="kw">from</span> <span class="str">'fs'</span>;
|
|
393
|
+
<span class="kw">import</span> { <span class="tp">PlatformUploadFn</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
394
|
+
|
|
395
|
+
<span class="kw">export const</span> <span class="at">nodeUploadFn</span>: <span class="tp">PlatformUploadFn</span> = <span class="kw">async</span> (presigned, file, onProgress) => {
|
|
396
|
+
<span class="kw">const</span> res = <span class="kw">await</span> <span class="fn">fetch</span>(presigned.uploadUrl, {
|
|
397
|
+
<span class="at">method</span>: presigned.method,
|
|
398
|
+
<span class="at">headers</span>: { <span class="str">'Content-Type'</span>: file.type, ...presigned.headers },
|
|
399
|
+
<span class="at">body</span>: <span class="fn">createReadStream</span>(file.uri) <span class="kw">as any</span>,
|
|
400
|
+
});
|
|
401
|
+
<span class="kw">if</span> (!res.ok) <span class="kw">throw new</span> <span class="fn">Error</span>(`Upload failed: ${res.status}`);
|
|
402
|
+
onProgress?.(<span class="num">100</span>);
|
|
403
|
+
};</code></pre>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<!-- Client config -->
|
|
407
|
+
<div data-m="builtin">
|
|
408
|
+
<h3>Client config — Built-in Auth</h3>
|
|
409
|
+
<div data-p="rn">
|
|
410
|
+
<pre><code><span class="cm">// src/chat/client.ts</span>
|
|
411
|
+
<span class="kw">import</span> { <span class="tp">AntzChatClient</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
412
|
+
<span class="kw">import</span> AsyncStorage <span class="kw">from</span> <span class="str">'@react-native-async-storage/async-storage'</span>;
|
|
413
|
+
<span class="kw">import</span> { rnUploadFn } <span class="kw">from</span> <span class="str">'./rnUploadFn'</span>;
|
|
414
|
+
|
|
415
|
+
<span class="kw">export const</span> <span class="at">chatClient</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
416
|
+
<span class="at">apiUrl</span>: <span class="str">'https://api.your-server.com/chat-api/api/v1'</span>,
|
|
417
|
+
<span class="at">tenantId</span>: <span class="str">'your-tenant-id'</span>,
|
|
418
|
+
<span class="at">persistStorage</span>: AsyncStorage,
|
|
419
|
+
<span class="at">platformUploadFn</span>: rnUploadFn,
|
|
420
|
+
});</code></pre>
|
|
421
|
+
</div>
|
|
422
|
+
<div data-p="web">
|
|
423
|
+
<pre><code><span class="cm">// src/chat/client.ts</span>
|
|
424
|
+
<span class="kw">import</span> { <span class="tp">AntzChatClient</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
425
|
+
<span class="kw">import</span> { webUploadFn } <span class="kw">from</span> <span class="str">'./webUploadFn'</span>;
|
|
426
|
+
|
|
427
|
+
<span class="kw">export const</span> <span class="at">chatClient</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
428
|
+
<span class="at">apiUrl</span>: <span class="str">'https://api.your-server.com/chat-api/api/v1'</span>,
|
|
429
|
+
<span class="at">tenantId</span>: <span class="str">'your-tenant-id'</span>,
|
|
430
|
+
<span class="at">persistStorage</span>: localStorage,
|
|
431
|
+
<span class="at">platformUploadFn</span>: webUploadFn,
|
|
432
|
+
});</code></pre>
|
|
433
|
+
</div>
|
|
434
|
+
<div data-p="node">
|
|
435
|
+
<pre><code><span class="kw">import</span> { <span class="tp">AntzChatClient</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
436
|
+
<span class="kw">import</span> { nodeUploadFn, memStorage } <span class="kw">from</span> <span class="str">'./nodeAdapters'</span>;
|
|
437
|
+
|
|
438
|
+
<span class="kw">export const</span> <span class="at">chatClient</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
439
|
+
<span class="at">apiUrl</span>: <span class="str">'https://api.your-server.com/chat-api/api/v1'</span>,
|
|
440
|
+
<span class="at">tenantId</span>: <span class="str">'your-tenant-id'</span>,
|
|
441
|
+
<span class="at">persistStorage</span>: memStorage,
|
|
442
|
+
<span class="at">platformUploadFn</span>: nodeUploadFn,
|
|
443
|
+
});</code></pre>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<div data-m="external">
|
|
448
|
+
<h3>Client config — External Auth</h3>
|
|
449
|
+
<div data-p="rn">
|
|
450
|
+
<pre><code><span class="kw">import</span> { <span class="tp">AntzChatClient</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
451
|
+
<span class="kw">import</span> AsyncStorage <span class="kw">from</span> <span class="str">'@react-native-async-storage/async-storage'</span>;
|
|
452
|
+
<span class="kw">import</span> { rnUploadFn } <span class="kw">from</span> <span class="str">'./rnUploadFn'</span>;
|
|
453
|
+
|
|
454
|
+
<span class="kw">export const</span> <span class="at">chatClient</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
455
|
+
<span class="at">apiUrl</span>: <span class="str">'https://api.your-server.com/chat-api/api/v1'</span>,
|
|
456
|
+
<span class="at">tenantId</span>: <span class="str">'your-tenant-id'</span>,
|
|
457
|
+
<span class="at">userId</span>: <span class="str">'external-user-id'</span>,
|
|
458
|
+
<span class="at">persistStorage</span>: AsyncStorage,
|
|
459
|
+
<span class="at">platformUploadFn</span>: rnUploadFn,
|
|
460
|
+
<span class="cm">// Called every time SDK needs a token. Always read from storage — never hardcode.</span>
|
|
461
|
+
<span class="at">authProvider</span>: <span class="kw">async</span> () => AsyncStorage.<span class="fn">getItem</span>(<span class="str">'access_token'</span>),
|
|
462
|
+
<span class="at">avatar</span>: { <span class="at">url</span>: <span class="str">'https://cdn.example.com/avatar.jpg'</span> }, <span class="cm">// or { base64: '...' }</span>
|
|
463
|
+
});</code></pre>
|
|
464
|
+
</div>
|
|
465
|
+
<div data-p="web">
|
|
466
|
+
<pre><code><span class="kw">import</span> { <span class="tp">AntzChatClient</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
467
|
+
<span class="kw">import</span> { webUploadFn } <span class="kw">from</span> <span class="str">'./webUploadFn'</span>;
|
|
468
|
+
|
|
469
|
+
<span class="kw">export const</span> <span class="at">chatClient</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
470
|
+
<span class="at">apiUrl</span>: <span class="str">'https://api.your-server.com/chat-api/api/v1'</span>,
|
|
471
|
+
<span class="at">tenantId</span>: <span class="str">'your-tenant-id'</span>,
|
|
472
|
+
<span class="at">userId</span>: <span class="str">'external-user-id'</span>,
|
|
473
|
+
<span class="at">persistStorage</span>: localStorage,
|
|
474
|
+
<span class="at">platformUploadFn</span>: webUploadFn,
|
|
475
|
+
<span class="at">authProvider</span>: <span class="kw">async</span> () => localStorage.<span class="fn">getItem</span>(<span class="str">'access_token'</span>),
|
|
476
|
+
<span class="cm">// Avatar — pass url OR base64, not both</span>
|
|
477
|
+
<span class="at">avatar</span>: { <span class="at">url</span>: <span class="str">'https://cdn.example.com/avatar.jpg'</span> },
|
|
478
|
+
<span class="cm">// avatar: { base64: 'data:image/png;base64,iVBORw0K...' },</span>
|
|
479
|
+
});</code></pre>
|
|
480
|
+
</div>
|
|
481
|
+
<div data-p="node">
|
|
482
|
+
<pre><code><span class="kw">import</span> { <span class="tp">AntzChatClient</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
483
|
+
<span class="kw">import</span> { nodeUploadFn, memStorage } <span class="kw">from</span> <span class="str">'./nodeAdapters'</span>;
|
|
484
|
+
|
|
485
|
+
<span class="kw">let</span> <span class="at">_token</span> = <span class="str">''</span>;
|
|
486
|
+
<span class="kw">export const</span> <span class="fn">setToken</span> = (t: <span class="tp">string</span>) => { _token = t; };
|
|
487
|
+
|
|
488
|
+
<span class="kw">export const</span> <span class="at">chatClient</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
489
|
+
<span class="at">apiUrl</span>: <span class="str">'https://api.your-server.com/chat-api/api/v1'</span>,
|
|
490
|
+
<span class="at">tenantId</span>: <span class="str">'your-tenant-id'</span>,
|
|
491
|
+
<span class="at">userId</span>: <span class="str">'external-user-id'</span>,
|
|
492
|
+
<span class="at">persistStorage</span>: memStorage,
|
|
493
|
+
<span class="at">platformUploadFn</span>: nodeUploadFn,
|
|
494
|
+
<span class="at">authProvider</span>: <span class="kw">async</span> () => _token,
|
|
495
|
+
<span class="cm">// Avatar — pass url OR base64, not both</span>
|
|
496
|
+
<span class="at">avatar</span>: { <span class="at">url</span>: <span class="str">'https://cdn.example.com/avatar.jpg'</span> },
|
|
497
|
+
<span class="cm">// avatar: { base64: 'data:image/png;base64,iVBORw0K...' },</span>
|
|
498
|
+
});</code></pre>
|
|
499
|
+
</div>
|
|
500
|
+
<div class="callout warn">
|
|
501
|
+
<strong>authProvider vs authToken</strong>
|
|
502
|
+
Always use <code>authProvider</code> for real apps. <code>authToken</code> is a static string — once set, the SDK cannot refresh it when it expires. <code>authProvider</code> is called on every connect, so your storage always delivers the latest token.
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<h3>Full config reference</h3>
|
|
507
|
+
<table>
|
|
508
|
+
<thead><tr><th>Option</th><th>Type</th><th>Required</th><th>Description</th></tr></thead>
|
|
509
|
+
<tbody>
|
|
510
|
+
<tr><td><code>apiUrl</code></td><td><code>string</code></td><td>Yes</td><td>Full REST API base URL including <code>/api/v1</code></td></tr>
|
|
511
|
+
<tr><td><code>socketUrl</code></td><td><code>string</code></td><td>No</td><td>Auto-derived from <code>apiUrl</code> by stripping <code>/api/v1</code></td></tr>
|
|
512
|
+
<tr><td><code>tenantId</code></td><td><code>string</code></td><td>Yes</td><td>Sent as <code>X-Tenant-ID</code> on every request and socket handshake</td></tr>
|
|
513
|
+
<tr><td><code>persistStorage</code></td><td><code>PersistStorage</code></td><td>Yes</td><td>AsyncStorage (RN) · localStorage (web) · custom (Node)</td></tr>
|
|
514
|
+
<tr><td><code>platformUploadFn</code></td><td><code>PlatformUploadFn</code></td><td>Yes</td><td>Binary uploader — fetch/XHR/stream depending on platform</td></tr>
|
|
515
|
+
<tr><td><code>authProvider</code></td><td><code>() => Promise<string></code></td><td>External auth</td><td>Token getter called on every connect/reconnect</td></tr>
|
|
516
|
+
<tr><td><code>userId</code></td><td><code>string</code></td><td>External auth</td><td>Your system's user ID</td></tr>
|
|
517
|
+
<tr><td><code>avatar.url</code></td><td><code>string</code></td><td>No</td><td>Avatar URL — pass <code>url</code> OR <code>base64</code>, not both</td></tr>
|
|
518
|
+
<tr><td><code>avatar.base64</code></td><td><code>string</code></td><td>No</td><td>Raw base64 avatar string</td></tr>
|
|
519
|
+
<tr><td><code>encryptionMode</code></td><td><code>'none'|'server'</code></td><td>No</td><td>Defaults to <code>'none'</code></td></tr>
|
|
520
|
+
<tr><td><code>upload</code></td><td><code>UploadConfig</code></td><td>No</td><td>File size limits, allowed types, progress callback</td></tr>
|
|
521
|
+
</tbody>
|
|
522
|
+
</table>
|
|
523
|
+
|
|
524
|
+
<h3>Avatar</h3>
|
|
525
|
+
<p>There are three ways to set or update a user's avatar. All methods store the image in the chat server's own storage (local/S3/Azure) with SHA-256 hash deduplication — re-uploading the same image is always a no-op.</p>
|
|
526
|
+
<table>
|
|
527
|
+
<thead><tr><th>Method</th><th>When to use</th></tr></thead>
|
|
528
|
+
<tbody>
|
|
529
|
+
<tr><td><code>AntzChatConfig.avatar</code></td><td>Avatar known at init time and won't change during the session</td></tr>
|
|
530
|
+
<tr><td><code>client.auth.syncAvatar({ url })</code></td><td>Avatar URL changes after init (e.g. user updates profile in your external system) — works in any auth mode</td></tr>
|
|
531
|
+
<tr><td><code>client.auth.uploadAvatar(file)</code></td><td>User picks a file from their device — builtin auth mode only</td></tr>
|
|
532
|
+
</tbody>
|
|
533
|
+
</table>
|
|
534
|
+
|
|
535
|
+
<h4>1. Config — on init</h4>
|
|
536
|
+
<pre><code><span class="kw">const</span> <span class="at">client</span> = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
537
|
+
<span class="cm">// ... other options</span>
|
|
538
|
+
<span class="at">avatar</span>: { <span class="at">url</span>: <span class="str">'https://cdn.example.com/avatar.jpg'</span> },
|
|
539
|
+
<span class="cm">// OR: avatar: { base64: 'data:image/jpeg;base64,...' },</span>
|
|
540
|
+
});</code></pre>
|
|
541
|
+
|
|
542
|
+
<h4>2. <code>syncAvatar()</code> — post-init update (any mode)</h4>
|
|
543
|
+
<p>Call any time after init when the avatar changes. The return value is the new signed URL for the stored image.</p>
|
|
544
|
+
<pre><code><span class="cm">// From a URL</span>
|
|
545
|
+
<span class="kw">const</span> { <span class="at">avatarUrl</span> } = <span class="kw">await</span> client.auth.<span class="fn">syncAvatar</span>({ <span class="at">url</span>: <span class="str">'https://cdn.example.com/new-avatar.jpg'</span> });
|
|
546
|
+
|
|
547
|
+
<span class="cm">// From base64</span>
|
|
548
|
+
<span class="kw">const</span> { <span class="at">avatarUrl</span> } = <span class="kw">await</span> client.auth.<span class="fn">syncAvatar</span>({ <span class="at">base64</span>: <span class="str">'data:image/jpeg;base64,...'</span> });</code></pre>
|
|
549
|
+
|
|
550
|
+
<h4>3. <code>uploadAvatar()</code> — file upload (builtin mode only)</h4>
|
|
551
|
+
<pre><code><span class="kw">const</span> <span class="at">file</span> = input.files[<span class="num">0</span>]; <span class="cm">// File from <input type="file"></span>
|
|
552
|
+
<span class="kw">const</span> { <span class="at">avatarUrl</span> } = <span class="kw">await</span> client.auth.<span class="fn">uploadAvatar</span>(file);</code></pre>
|
|
553
|
+
|
|
554
|
+
<p>Supported formats: JPEG, PNG, GIF, WebP. Default max size: <strong>5 MB</strong> (configurable via <code>AVATAR_MAX_SIZE</code> env var on the server).</p>
|
|
555
|
+
</section>
|
|
556
|
+
|
|
557
|
+
<hr class="divider"/>
|
|
558
|
+
|
|
559
|
+
<!-- ─── STEP 2: AUTH ───────────────────────────────────────────────────── -->
|
|
560
|
+
<section id="step-auth-builtin" data-m="builtin">
|
|
561
|
+
<h2><span class="step">STEP 2</span> Login / Register</h2>
|
|
562
|
+
<p>Call <code>authApi.login()</code> — the SDK authenticates and stores tokens automatically. Then call <code>chatClient.connect()</code>.</p>
|
|
563
|
+
|
|
564
|
+
<h3>Login</h3>
|
|
565
|
+
<pre><code><span class="kw">import</span> { authApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
566
|
+
<span class="kw">import</span> { chatClient } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
567
|
+
|
|
568
|
+
<span class="kw">async function</span> <span class="fn">login</span>(email: <span class="tp">string</span>, password: <span class="tp">string</span>) {
|
|
569
|
+
<span class="kw">const</span> { user, tokens } = <span class="kw">await</span> authApi.<span class="fn">login</span>({ email, password });
|
|
570
|
+
<span class="cm">// Tokens stored automatically. Save user to your own app state.</span>
|
|
571
|
+
yourAppState.<span class="fn">setUser</span>(user);
|
|
572
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
573
|
+
}</code></pre>
|
|
574
|
+
|
|
575
|
+
<h3>Register</h3>
|
|
576
|
+
<pre><code><span class="kw">const</span> { user } = <span class="kw">await</span> authApi.<span class="fn">register</span>({
|
|
577
|
+
<span class="at">email</span>: <span class="str">'user@example.com'</span>, <span class="at">password</span>: <span class="str">'secret'</span>,
|
|
578
|
+
<span class="at">username</span>: <span class="str">'johndoe'</span>, <span class="at">firstName</span>: <span class="str">'John'</span>, <span class="at">lastName</span>: <span class="str">'Doe'</span>,
|
|
579
|
+
});</code></pre>
|
|
580
|
+
|
|
581
|
+
<h3>Logout</h3>
|
|
582
|
+
<pre><code><span class="kw">import</span> { chatClient } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
583
|
+
chatClient.<span class="fn">disconnect</span>(); <span class="cm">// socket first</span>
|
|
584
|
+
<span class="kw">await</span> authApi.<span class="fn">logout</span>(tokens.refreshToken); <span class="cm">// then invalidate session</span>
|
|
585
|
+
<span class="cm">// or authApi.logoutAll() — all devices</span></code></pre>
|
|
586
|
+
</section>
|
|
587
|
+
|
|
588
|
+
<section id="step-auth-external" data-m="external">
|
|
589
|
+
<h2><span class="step">STEP 2</span> Connect Your Auth</h2>
|
|
590
|
+
<p>You handle login. Store the token first, then call <code>chatClient.connect()</code>. The SDK reads it via <code>authProvider</code>.</p>
|
|
591
|
+
|
|
592
|
+
<div data-p="rn">
|
|
593
|
+
<pre><code><span class="kw">import</span> AsyncStorage <span class="kw">from</span> <span class="str">'@react-native-async-storage/async-storage'</span>;
|
|
594
|
+
<span class="kw">import</span> { chatClient } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
595
|
+
|
|
596
|
+
<span class="kw">async function</span> <span class="fn">login</span>(email: <span class="tp">string</span>, password: <span class="tp">string</span>) {
|
|
597
|
+
<span class="kw">const</span> token = <span class="kw">await</span> yourAuthSystem.<span class="fn">signIn</span>(email, password);
|
|
598
|
+
<span class="kw">await</span> AsyncStorage.<span class="fn">setItem</span>(<span class="str">'access_token'</span>, token);
|
|
599
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
<span class="cm">// Logout</span>
|
|
603
|
+
chatClient.<span class="fn">disconnect</span>();
|
|
604
|
+
<span class="kw">await</span> AsyncStorage.<span class="fn">removeItem</span>(<span class="str">'access_token'</span>);</code></pre>
|
|
605
|
+
</div>
|
|
606
|
+
<div data-p="web">
|
|
607
|
+
<pre><code><span class="kw">import</span> { chatClient } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
608
|
+
|
|
609
|
+
<span class="kw">async function</span> <span class="fn">login</span>(email: <span class="tp">string</span>, password: <span class="tp">string</span>) {
|
|
610
|
+
<span class="kw">const</span> token = <span class="kw">await</span> yourAuthSystem.<span class="fn">signIn</span>(email, password);
|
|
611
|
+
localStorage.<span class="fn">setItem</span>(<span class="str">'access_token'</span>, token);
|
|
612
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
<span class="cm">// Logout</span>
|
|
616
|
+
chatClient.<span class="fn">disconnect</span>();
|
|
617
|
+
localStorage.<span class="fn">removeItem</span>(<span class="str">'access_token'</span>);</code></pre>
|
|
618
|
+
</div>
|
|
619
|
+
<div data-p="node">
|
|
620
|
+
<pre><code><span class="kw">import</span> { chatClient, setToken } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
621
|
+
|
|
622
|
+
<span class="kw">async function</span> <span class="fn">init</span>(token: <span class="tp">string</span>) {
|
|
623
|
+
<span class="fn">setToken</span>(token); <span class="cm">// authProvider reads this</span>
|
|
624
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
625
|
+
}</code></pre>
|
|
626
|
+
</div>
|
|
627
|
+
</section>
|
|
628
|
+
|
|
629
|
+
<!-- ─── STEP 3: SOCKET ─────────────────────────────────────────────────── -->
|
|
630
|
+
<section id="step-socket">
|
|
631
|
+
<h2><span class="step">STEP 3</span> Connect the Socket</h2>
|
|
632
|
+
<p><code>chatClient.connect()</code> handles everything — it reads the token via <code>authProvider</code> and opens the socket. No need to pass the token again.</p>
|
|
633
|
+
|
|
634
|
+
<!-- RN: root provider with AppState -->
|
|
635
|
+
<div data-p="rn">
|
|
636
|
+
<h3>Root provider — session restore + foreground handling</h3>
|
|
637
|
+
<p>Tokens are 15 minutes. Always refresh before reconnecting on foreground.</p>
|
|
638
|
+
<pre><code><span class="kw">import</span> React, { useEffect } <span class="kw">from</span> <span class="str">'react'</span>;
|
|
639
|
+
<span class="kw">import</span> { AppState } <span class="kw">from</span> <span class="str">'react-native'</span>;
|
|
640
|
+
<span class="kw">import</span> AsyncStorage <span class="kw">from</span> <span class="str">'@react-native-async-storage/async-storage'</span>;
|
|
641
|
+
<span class="kw">import</span> { refreshSocketAuth, onSocketStatus } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
642
|
+
<span class="kw">import</span> { chatClient } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
643
|
+
|
|
644
|
+
<span class="kw">export function</span> <span class="fn">ChatProvider</span>({ children }: { children: React.ReactNode }) {
|
|
645
|
+
<span class="fn">useEffect</span>(() => {
|
|
646
|
+
<span class="kw">let</span> unsub: (() => <span class="tp">void</span>) | <span class="tp">undefined</span>;
|
|
647
|
+
|
|
648
|
+
<span class="kw">async function</span> <span class="fn">init</span>() {
|
|
649
|
+
<span class="kw">const</span> token = <span class="kw">await</span> AsyncStorage.<span class="fn">getItem</span>(<span class="str">'access_token'</span>);
|
|
650
|
+
<span class="kw">if</span> (!token) <span class="kw">return</span>;
|
|
651
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
652
|
+
unsub = <span class="fn">onSocketStatus</span>(s => console.<span class="fn">log</span>(<span class="str">'socket:'</span>, s));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
<span class="kw">async function</span> <span class="fn">onForeground</span>() {
|
|
656
|
+
<span class="kw">try</span> {
|
|
657
|
+
<span class="kw">const</span> newToken = <span class="kw">await</span> yourAuthSystem.<span class="fn">refreshToken</span>();
|
|
658
|
+
<span class="kw">await</span> AsyncStorage.<span class="fn">setItem</span>(<span class="str">'access_token'</span>, newToken);
|
|
659
|
+
<span class="fn">refreshSocketAuth</span>(); <span class="cm">// re-reads authProvider → updates socket.auth</span>
|
|
660
|
+
chatClient.<span class="fn">connect</span>();
|
|
661
|
+
} <span class="kw">catch</span> { <span class="cm">/* token refresh failed — user needs to re-login */</span> }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
<span class="kw">const</span> sub = AppState.<span class="fn">addEventListener</span>(<span class="str">'change'</span>, s => {
|
|
665
|
+
<span class="kw">if</span> (s === <span class="str">'background'</span>) chatClient.<span class="fn">disconnect</span>();
|
|
666
|
+
<span class="kw">if</span> (s === <span class="str">'active'</span>) <span class="fn">onForeground</span>();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
<span class="fn">init</span>();
|
|
670
|
+
<span class="kw">return</span> () => { unsub?.(); sub.<span class="fn">remove</span>(); };
|
|
671
|
+
}, []);
|
|
672
|
+
<span class="kw">return</span> <>{children}</>;
|
|
673
|
+
}</code></pre>
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
<!-- Web: visibility + localStorage -->
|
|
677
|
+
<div data-p="web">
|
|
678
|
+
<h3>Root provider — session restore + tab visibility</h3>
|
|
679
|
+
<pre><code><span class="kw">import</span> { useEffect } <span class="kw">from</span> <span class="str">'react'</span>;
|
|
680
|
+
<span class="kw">import</span> { refreshSocketAuth, onSocketStatus } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
681
|
+
<span class="kw">import</span> { chatClient } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
682
|
+
|
|
683
|
+
<span class="kw">export function</span> <span class="fn">ChatProvider</span>({ children }: { children: React.ReactNode }) {
|
|
684
|
+
<span class="fn">useEffect</span>(() => {
|
|
685
|
+
<span class="kw">const</span> token = localStorage.<span class="fn">getItem</span>(<span class="str">'access_token'</span>);
|
|
686
|
+
<span class="kw">if</span> (token) chatClient.<span class="fn">connect</span>();
|
|
687
|
+
|
|
688
|
+
<span class="kw">const</span> <span class="fn">onVisibility</span> = <span class="kw">async</span> () => {
|
|
689
|
+
<span class="kw">if</span> (document.visibilityState === <span class="str">'hidden'</span>) { chatClient.<span class="fn">disconnect</span>(); <span class="kw">return</span>; }
|
|
690
|
+
<span class="kw">try</span> {
|
|
691
|
+
<span class="kw">const</span> newToken = <span class="kw">await</span> yourAuthSystem.<span class="fn">refreshToken</span>();
|
|
692
|
+
localStorage.<span class="fn">setItem</span>(<span class="str">'access_token'</span>, newToken);
|
|
693
|
+
<span class="fn">refreshSocketAuth</span>();
|
|
694
|
+
chatClient.<span class="fn">connect</span>();
|
|
695
|
+
} <span class="kw">catch</span> {}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
document.<span class="fn">addEventListener</span>(<span class="str">'visibilitychange'</span>, onVisibility);
|
|
699
|
+
<span class="kw">const</span> unsub = <span class="fn">onSocketStatus</span>(s => console.<span class="fn">log</span>(<span class="str">'socket:'</span>, s));
|
|
700
|
+
<span class="kw">return</span> () => { document.<span class="fn">removeEventListener</span>(<span class="str">'visibilitychange'</span>, onVisibility); unsub(); };
|
|
701
|
+
}, []);
|
|
702
|
+
<span class="kw">return</span> <>{children}</>;
|
|
703
|
+
}</code></pre>
|
|
704
|
+
</div>
|
|
705
|
+
|
|
706
|
+
<!-- Node.js: simple connect -->
|
|
707
|
+
<div data-p="node">
|
|
708
|
+
<h3>Connect in your server startup</h3>
|
|
709
|
+
<pre><code><span class="kw">import</span> { chatClient, setToken } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
710
|
+
|
|
711
|
+
<span class="kw">async function</span> <span class="fn">bootstrap</span>() {
|
|
712
|
+
<span class="fn">setToken</span>(process.env.CHAT_TOKEN!);
|
|
713
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
714
|
+
console.<span class="fn">log</span>(<span class="str">'Chat socket connected'</span>);
|
|
715
|
+
}</code></pre>
|
|
716
|
+
<div class="callout info">
|
|
717
|
+
<strong>Token refresh in Node</strong> — call <code>setToken(newToken)</code> then <code>refreshSocketAuth()</code> from <code>@antzsoft/chat-core</code> whenever your token refreshes.
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
|
|
721
|
+
<div class="callout info">
|
|
722
|
+
<strong>Auto-reconnect is built in.</strong>
|
|
723
|
+
Network drops retry automatically (up to 10 attempts, 1–5 s backoff). Auto-reconnect reuses the token from the original connect call — it does NOT call <code>authProvider</code> again. That's why foreground needs <code>refreshSocketAuth()</code> explicitly.
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<h3>Token refresh mid-session (without going to background)</h3>
|
|
727
|
+
<p>Wire this into your HTTP interceptor — wherever you refresh the access token, call <code>refreshSocketAuth()</code> immediately after updating storage.</p>
|
|
728
|
+
<pre><code><span class="kw">import</span> { refreshSocketAuth } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
729
|
+
|
|
730
|
+
<span class="kw">const</span> newToken = <span class="kw">await</span> yourAuthSystem.<span class="fn">refreshToken</span>();
|
|
731
|
+
<span class="cm">// Update storage first (authProvider reads from here)</span>
|
|
732
|
+
<span class="cm">// RN: await AsyncStorage.setItem('access_token', newToken)</span>
|
|
733
|
+
<span class="cm">// Web: localStorage.setItem('access_token', newToken)</span>
|
|
734
|
+
<span class="fn">refreshSocketAuth</span>(); <span class="cm">// SDK re-reads authProvider → updates socket.auth in place</span></code></pre>
|
|
735
|
+
</section>
|
|
736
|
+
|
|
737
|
+
<!-- ─── STEP 4: STATUS ─────────────────────────────────────────────────── -->
|
|
738
|
+
<section id="step-status">
|
|
739
|
+
<h2><span class="step">STEP 4</span> Monitor Socket Status</h2>
|
|
740
|
+
<div data-p="rn web">
|
|
741
|
+
<pre><code><span class="kw">import</span> { onSocketStatus, getSocketStatus, <span class="tp">SocketStatus</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
742
|
+
|
|
743
|
+
<span class="kw">const</span> [status, setStatus] = <span class="fn">useState</span><<span class="tp">SocketStatus</span>>(<span class="fn">getSocketStatus</span>());
|
|
744
|
+
<span class="fn">useEffect</span>(() => <span class="fn">onSocketStatus</span>(setStatus), []);</code></pre>
|
|
745
|
+
</div>
|
|
746
|
+
<div data-p="node">
|
|
747
|
+
<pre><code><span class="kw">import</span> { onSocketStatus, getSocketStatus } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
748
|
+
|
|
749
|
+
<span class="fn">onSocketStatus</span>((status) => console.<span class="fn">log</span>(<span class="str">'socket:'</span>, status));
|
|
750
|
+
console.<span class="fn">log</span>(<span class="fn">getSocketStatus</span>()); <span class="cm">// 'connected' | 'disconnected' | 'reconnecting' | 'error'</span></code></pre>
|
|
751
|
+
</div>
|
|
752
|
+
<table>
|
|
753
|
+
<thead><tr><th>Status</th><th>Meaning</th></tr></thead>
|
|
754
|
+
<tbody>
|
|
755
|
+
<tr><td><code>'disconnected'</code></td><td>Not connected, no active attempt</td></tr>
|
|
756
|
+
<tr><td><code>'connecting'</code></td><td>Initial connection in progress</td></tr>
|
|
757
|
+
<tr><td><code>'connected'</code></td><td>Fully connected, events flowing</td></tr>
|
|
758
|
+
<tr><td><code>'reconnecting'</code></td><td>Lost connection, retrying automatically</td></tr>
|
|
759
|
+
<tr><td><code>'error'</code></td><td>Connection rejected — check token / server</td></tr>
|
|
760
|
+
</tbody>
|
|
761
|
+
</table>
|
|
762
|
+
</section>
|
|
763
|
+
|
|
764
|
+
<!-- ─── STEP 5: DISCONNECT ────────────────────────────────────────────── -->
|
|
765
|
+
<section id="step-disconnect">
|
|
766
|
+
<h2><span class="step">STEP 5</span> Disconnect</h2>
|
|
767
|
+
<p>Background/tab-hidden handling is already in <code>ChatProvider</code> above. The only other place you need to disconnect is on logout.</p>
|
|
768
|
+
<pre><code><span class="cm">// Always disconnect before clearing the session</span>
|
|
769
|
+
chatClient.<span class="fn">disconnect</span>();
|
|
770
|
+
<span class="cm">// then clear your token / call your auth system logout</span></code></pre>
|
|
771
|
+
<div class="callout warn">
|
|
772
|
+
<strong>Disconnect before clearing the token.</strong>
|
|
773
|
+
If you clear the token first and the socket auto-reconnects, it will reconnect with no token and get rejected by the server.
|
|
774
|
+
</div>
|
|
775
|
+
</section>
|
|
776
|
+
|
|
777
|
+
<hr class="divider"/>
|
|
778
|
+
|
|
779
|
+
<!-- ─── STEP 6: CONVERSATIONS ─────────────────────────────────────────── -->
|
|
780
|
+
<section id="step-convs">
|
|
781
|
+
<h2><span class="step">STEP 6</span> List & Manage Conversations</h2>
|
|
782
|
+
|
|
783
|
+
<h3>Listing & Filtering</h3>
|
|
784
|
+
<p><code>conversationsApi.list()</code> accepts a <code>ConversationListParams</code> object. All filters are applied server-side before pagination — totals and page counts always reflect the filtered set.</p>
|
|
785
|
+
|
|
786
|
+
<table>
|
|
787
|
+
<tr><th>Filter</th><th>Type</th><th>Description</th></tr>
|
|
788
|
+
<tr><td><code>page</code></td><td><code>number</code></td><td>Page number — omit (along with <code>limit</code>) to return all results</td></tr>
|
|
789
|
+
<tr><td><code>limit</code></td><td><code>number</code></td><td>Results per page — omit (along with <code>page</code>) to return all results</td></tr>
|
|
790
|
+
<tr><td><code>type</code></td><td><code>'direct' | 'group'</code></td><td>Filter by conversation type</td></tr>
|
|
791
|
+
<tr><td><code>isPinned</code></td><td><code>boolean</code></td><td><code>true</code> = only pinned, <code>false</code> = only unpinned</td></tr>
|
|
792
|
+
<tr><td><code>isMuted</code></td><td><code>boolean</code></td><td><code>true</code> = only muted, <code>false</code> = only unmuted</td></tr>
|
|
793
|
+
<tr><td><code>hasUnread</code></td><td><code>boolean</code></td><td>Only conversations with at least 1 unread message</td></tr>
|
|
794
|
+
<tr><td><code>search</code></td><td><code>string</code></td><td>Server-side text search on group name & description</td></tr>
|
|
795
|
+
<tr><td><code>role</code></td><td><code>'admin' | 'member'</code></td><td>Current user's role in the conversation</td></tr>
|
|
796
|
+
<tr><td><code>hasAttachments</code></td><td><code>boolean</code></td><td>Last message has attachments</td></tr>
|
|
797
|
+
<tr><td><code>attachmentType</code></td><td><code>'image' | 'video' | 'document' | 'audio'</code></td><td>Last message attachment type</td></tr>
|
|
798
|
+
<tr><td><code>notificationsEnabled</code></td><td><code>boolean</code></td><td>Notification setting for the current user</td></tr>
|
|
799
|
+
</table>
|
|
800
|
+
|
|
801
|
+
<div class="callout info"><strong>Pagination is optional</strong> Omit <code>page</code> and <code>limit</code> to receive all matching conversations in one response. Pass both to opt into offset pagination. All filters run as MongoDB aggregation stages before <code>$skip</code> / <code>$limit</code> — the <code>hasUnread</code> filter uses a <code>$lookup</code> join inside the pipeline, no post-filter workarounds.</div>
|
|
802
|
+
|
|
803
|
+
<pre><code><span class="kw">import</span> { conversationsApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
804
|
+
|
|
805
|
+
<span class="cm">// All conversations (omit page + limit)</span>
|
|
806
|
+
<span class="kw">const</span> { data } = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>();
|
|
807
|
+
|
|
808
|
+
<span class="cm">// Paginated (opt in by passing page + limit)</span>
|
|
809
|
+
<span class="kw">const</span> { data, meta } = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">page</span>: <span class="num">1</span>, <span class="at">limit</span>: <span class="num">20</span> });
|
|
810
|
+
|
|
811
|
+
<span class="cm">// Filter by type</span>
|
|
812
|
+
<span class="kw">const</span> groups = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">type</span>: <span class="str">'group'</span> });
|
|
813
|
+
<span class="kw">const</span> dms = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">type</span>: <span class="str">'direct'</span> });
|
|
814
|
+
|
|
815
|
+
<span class="cm">// Pinned / muted / unread</span>
|
|
816
|
+
<span class="kw">const</span> pinned = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">isPinned</span>: <span class="kw">true</span> });
|
|
817
|
+
<span class="kw">const</span> muted = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">isMuted</span>: <span class="kw">true</span> });
|
|
818
|
+
<span class="kw">const</span> unread = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">hasUnread</span>: <span class="kw">true</span> });
|
|
819
|
+
|
|
820
|
+
<span class="cm">// Text search (server-side MongoDB text index on name + description)</span>
|
|
821
|
+
<span class="kw">const</span> found = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({ <span class="at">search</span>: <span class="str">'design'</span> });
|
|
822
|
+
|
|
823
|
+
<span class="cm">// Admin-only conversations + combined filters</span>
|
|
824
|
+
<span class="kw">const</span> urgent = <span class="kw">await</span> conversationsApi.<span class="fn">list</span>({
|
|
825
|
+
<span class="at">type</span>: <span class="str">'group'</span>, <span class="at">isPinned</span>: <span class="kw">true</span>, <span class="at">hasUnread</span>: <span class="kw">true</span>,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
<span class="cm">// Get single</span>
|
|
829
|
+
<span class="kw">const</span> conv = <span class="kw">await</span> conversationsApi.<span class="fn">get</span>(conversationId);
|
|
830
|
+
|
|
831
|
+
<span class="cm">// List all users (no page/limit = server returns everything)</span>
|
|
832
|
+
<span class="kw">const</span> { data: users } = <span class="kw">await</span> usersApi.<span class="fn">list</span>();
|
|
833
|
+
|
|
834
|
+
<span class="cm">// Search users by name / username / email</span>
|
|
835
|
+
<span class="kw">const</span> { data: results } = <span class="kw">await</span> usersApi.<span class="fn">list</span>({ <span class="at">query</span>: <span class="str">'john'</span> });
|
|
836
|
+
|
|
837
|
+
<span class="cm">// Create DM — returns existing if already exists</span>
|
|
838
|
+
<span class="kw">const</span> dm = <span class="kw">await</span> conversationsApi.<span class="fn">createDirect</span>({ <span class="at">userId</span>: <span class="str">'target-user-id'</span> });
|
|
839
|
+
|
|
840
|
+
<span class="cm">// Create group</span>
|
|
841
|
+
<span class="kw">const</span> group = <span class="kw">await</span> conversationsApi.<span class="fn">createGroup</span>({
|
|
842
|
+
<span class="at">name</span>: <span class="str">'Design Team'</span>, <span class="at">participantIds</span>: [<span class="str">'user-1'</span>, <span class="str">'user-2'</span>],
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
<span class="cm">// Participants</span>
|
|
846
|
+
<span class="kw">const</span> members = <span class="kw">await</span> conversationsApi.<span class="fn">getMembers</span>(conversationId);
|
|
847
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">addParticipants</span>(conversationId, [<span class="str">'user-3'</span>]);
|
|
848
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">removeParticipant</span>(conversationId, <span class="str">'user-2'</span>);
|
|
849
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">updateParticipantRole</span>(conversationId, <span class="str">'user-1'</span>, <span class="str">'admin'</span>);
|
|
850
|
+
|
|
851
|
+
<span class="cm">// Pin · Mute · Leave · Delete</span>
|
|
852
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">pin</span>(conversationId);
|
|
853
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">mute</span>(conversationId, <span class="str">'2025-12-31T23:59:59Z'</span>); <span class="cm">// omit date = indefinite</span>
|
|
854
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">leave</span>(conversationId);
|
|
855
|
+
<span class="kw">await</span> conversationsApi.<span class="fn">delete</span>(conversationId); <span class="cm">// admin only</span></code></pre>
|
|
856
|
+
</section>
|
|
857
|
+
|
|
858
|
+
<!-- ─── STEP 7: ROOMS ──────────────────────────────────────────────────── -->
|
|
859
|
+
<section id="step-rooms">
|
|
860
|
+
<h2><span class="step">STEP 7</span> Join & Leave a Room</h2>
|
|
861
|
+
<p>Joining a room subscribes your socket to real-time events for that conversation. Both <code>joinRoom</code> and <code>leaveRoom</code> are fire-and-forget — they silently no-op if the socket isn't connected.</p>
|
|
862
|
+
|
|
863
|
+
<div data-p="rn web">
|
|
864
|
+
<pre><code><span class="kw">import</span> { socketEmit, onSocketStatus } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
865
|
+
|
|
866
|
+
<span class="fn">useEffect</span>(() => {
|
|
867
|
+
socketEmit.<span class="fn">joinRoom</span>(conversationId);
|
|
868
|
+
|
|
869
|
+
<span class="cm">// Re-join if socket reconnects while this screen is open (e.g. after foreground)</span>
|
|
870
|
+
<span class="kw">const</span> unsub = <span class="fn">onSocketStatus</span>((s) => {
|
|
871
|
+
<span class="kw">if</span> (s === <span class="str">'connected'</span>) socketEmit.<span class="fn">joinRoom</span>(conversationId);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
<span class="kw">return</span> () => { socketEmit.<span class="fn">leaveRoom</span>(conversationId); unsub(); };
|
|
875
|
+
}, [conversationId]);</code></pre>
|
|
876
|
+
</div>
|
|
877
|
+
<div data-p="node">
|
|
878
|
+
<pre><code><span class="kw">import</span> { socketEmit } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
879
|
+
|
|
880
|
+
socketEmit.<span class="fn">joinRoom</span>(conversationId);
|
|
881
|
+
<span class="cm">// When done:</span>
|
|
882
|
+
socketEmit.<span class="fn">leaveRoom</span>(conversationId);</code></pre>
|
|
883
|
+
</div>
|
|
884
|
+
<div class="callout warn">
|
|
885
|
+
<strong>Room subscriptions don't survive reconnects.</strong>
|
|
886
|
+
The server forgets which rooms you were in if the socket disconnects. Always re-join after reconnect (the <code>onSocketStatus</code> pattern above handles this for React apps).
|
|
887
|
+
</div>
|
|
888
|
+
</section>
|
|
889
|
+
|
|
890
|
+
<hr class="divider"/>
|
|
891
|
+
|
|
892
|
+
<!-- ─── STEP 8: LOAD MESSAGES ─────────────────────────────────────────── -->
|
|
893
|
+
<section id="step-load">
|
|
894
|
+
<h2><span class="step">STEP 8</span> Load Messages</h2>
|
|
895
|
+
<p>Cursor-paginated. Load latest on open, fetch older as user scrolls up.</p>
|
|
896
|
+
<pre><code><span class="kw">import</span> { messagesApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
897
|
+
|
|
898
|
+
<span class="cm">// Initial load</span>
|
|
899
|
+
<span class="kw">const</span> { data: messages, meta } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, { <span class="at">limit</span>: <span class="num">30</span> });
|
|
900
|
+
|
|
901
|
+
<span class="cm">// Load older (scroll up)</span>
|
|
902
|
+
<span class="kw">const</span> { data: older } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, {
|
|
903
|
+
<span class="at">cursor</span>: meta.nextCursor, <span class="at">direction</span>: <span class="str">'before'</span>, <span class="at">limit</span>: <span class="num">30</span>,
|
|
904
|
+
});
|
|
905
|
+
<span class="cm">// meta.hasMore — false when you've reached the beginning</span></code></pre>
|
|
906
|
+
</section>
|
|
907
|
+
|
|
908
|
+
<!-- ─── STEP 9: SEND ───────────────────────────────────────────────────── -->
|
|
909
|
+
<section id="step-send">
|
|
910
|
+
<h2><span class="step">STEP 9</span> Send a Message</h2>
|
|
911
|
+
<pre><code><span class="kw">import</span> { socketEmit } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
912
|
+
<span class="kw">import</span> { v4 <span class="kw">as</span> uuid } <span class="kw">from</span> <span class="str">'uuid'</span>;
|
|
913
|
+
|
|
914
|
+
<span class="cm">// Text</span>
|
|
915
|
+
<span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">text</span>: <span class="str">'Hello!'</span>, <span class="at">tempId</span>: <span class="fn">uuid</span>() });
|
|
916
|
+
|
|
917
|
+
<span class="cm">// Reply</span>
|
|
918
|
+
<span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">text</span>: <span class="str">'Agreed'</span>, <span class="at">tempId</span>: <span class="fn">uuid</span>(), <span class="at">replyTo</span>: messageId });
|
|
919
|
+
|
|
920
|
+
<span class="cm">// With attachments — upload first (Step 16), then send fileIds</span>
|
|
921
|
+
<span class="kw">const</span> { successful } = <span class="kw">await</span> chatClient.<span class="fn">uploadFiles</span>(files, conversationId);
|
|
922
|
+
<span class="kw">const</span> attachments = successful.<span class="fn">map</span>(f => ({
|
|
923
|
+
<span class="at">fileId</span>: f.id, <span class="at">type</span>: f.type, <span class="at">url</span>: f.url, <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
|
|
924
|
+
}));
|
|
925
|
+
<span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(), attachments });</code></pre>
|
|
926
|
+
</section>
|
|
927
|
+
|
|
928
|
+
<!-- ─── STEP 10: REAL-TIME ─────────────────────────────────────────────── -->
|
|
929
|
+
<section id="step-realtime">
|
|
930
|
+
<h2><span class="step">STEP 10</span> Real-time Events</h2>
|
|
931
|
+
<div data-p="rn web">
|
|
932
|
+
<pre><code><span class="kw">import</span> { tryGetSocket } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
933
|
+
|
|
934
|
+
<span class="fn">useEffect</span>(() => {
|
|
935
|
+
<span class="kw">const</span> socket = <span class="fn">tryGetSocket</span>();
|
|
936
|
+
<span class="kw">if</span> (!socket) <span class="kw">return</span>;
|
|
937
|
+
|
|
938
|
+
<span class="kw">const</span> <span class="fn">onNew</span> = ({ message, tempId }: <span class="tp">NewMessageEvent</span>) =>
|
|
939
|
+
<span class="fn">setMessages</span>(prev => {
|
|
940
|
+
<span class="kw">const</span> idx = prev.<span class="fn">findIndex</span>(m => m.id === tempId);
|
|
941
|
+
<span class="kw">if</span> (idx !== -<span class="num">1</span>) { <span class="kw">const</span> n = [...prev]; n[idx] = message; <span class="kw">return</span> n; }
|
|
942
|
+
<span class="kw">return</span> [...prev, message];
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
<span class="kw">const</span> <span class="fn">onUpdated</span> = ({ messageId, text, editedAt }: <span class="tp">MessageUpdatedEvent</span>) =>
|
|
946
|
+
<span class="fn">setMessages</span>(prev => prev.<span class="fn">map</span>(m =>
|
|
947
|
+
m.id === messageId ? { ...m, content: { ...m.content, text }, <span class="at">isEdited</span>: <span class="kw">true</span>, editedAt } : m
|
|
948
|
+
));
|
|
949
|
+
|
|
950
|
+
<span class="kw">const</span> <span class="fn">onDeleted</span> = ({ messageId }: <span class="tp">MessageDeletedEvent</span>) =>
|
|
951
|
+
<span class="fn">setMessages</span>(prev => prev.<span class="fn">filter</span>(m => m.id !== messageId));
|
|
952
|
+
|
|
953
|
+
socket.<span class="fn">on</span>(<span class="str">'new_message'</span>, onNew);
|
|
954
|
+
socket.<span class="fn">on</span>(<span class="str">'message_updated'</span>, onUpdated);
|
|
955
|
+
socket.<span class="fn">on</span>(<span class="str">'message_deleted'</span>, onDeleted);
|
|
956
|
+
<span class="kw">return</span> () => {
|
|
957
|
+
socket.<span class="fn">off</span>(<span class="str">'new_message'</span>, onNew);
|
|
958
|
+
socket.<span class="fn">off</span>(<span class="str">'message_updated'</span>, onUpdated);
|
|
959
|
+
socket.<span class="fn">off</span>(<span class="str">'message_deleted'</span>, onDeleted);
|
|
960
|
+
};
|
|
961
|
+
}, [conversationId]);</code></pre>
|
|
962
|
+
</div>
|
|
963
|
+
<div data-p="node">
|
|
964
|
+
<pre><code><span class="kw">import</span> { getSocket } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
965
|
+
|
|
966
|
+
<span class="kw">const</span> socket = <span class="fn">getSocket</span>();
|
|
967
|
+
socket.<span class="fn">on</span>(<span class="str">'new_message'</span>, ({ message }) => console.<span class="fn">log</span>(<span class="str">'new:'</span>, message));
|
|
968
|
+
socket.<span class="fn">on</span>(<span class="str">'message_updated'</span>, (e) => console.<span class="fn">log</span>(<span class="str">'updated:'</span>, e));
|
|
969
|
+
socket.<span class="fn">on</span>(<span class="str">'message_deleted'</span>, (e) => console.<span class="fn">log</span>(<span class="str">'deleted:'</span>, e));</code></pre>
|
|
970
|
+
</div>
|
|
971
|
+
<h4>All socket events</h4>
|
|
972
|
+
<table>
|
|
973
|
+
<thead><tr><th>Event</th><th>When fired</th></tr></thead>
|
|
974
|
+
<tbody>
|
|
975
|
+
<tr><td><code>'new_message'</code></td><td>New message in a joined room</td></tr>
|
|
976
|
+
<tr><td><code>'message_updated'</code></td><td>A message was edited</td></tr>
|
|
977
|
+
<tr><td><code>'message_deleted'</code></td><td>Deleted for everyone</td></tr>
|
|
978
|
+
<tr><td><code>'message_deleted_for_me'</code></td><td>Deleted for current user only</td></tr>
|
|
979
|
+
<tr><td><code>'reaction_updated'</code></td><td>Emoji reaction added/removed</td></tr>
|
|
980
|
+
<tr><td><code>'typing'</code></td><td>User started/stopped typing</td></tr>
|
|
981
|
+
<tr><td><code>'user_online'</code> / <code>'user_offline'</code></td><td>User went online/offline — auto-updates <code>useChatStore.lastSeen</code></td></tr>
|
|
982
|
+
<tr><td><code>'read_receipt'</code></td><td>Messages marked as read — auto-updates <code>useChatStore.lastRead</code></td></tr>
|
|
983
|
+
<tr><td><code>'message_ack'</code></td><td>Your sent message acknowledged (tempId → real ID)</td></tr>
|
|
984
|
+
<tr><td><code>'message_delivered'</code></td><td>Message delivered to recipient</td></tr>
|
|
985
|
+
</tbody>
|
|
986
|
+
</table>
|
|
987
|
+
</section>
|
|
988
|
+
|
|
989
|
+
<!-- ─── STEP 11: TYPING ───────────────────────────────────────────────── -->
|
|
990
|
+
<section id="step-typing">
|
|
991
|
+
<h2><span class="step">STEP 11</span> Typing Indicators</h2>
|
|
992
|
+
<div data-p="rn web">
|
|
993
|
+
<pre><code><span class="kw">const</span> timer = <span class="fn">useRef</span><<span class="tp">ReturnType</span><<span class="kw">typeof</span> setTimeout>>();
|
|
994
|
+
|
|
995
|
+
<span class="kw">function</span> <span class="fn">onChangeText</span>(val: <span class="tp">string</span>) {
|
|
996
|
+
<span class="fn">setValue</span>(val);
|
|
997
|
+
socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">true</span>);
|
|
998
|
+
<span class="fn">clearTimeout</span>(timer.current);
|
|
999
|
+
timer.current = <span class="fn">setTimeout</span>(() => socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">false</span>), <span class="num">3000</span>);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
<span class="cm">// Listen for others typing</span>
|
|
1003
|
+
socket.<span class="fn">on</span>(<span class="str">'typing'</span>, ({ conversationId, userId, displayName, isTyping }) => {
|
|
1004
|
+
isTyping ? <span class="fn">addTypingUser</span>(conversationId, { userId, displayName })
|
|
1005
|
+
: <span class="fn">removeTypingUser</span>(conversationId, userId);
|
|
1006
|
+
});</code></pre>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div data-p="node">
|
|
1009
|
+
<pre><code>socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">true</span>);
|
|
1010
|
+
<span class="kw">setTimeout</span>(() => socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">false</span>), <span class="num">3000</span>);</code></pre>
|
|
1011
|
+
</div>
|
|
1012
|
+
</section>
|
|
1013
|
+
|
|
1014
|
+
<!-- ─── STEP 12: READ RECEIPTS & LAST SEEN ────────────────────────────── -->
|
|
1015
|
+
<section id="step-read">
|
|
1016
|
+
<h2><span class="step">STEP 12</span> Read Receipts & Last Seen</h2>
|
|
1017
|
+
|
|
1018
|
+
<p>There are three related concepts here:</p>
|
|
1019
|
+
<table>
|
|
1020
|
+
<thead><tr><th>Concept</th><th>What it is</th><th>Where it lives</th></tr></thead>
|
|
1021
|
+
<tbody>
|
|
1022
|
+
<tr><td><strong>Mark as read</strong></td><td>Tell the server the current user has read messages</td><td><code>socketEmit.markRead()</code></td></tr>
|
|
1023
|
+
<tr><td><strong>Last-read pointer</strong></td><td>Which message the current user last read in a conversation</td><td><code>useChatStore.lastRead[conversationId]</code></td></tr>
|
|
1024
|
+
<tr><td><strong>Last seen</strong></td><td>When another user was last online</td><td><code>useChatStore.lastSeen[userId]</code></td></tr>
|
|
1025
|
+
</tbody>
|
|
1026
|
+
</table>
|
|
1027
|
+
|
|
1028
|
+
<h3>Marking as read</h3>
|
|
1029
|
+
<p>Call this when the user opens or focuses a conversation. The server broadcasts a <code>read_receipt</code> event to all participants so their UIs can update.</p>
|
|
1030
|
+
|
|
1031
|
+
<div data-p="rn">
|
|
1032
|
+
<pre><code><span class="kw">import</span> { useFocusEffect } <span class="kw">from</span> <span class="str">'@react-navigation/native'</span>;
|
|
1033
|
+
<span class="kw">import</span> { socketEmit } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1034
|
+
|
|
1035
|
+
<span class="cm">// Mark the whole conversation read every time this screen comes into focus</span>
|
|
1036
|
+
<span class="fn">useFocusEffect</span>(<span class="fn">useCallback</span>(() => {
|
|
1037
|
+
socketEmit.<span class="fn">markRead</span>(conversationId);
|
|
1038
|
+
}, [conversationId]));
|
|
1039
|
+
|
|
1040
|
+
<span class="cm">// Mark up to a specific message (e.g. last visible in the list)</span>
|
|
1041
|
+
socketEmit.<span class="fn">markRead</span>(conversationId, messageId);</code></pre>
|
|
1042
|
+
</div>
|
|
1043
|
+
<div data-p="web">
|
|
1044
|
+
<pre><code><span class="kw">import</span> { socketEmit } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1045
|
+
|
|
1046
|
+
<span class="cm">// Mark read when the conversation becomes visible</span>
|
|
1047
|
+
socketEmit.<span class="fn">markRead</span>(conversationId);
|
|
1048
|
+
|
|
1049
|
+
<span class="cm">// Mark up to a specific message</span>
|
|
1050
|
+
socketEmit.<span class="fn">markRead</span>(conversationId, messageId);</code></pre>
|
|
1051
|
+
</div>
|
|
1052
|
+
<div data-p="node">
|
|
1053
|
+
<pre><code><span class="kw">import</span> { socketEmit, messagesApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1054
|
+
|
|
1055
|
+
socketEmit.<span class="fn">markRead</span>(conversationId); <span class="cm">// fire-and-forget via socket</span>
|
|
1056
|
+
<span class="kw">await</span> messagesApi.<span class="fn">markAsRead</span>(conversationId); <span class="cm">// REST alternative (awaitable)</span></code></pre>
|
|
1057
|
+
</div>
|
|
1058
|
+
|
|
1059
|
+
<hr class="divider" style="margin:28px 0"/>
|
|
1060
|
+
|
|
1061
|
+
<h3>Last-read pointer — where the user left off</h3>
|
|
1062
|
+
<p>The store keeps a <strong>last-read pointer</strong> per conversation: the ID and timestamp of the last message the current user read. This is useful for scroll-to-unread and "unread from here" badges.</p>
|
|
1063
|
+
|
|
1064
|
+
<div class="callout tip">
|
|
1065
|
+
<strong>Automatic after connect</strong>
|
|
1066
|
+
Once the socket is connected, <code>read_receipt</code> events automatically update <code>useChatStore.lastRead</code>. You only need to seed it manually on the very first load (before any socket event has arrived).
|
|
1067
|
+
</div>
|
|
1068
|
+
|
|
1069
|
+
<pre><code><span class="kw">import</span> { messagesApi, useChatStore, <span class="tp">LastReadEntry</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1070
|
+
|
|
1071
|
+
<span class="cm">// Step 1 — seed the store when the conversation screen opens</span>
|
|
1072
|
+
<span class="kw">const</span> { lastReadMessageId, lastReadAt } = <span class="kw">await</span> messagesApi.<span class="fn">getLastRead</span>(conversationId);
|
|
1073
|
+
<span class="kw">if</span> (lastReadMessageId && lastReadAt) {
|
|
1074
|
+
useChatStore.<span class="fn">getState</span>().<span class="fn">setLastRead</span>(conversationId, lastReadMessageId, lastReadAt);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
<span class="cm">// Step 2 — read reactively anywhere in your UI</span>
|
|
1078
|
+
<span class="kw">const</span> lastRead: <span class="tp">LastReadEntry</span> | <span class="tp">undefined</span> = useChatStore(s => s.lastRead[conversationId]);
|
|
1079
|
+
<span class="cm">// lastRead.messageId — scroll to this on open</span>
|
|
1080
|
+
<span class="cm">// lastRead.readAt — ISO timestamp</span></code></pre>
|
|
1081
|
+
|
|
1082
|
+
<div data-p="rn web">
|
|
1083
|
+
<pre><code><span class="cm">// Practical example: show "New messages" divider above unread messages</span>
|
|
1084
|
+
<span class="kw">const</span> lastRead = <span class="fn">useChatStore</span>(s => s.lastRead[conversationId]);
|
|
1085
|
+
|
|
1086
|
+
<span class="kw">function</span> <span class="fn">MessageRow</span>({ message, prevMessage }) {
|
|
1087
|
+
<span class="kw">const</span> isFirstUnread =
|
|
1088
|
+
lastRead &&
|
|
1089
|
+
prevMessage?.id === lastRead.messageId; <span class="cm">// this message is right after the last-read one</span>
|
|
1090
|
+
|
|
1091
|
+
<span class="kw">return</span> (
|
|
1092
|
+
<>
|
|
1093
|
+
{isFirstUnread && <<span class="fn">UnreadDivider</span> />}
|
|
1094
|
+
<<span class="fn">Bubble</span> message={message} />
|
|
1095
|
+
</>
|
|
1096
|
+
);
|
|
1097
|
+
}</code></pre>
|
|
1098
|
+
</div>
|
|
1099
|
+
|
|
1100
|
+
<hr class="divider" style="margin:28px 0"/>
|
|
1101
|
+
|
|
1102
|
+
<h3>Last seen — when another user was online</h3>
|
|
1103
|
+
<p>Each user's last-seen timestamp is stored in <code>useChatStore.lastSeen[userId]</code>. The socket keeps it live: when a user disconnects, a <code>user_offline</code> event fires and the store updates automatically.</p>
|
|
1104
|
+
|
|
1105
|
+
<div class="callout tip">
|
|
1106
|
+
<strong>Automatic after connect</strong>
|
|
1107
|
+
<code>user_offline</code> events auto-update <code>useChatStore.lastSeen</code>. Seed the initial value from the API on first load.
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
<pre><code><span class="kw">import</span> { usersApi, useChatStore } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1111
|
+
|
|
1112
|
+
<span class="cm">// Seed on first render (e.g. when opening a DM or a user profile)</span>
|
|
1113
|
+
<span class="kw">const</span> { lastSeenAt } = <span class="kw">await</span> usersApi.<span class="fn">getLastSeen</span>(userId);
|
|
1114
|
+
<span class="kw">if</span> (lastSeenAt) useChatStore.<span class="fn">getState</span>().<span class="fn">setLastSeen</span>(userId, lastSeenAt);
|
|
1115
|
+
|
|
1116
|
+
<span class="cm">// Read reactively — updates whenever user_offline fires</span>
|
|
1117
|
+
<span class="kw">const</span> lastSeenAt = <span class="fn">useChatStore</span>(s => s.lastSeen[userId]); <span class="cm">// ISO string | undefined</span>
|
|
1118
|
+
<span class="kw">const</span> isOnline = <span class="fn">useChatStore</span>(s => s.onlineUsers.<span class="fn">includes</span>(userId));</code></pre>
|
|
1119
|
+
|
|
1120
|
+
<div data-p="rn web">
|
|
1121
|
+
<pre><code><span class="cm">// "Last seen 5 minutes ago" — format the ISO string however you like</span>
|
|
1122
|
+
<span class="kw">function</span> <span class="fn">LastSeenLabel</span>({ userId }: { userId: <span class="tp">string</span> }) {
|
|
1123
|
+
<span class="kw">const</span> isOnline = <span class="fn">useChatStore</span>(s => s.onlineUsers.<span class="fn">includes</span>(userId));
|
|
1124
|
+
<span class="kw">const</span> lastSeenAt = <span class="fn">useChatStore</span>(s => s.lastSeen[userId]);
|
|
1125
|
+
|
|
1126
|
+
<span class="kw">if</span> (isOnline) <span class="kw">return</span> <<span class="fn">Text</span>>Online</<span class="fn">Text</span>>;
|
|
1127
|
+
<span class="kw">if</span> (lastSeenAt) <span class="kw">return</span> <<span class="fn">Text</span>>Last seen {<span class="fn">formatRelative</span>(<span class="kw">new</span> <span class="fn">Date</span>(lastSeenAt))}</<span class="fn">Text</span>>;
|
|
1128
|
+
<span class="kw">return</span> <<span class="fn">Text</span>>Offline</<span class="fn">Text</span>>;
|
|
1129
|
+
}</code></pre>
|
|
1130
|
+
</div>
|
|
1131
|
+
|
|
1132
|
+
<hr class="divider" style="margin:28px 0"/>
|
|
1133
|
+
|
|
1134
|
+
<h3>Listening to others' read receipts</h3>
|
|
1135
|
+
<p>You don't need to manually subscribe to <code>read_receipt</code> for your own last-read tracking — the store handles that. But you may want to listen to update <em>message-level</em> read indicators (e.g. double-tick UI):</p>
|
|
1136
|
+
|
|
1137
|
+
<pre><code><span class="kw">import</span> { tryGetSocket } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1138
|
+
|
|
1139
|
+
<span class="kw">const</span> socket = <span class="fn">tryGetSocket</span>();
|
|
1140
|
+
socket?.<span class="fn">on</span>(<span class="str">'read_receipt'</span>, ({ conversationId, messageId, userId, fullyReadMessageIds }) => {
|
|
1141
|
+
<span class="cm">// fullyReadMessageIds — messages everyone in the conversation has now read</span>
|
|
1142
|
+
<span class="cm">// Show double-tick / "seen by all" indicator for these</span>
|
|
1143
|
+
<span class="fn">markFullyRead</span>(fullyReadMessageIds ?? []);
|
|
1144
|
+
});</code></pre>
|
|
1145
|
+
|
|
1146
|
+
<h4>Summary — what is automatic vs. what you do</h4>
|
|
1147
|
+
<table>
|
|
1148
|
+
<thead><tr><th>Action</th><th>Automatic?</th><th>What you do</th></tr></thead>
|
|
1149
|
+
<tbody>
|
|
1150
|
+
<tr><td>Store <code>lastRead</code> updated on <code>read_receipt</code></td><td>✅ Yes</td><td>Nothing — wired inside <code>connectSocket</code></td></tr>
|
|
1151
|
+
<tr><td>Store <code>lastSeen</code> updated on <code>user_offline</code></td><td>✅ Yes</td><td>Nothing — wired inside <code>connectSocket</code></td></tr>
|
|
1152
|
+
<tr><td>Initial <code>lastRead</code> value on screen open</td><td>❌ Manual</td><td>Call <code>messagesApi.getLastRead(conversationId)</code>, push to store</td></tr>
|
|
1153
|
+
<tr><td>Initial <code>lastSeen</code> value on profile/DM open</td><td>❌ Manual</td><td>Call <code>usersApi.getLastSeen(userId)</code>, push to store</td></tr>
|
|
1154
|
+
<tr><td>Tell the server the user read messages</td><td>❌ Manual</td><td>Call <code>socketEmit.markRead(conversationId)</code> on focus</td></tr>
|
|
1155
|
+
</tbody>
|
|
1156
|
+
</table>
|
|
1157
|
+
</section>
|
|
1158
|
+
|
|
1159
|
+
<!-- ─── STEP 13: EDIT & DELETE ────────────────────────────────────────── -->
|
|
1160
|
+
<section id="step-edit">
|
|
1161
|
+
<h2><span class="step">STEP 13</span> Edit & Delete Messages</h2>
|
|
1162
|
+
|
|
1163
|
+
<h3>Edit</h3>
|
|
1164
|
+
<pre><code><span class="kw">await</span> socketEmit.<span class="fn">updateMessage</span>(messageId, <span class="str">'Updated text'</span>);
|
|
1165
|
+
<span class="cm">// REST fallback: await messagesApi.update(messageId, 'Updated text')</span></code></pre>
|
|
1166
|
+
<div class="callout info">
|
|
1167
|
+
<strong>Edit window</strong> — check <code>conversation.settings.messageConfig.editWindowSeconds</code>:
|
|
1168
|
+
<pre style="margin-top:8px"><code><span class="kw">function</span> <span class="fn">canEdit</span>(msg: <span class="tp">Message</span>, conv: <span class="tp">Conversation</span>, userId: <span class="tp">string</span>) {
|
|
1169
|
+
<span class="kw">if</span> (msg.senderId !== userId) <span class="kw">return false</span>;
|
|
1170
|
+
<span class="kw">const</span> w = conv.settings?.messageConfig?.editWindowSeconds;
|
|
1171
|
+
<span class="kw">return</span> !w || (Date.<span class="fn">now</span>() - <span class="kw">new</span> <span class="fn">Date</span>(msg.sentAt).<span class="fn">getTime</span>()) < w * <span class="num">1000</span>;
|
|
1172
|
+
}</code></pre>
|
|
1173
|
+
</div>
|
|
1174
|
+
|
|
1175
|
+
<h3>Delete — when to show which option</h3>
|
|
1176
|
+
<table>
|
|
1177
|
+
<thead><tr><th>Action</th><th>Show when</th><th>What it does</th></tr></thead>
|
|
1178
|
+
<tbody>
|
|
1179
|
+
<tr><td><strong>Delete for everyone</strong></td><td>Your message within the delete window, OR you're a group admin</td><td>Removes for all — others receive <code>message_deleted</code> event</td></tr>
|
|
1180
|
+
<tr><td><strong>Delete for me</strong></td><td>Always — any message, any conversation type</td><td>Hides locally only — you receive <code>message_deleted_for_me</code></td></tr>
|
|
1181
|
+
</tbody>
|
|
1182
|
+
</table>
|
|
1183
|
+
|
|
1184
|
+
<div class="callout info">
|
|
1185
|
+
<strong>Default delete window</strong> — if <code>conversation.settings.messageConfig.deleteWindowSeconds</code> is not set, the server defaults to <strong>1800 seconds (30 minutes)</strong>. Your UI helper must use the same fallback, otherwise you may show "Delete for everyone" after the window has already expired on the server and get a <code>403 Forbidden</code> response.
|
|
1186
|
+
</div>
|
|
1187
|
+
|
|
1188
|
+
<div class="callout info">
|
|
1189
|
+
<strong>DMs have no admins</strong> — in a direct message conversation <code>conv.type === 'direct'</code>, participants have no <code>admin</code> role. <code>isAdmin</code> will always be <code>false</code>, so "Delete for everyone" is only available to the message sender within the window. Do not show an admin-based delete option in DMs.
|
|
1190
|
+
</div>
|
|
1191
|
+
|
|
1192
|
+
<pre><code><span class="kw">const</span> DEFAULT_DELETE_WINDOW_SEC = <span class="num">1800</span>; <span class="cm">// matches server default</span>
|
|
1193
|
+
|
|
1194
|
+
<span class="kw">function</span> <span class="fn">getDeleteOptions</span>(msg: <span class="tp">Message</span>, conv: <span class="tp">Conversation</span>, userId: <span class="tp">string</span>) {
|
|
1195
|
+
<span class="kw">const</span> isMine = msg.senderId === userId;
|
|
1196
|
+
<span class="kw">const</span> isAdmin = conv.type !== <span class="str">'direct'</span> &&
|
|
1197
|
+
conv.participants.<span class="fn">find</span>(p => p.userId === userId)?.role === <span class="str">'admin'</span>;
|
|
1198
|
+
<span class="kw">const</span> w = conv.settings?.messageConfig?.deleteWindowSeconds ?? DEFAULT_DELETE_WINDOW_SEC;
|
|
1199
|
+
<span class="kw">const</span> inWindow = (Date.<span class="fn">now</span>() - <span class="kw">new</span> <span class="fn">Date</span>(msg.sentAt).<span class="fn">getTime</span>()) < w * <span class="num">1000</span>;
|
|
1200
|
+
<span class="kw">return</span> { <span class="at">canDeleteForEveryone</span>: (isMine && inWindow) || isAdmin, <span class="at">canDeleteForMe</span>: <span class="kw">true</span> };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
<span class="kw">await</span> socketEmit.<span class="fn">deleteMessage</span>(messageId); <span class="cm">// for everyone</span>
|
|
1204
|
+
<span class="kw">await</span> socketEmit.<span class="fn">deleteMessageForMe</span>(messageId); <span class="cm">// for me only</span>
|
|
1205
|
+
<span class="cm">// REST: messagesApi.delete(id) / messagesApi.deleteForMe(id)</span></code></pre>
|
|
1206
|
+
</section>
|
|
1207
|
+
|
|
1208
|
+
<!-- ─── STEP 14: SEARCH ───────────────────────────────────────────────── -->
|
|
1209
|
+
<section id="step-search">
|
|
1210
|
+
<h2><span class="step">STEP 14</span> Search Messages</h2>
|
|
1211
|
+
<pre><code><span class="kw">const</span> { data, meta } = <span class="kw">await</span> messagesApi.<span class="fn">search</span>({
|
|
1212
|
+
<span class="at">query</span>: <span class="str">'meeting notes'</span>,
|
|
1213
|
+
<span class="at">conversationId</span>: conversationId, <span class="cm">// omit to search all conversations</span>
|
|
1214
|
+
<span class="at">page</span>: <span class="num">1</span>, <span class="at">limit</span>: <span class="num">20</span>,
|
|
1215
|
+
});
|
|
1216
|
+
<span class="cm">// meta.hasNextPage — paginate with page: 2, 3...</span>
|
|
1217
|
+
|
|
1218
|
+
<span class="cm">// Get a single message by ID</span>
|
|
1219
|
+
<span class="kw">const</span> msg = <span class="kw">await</span> messagesApi.<span class="fn">get</span>(messageId);</code></pre>
|
|
1220
|
+
</section>
|
|
1221
|
+
|
|
1222
|
+
<!-- ─── STEP 15: REACTIONS ────────────────────────────────────────────── -->
|
|
1223
|
+
<section id="step-reactions">
|
|
1224
|
+
<h2><span class="step">STEP 15</span> Reactions, Pin & Star</h2>
|
|
1225
|
+
<pre><code><span class="cm">// Reactions — message.reactions is { emoji, userIds, count }[]</span>
|
|
1226
|
+
<span class="kw">await</span> socketEmit.<span class="fn">addReaction</span>(messageId, <span class="str">'👍'</span>);
|
|
1227
|
+
<span class="kw">await</span> socketEmit.<span class="fn">removeReaction</span>(messageId, <span class="str">'👍'</span>);
|
|
1228
|
+
<span class="cm">// Check if you reacted: message.reactions.find(r => r.emoji === '👍')?.userIds.includes(userId)</span>
|
|
1229
|
+
|
|
1230
|
+
<span class="cm">// Pin / Unpin</span>
|
|
1231
|
+
<span class="kw">await</span> socketEmit.<span class="fn">pinMessage</span>(messageId);
|
|
1232
|
+
<span class="kw">await</span> socketEmit.<span class="fn">unpinMessage</span>(messageId);
|
|
1233
|
+
<span class="kw">const</span> pinned = <span class="kw">await</span> messagesApi.<span class="fn">getPinned</span>(conversationId); <span class="cm">// Message[]</span>
|
|
1234
|
+
|
|
1235
|
+
<span class="cm">// Star / Unstar (personal — not visible to others)</span>
|
|
1236
|
+
<span class="kw">await</span> messagesApi.<span class="fn">star</span>(messageId);
|
|
1237
|
+
<span class="kw">await</span> messagesApi.<span class="fn">unstar</span>(messageId);
|
|
1238
|
+
<span class="kw">const</span> { data: starred } = <span class="kw">await</span> messagesApi.<span class="fn">getStarred</span>({ <span class="at">limit</span>: <span class="num">20</span>, conversationId });</code></pre>
|
|
1239
|
+
</section>
|
|
1240
|
+
|
|
1241
|
+
<hr class="divider"/>
|
|
1242
|
+
|
|
1243
|
+
<!-- ─── STEP 16: UPLOAD ───────────────────────────────────────────────── -->
|
|
1244
|
+
<section id="step-upload">
|
|
1245
|
+
<h2><span class="step">STEP 16</span> Upload Files</h2>
|
|
1246
|
+
<p>The SDK handles the full flow: presigned URL request → binary upload via your <code>platformUploadFn</code> → server confirmation. You get back <code>fileId</code>s to attach to messages.</p>
|
|
1247
|
+
|
|
1248
|
+
<div data-p="rn">
|
|
1249
|
+
<pre><code><span class="kw">import</span> * <span class="kw">as</span> ImagePicker <span class="kw">from</span> <span class="str">'expo-image-picker'</span>;
|
|
1250
|
+
|
|
1251
|
+
<span class="kw">const</span> result = <span class="kw">await</span> ImagePicker.<span class="fn">launchImageLibraryAsync</span>({ <span class="at">allowsMultipleSelection</span>: <span class="kw">true</span> });
|
|
1252
|
+
<span class="kw">if</span> (result.canceled) <span class="kw">return</span>;
|
|
1253
|
+
|
|
1254
|
+
<span class="kw">const</span> files = result.assets.<span class="fn">map</span>(a => ({
|
|
1255
|
+
<span class="at">uri</span>: a.uri, <span class="at">name</span>: a.fileName ?? <span class="str">'file'</span>,
|
|
1256
|
+
<span class="at">type</span>: a.mimeType ?? <span class="str">'application/octet-stream'</span>, <span class="at">size</span>: a.fileSize ?? <span class="num">0</span>,
|
|
1257
|
+
}));
|
|
1258
|
+
|
|
1259
|
+
<span class="kw">const</span> { successful, failed } = <span class="kw">await</span> chatClient.<span class="fn">uploadFiles</span>(files, conversationId);
|
|
1260
|
+
<span class="kw">if</span> (failed.length) console.<span class="fn">warn</span>(<span class="str">'Failed:'</span>, failed);</code></pre>
|
|
1261
|
+
</div>
|
|
1262
|
+
<div data-p="web">
|
|
1263
|
+
<pre><code><span class="cm">// From a file input element</span>
|
|
1264
|
+
<span class="kw">const</span> files = [...inputEl.files].<span class="fn">map</span>(f => ({
|
|
1265
|
+
<span class="at">uri</span>: URL.<span class="fn">createObjectURL</span>(f), <span class="at">name</span>: f.name, <span class="at">type</span>: f.type, <span class="at">size</span>: f.size,
|
|
1266
|
+
}));
|
|
1267
|
+
|
|
1268
|
+
<span class="kw">const</span> { successful, failed } = <span class="kw">await</span> chatClient.<span class="fn">uploadFiles</span>(files, conversationId);
|
|
1269
|
+
<span class="kw">if</span> (failed.length) console.<span class="fn">warn</span>(<span class="str">'Failed:'</span>, failed);</code></pre>
|
|
1270
|
+
</div>
|
|
1271
|
+
<div data-p="node">
|
|
1272
|
+
<pre><code><span class="cm">// From a file path on disk</span>
|
|
1273
|
+
<span class="kw">import</span> { statSync } <span class="kw">from</span> <span class="str">'fs'</span>;
|
|
1274
|
+
<span class="kw">import</span> { lookup } <span class="kw">from</span> <span class="str">'mime-types'</span>;
|
|
1275
|
+
|
|
1276
|
+
<span class="kw">const</span> filePath = <span class="str">'/tmp/report.pdf'</span>;
|
|
1277
|
+
<span class="kw">const</span> files = [{
|
|
1278
|
+
<span class="at">uri</span>: filePath,
|
|
1279
|
+
<span class="at">name</span>: <span class="str">'report.pdf'</span>,
|
|
1280
|
+
<span class="at">type</span>: <span class="fn">lookup</span>(filePath) || <span class="str">'application/octet-stream'</span>,
|
|
1281
|
+
<span class="at">size</span>: <span class="fn">statSync</span>(filePath).size,
|
|
1282
|
+
}];
|
|
1283
|
+
|
|
1284
|
+
<span class="kw">const</span> { successful, failed } = <span class="kw">await</span> chatClient.<span class="fn">uploadFiles</span>(files, conversationId);</code></pre>
|
|
1285
|
+
</div>
|
|
1286
|
+
|
|
1287
|
+
<p>Then send with attachments:</p>
|
|
1288
|
+
<pre><code><span class="kw">const</span> attachments = successful.<span class="fn">map</span>(f => ({
|
|
1289
|
+
<span class="at">fileId</span>: f.id, <span class="at">type</span>: f.type, <span class="at">url</span>: f.url,
|
|
1290
|
+
<span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
|
|
1291
|
+
}));
|
|
1292
|
+
<span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(), attachments });</code></pre>
|
|
1293
|
+
|
|
1294
|
+
<h3>Upload limits — configure in client.ts</h3>
|
|
1295
|
+
<pre><code><span class="kw">new</span> <span class="fn">AntzChatClient</span>({
|
|
1296
|
+
<span class="cm">// ...</span>
|
|
1297
|
+
<span class="at">upload</span>: {
|
|
1298
|
+
<span class="at">onProgress</span>: (pct) => <span class="fn">setProgress</span>(pct), <span class="cm">// 0–100 aggregate</span>
|
|
1299
|
+
<span class="at">onUploadError</span>: (file, err) => console.<span class="fn">error</span>(file.name, err),
|
|
1300
|
+
<span class="at">maxFileSizeMB</span>: <span class="num">50</span>,
|
|
1301
|
+
<span class="at">maxFilesPerMessage</span>: <span class="num">10</span>,
|
|
1302
|
+
<span class="at">allowedTypes</span>: [<span class="str">'image'</span>, <span class="str">'video'</span>, <span class="str">'document'</span>],
|
|
1303
|
+
},
|
|
1304
|
+
});</code></pre>
|
|
1305
|
+
</section>
|
|
1306
|
+
|
|
1307
|
+
<!-- ─── STEP 17: PUSH (RN + WEB only) ────────────────────────────────── -->
|
|
1308
|
+
<section id="step-push" data-p="rn web">
|
|
1309
|
+
<h2><span class="step">STEP 17</span> Push Notifications</h2>
|
|
1310
|
+
|
|
1311
|
+
<p>
|
|
1312
|
+
The server stores one device token document per physical device in <code>chat_device_tokens</code>.
|
|
1313
|
+
Tokens are separate from the user profile — a single user can have multiple active tokens
|
|
1314
|
+
(phone + tablet + browser). The server looks up all active tokens for a user and delivers
|
|
1315
|
+
push notifications to every registered device when a message or event arrives while they are offline.
|
|
1316
|
+
</p>
|
|
1317
|
+
|
|
1318
|
+
<h3>How registration works</h3>
|
|
1319
|
+
<p>
|
|
1320
|
+
Every call to <code>devicesApi.register()</code> is an <strong>upsert</strong> keyed on <code>deviceId</code>.
|
|
1321
|
+
If the same <code>deviceId</code> is registered again (app restart, token rotation), the existing record is
|
|
1322
|
+
updated — no duplicate is created. This means it is safe — and correct — to call <code>register()</code>
|
|
1323
|
+
on every app launch.
|
|
1324
|
+
</p>
|
|
1325
|
+
|
|
1326
|
+
<h3>The <code>deviceId</code> rule — read this first</h3>
|
|
1327
|
+
<p>
|
|
1328
|
+
<code>deviceId</code> must be a <strong>stable UUID that persists across app restarts</strong>.
|
|
1329
|
+
Generate it once on first install and store it in <code>AsyncStorage</code> / <code>SecureStore</code> (mobile)
|
|
1330
|
+
or <code>localStorage</code> (web). Never regenerate it on each launch — if you lose the <code>deviceId</code>
|
|
1331
|
+
the old token record becomes an orphan in the server's DB and the user will receive duplicate notifications
|
|
1332
|
+
until the stale token expires.
|
|
1333
|
+
</p>
|
|
1334
|
+
|
|
1335
|
+
<!-- ── React Native (Expo) ─────────────────────────────────────────────── -->
|
|
1336
|
+
<div data-p="rn">
|
|
1337
|
+
<h3>React Native — Expo push (iOS & Android)</h3>
|
|
1338
|
+
|
|
1339
|
+
<h4>When to call</h4>
|
|
1340
|
+
<p>
|
|
1341
|
+
Call <code>registerPushToken()</code> <strong>on every app launch, right after authentication</strong>.
|
|
1342
|
+
Expo can silently rotate the push token after an OS upgrade, app reinstall, or when the device
|
|
1343
|
+
transfers APNs registration. Calling on every launch ensures the server always has the current token
|
|
1344
|
+
— if the token hasn't changed the upsert is a no-op (only <code>lastUsedAt</code> is bumped).
|
|
1345
|
+
</p>
|
|
1346
|
+
|
|
1347
|
+
<pre><code><span class="kw">import</span> { devicesApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1348
|
+
<span class="kw">import</span> * <span class="kw">as</span> Notifications <span class="kw">from</span> <span class="str">'expo-notifications'</span>;
|
|
1349
|
+
<span class="kw">import</span> * <span class="kw">as</span> Device <span class="kw">from</span> <span class="str">'expo-device'</span>;
|
|
1350
|
+
<span class="kw">import</span> * <span class="kw">as</span> SecureStore <span class="kw">from</span> <span class="str">'expo-secure-store'</span>;
|
|
1351
|
+
<span class="kw">import</span> * <span class="kw">as</span> Crypto <span class="kw">from</span> <span class="str">'expo-crypto'</span>;
|
|
1352
|
+
|
|
1353
|
+
<span class="cm">// Generated once on first install, stored securely, never changes.</span>
|
|
1354
|
+
<span class="kw">async function</span> <span class="fn">getStableDeviceId</span>(): <span class="tp">Promise</span><<span class="tp">string</span>> {
|
|
1355
|
+
<span class="kw">const</span> existing = <span class="kw">await</span> SecureStore.<span class="fn">getItemAsync</span>(<span class="str">'chat-device-id'</span>);
|
|
1356
|
+
<span class="kw">if</span> (existing) <span class="kw">return</span> existing;
|
|
1357
|
+
<span class="kw">const</span> id = Crypto.<span class="fn">randomUUID</span>();
|
|
1358
|
+
<span class="kw">await</span> SecureStore.<span class="fn">setItemAsync</span>(<span class="str">'chat-device-id'</span>, id);
|
|
1359
|
+
<span class="kw">return</span> id;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
<span class="cm">// Call after login on every app launch.</span>
|
|
1363
|
+
<span class="kw">async function</span> <span class="fn">registerPushToken</span>(): <span class="tp">Promise</span><<span class="tp">void</span>> {
|
|
1364
|
+
<span class="kw">if</span> (!Device.isDevice) <span class="kw">return</span>; <span class="cm">// simulators cannot receive push</span>
|
|
1365
|
+
|
|
1366
|
+
<span class="kw">const</span> { status: existing } = <span class="kw">await</span> Notifications.<span class="fn">getPermissionsAsync</span>();
|
|
1367
|
+
<span class="kw">const</span> { status } = existing !== <span class="str">'granted'</span>
|
|
1368
|
+
? <span class="kw">await</span> Notifications.<span class="fn">requestPermissionsAsync</span>()
|
|
1369
|
+
: { status: existing };
|
|
1370
|
+
|
|
1371
|
+
<span class="kw">if</span> (status !== <span class="str">'granted'</span>) <span class="kw">return</span>; <span class="cm">// user denied — do not register</span>
|
|
1372
|
+
|
|
1373
|
+
<span class="kw">const</span> { data: token } = <span class="kw">await</span> Notifications.<span class="fn">getExpoPushTokenAsync</span>();
|
|
1374
|
+
<span class="kw">const</span> deviceId = <span class="kw">await</span> <span class="fn">getStableDeviceId</span>();
|
|
1375
|
+
|
|
1376
|
+
<span class="kw">await</span> devicesApi.<span class="fn">register</span>({
|
|
1377
|
+
deviceId,
|
|
1378
|
+
platform: Device.osName === <span class="str">'iOS'</span> ? <span class="str">'ios'</span> : <span class="str">'android'</span>,
|
|
1379
|
+
provider: <span class="str">'expo'</span>,
|
|
1380
|
+
token,
|
|
1381
|
+
userAgent: <span class="str">`${Device.modelName} / ${Device.osName} ${Device.osVersion}`</span>,
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
<span class="cm">// On logout — deactivates this device so push stops immediately.</span>
|
|
1386
|
+
<span class="kw">async function</span> <span class="fn">unregisterPushToken</span>(): <span class="tp">Promise</span><<span class="tp">void</span>> {
|
|
1387
|
+
<span class="kw">const</span> deviceId = <span class="kw">await</span> SecureStore.<span class="fn">getItemAsync</span>(<span class="str">'chat-device-id'</span>);
|
|
1388
|
+
<span class="kw">if</span> (deviceId) <span class="kw">await</span> devicesApi.<span class="fn">remove</span>(deviceId);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
<span class="cm">// ── Usage ──────────────────────────────────────────────────────────────
|
|
1392
|
+
// In your auth flow — call both after every successful login:</span>
|
|
1393
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
1394
|
+
<span class="kw">await</span> <span class="fn">registerPushToken</span>(); <span class="cm">// safe to call on every launch</span>
|
|
1395
|
+
|
|
1396
|
+
<span class="cm">// On logout:</span>
|
|
1397
|
+
<span class="kw">await</span> <span class="fn">unregisterPushToken</span>();
|
|
1398
|
+
<span class="kw">await</span> chatClient.<span class="fn">disconnect</span>();</code></pre>
|
|
1399
|
+
|
|
1400
|
+
<h4>FCM (Firebase) — Android / direct FCM without Expo</h4>
|
|
1401
|
+
<pre><code><span class="kw">import</span> messaging <span class="kw">from</span> <span class="str">'@react-native-firebase/messaging'</span>;
|
|
1402
|
+
|
|
1403
|
+
<span class="kw">async function</span> <span class="fn">registerFCMToken</span>(): <span class="tp">Promise</span><<span class="tp">void</span>> {
|
|
1404
|
+
<span class="kw">const</span> granted = <span class="kw">await</span> messaging().<span class="fn">requestPermission</span>();
|
|
1405
|
+
<span class="kw">if</span> (!granted) <span class="kw">return</span>;
|
|
1406
|
+
|
|
1407
|
+
<span class="kw">const</span> token = <span class="kw">await</span> messaging().<span class="fn">getToken</span>();
|
|
1408
|
+
<span class="kw">const</span> deviceId = <span class="kw">await</span> <span class="fn">getStableDeviceId</span>();
|
|
1409
|
+
|
|
1410
|
+
<span class="kw">await</span> devicesApi.<span class="fn">register</span>({
|
|
1411
|
+
deviceId,
|
|
1412
|
+
platform: <span class="str">'android'</span>,
|
|
1413
|
+
provider: <span class="str">'fcm'</span>,
|
|
1414
|
+
token,
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
<span class="cm">// FCM tokens can rotate — re-register whenever Firebase issues a new one.</span>
|
|
1418
|
+
messaging().<span class="fn">onTokenRefresh</span>(<span class="kw">async</span> (newToken) => {
|
|
1419
|
+
<span class="kw">await</span> devicesApi.<span class="fn">register</span>({ deviceId, platform: <span class="str">'android'</span>, provider: <span class="str">'fcm'</span>, token: newToken });
|
|
1420
|
+
});
|
|
1421
|
+
}</code></pre>
|
|
1422
|
+
|
|
1423
|
+
<h4>Token lifecycle summary — mobile</h4>
|
|
1424
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px;margin:12px 0">
|
|
1425
|
+
<thead><tr style="border-bottom:1px solid var(--border)">
|
|
1426
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Event</th>
|
|
1427
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Action</th>
|
|
1428
|
+
</tr></thead>
|
|
1429
|
+
<tbody>
|
|
1430
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">App launch after login</td><td style="padding:6px 10px">Call <code>register()</code> — upsert handles token rotation automatically</td></tr>
|
|
1431
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">User logs out</td><td style="padding:6px 10px">Call <code>remove(deviceId)</code> — server sets token <code>isActive: false</code></td></tr>
|
|
1432
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">User revokes permission in OS settings</td><td style="padding:6px 10px">Call <code>remove(deviceId)</code> in your permission-change handler</td></tr>
|
|
1433
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">App reinstall / token rotation</td><td style="padding:6px 10px">Handled automatically — next launch calls <code>register()</code> with the new token</td></tr>
|
|
1434
|
+
<tr><td style="padding:6px 10px">Notification engine gets <code>DeviceNotRegistered</code> from Expo/FCM</td><td style="padding:6px 10px">Engine automatically marks the token inactive — no action needed in app</td></tr>
|
|
1435
|
+
</tbody>
|
|
1436
|
+
</table>
|
|
1437
|
+
</div>
|
|
1438
|
+
|
|
1439
|
+
<!-- ── Web (VAPID) ────────────────────────────────────────────────────── -->
|
|
1440
|
+
<div data-p="web">
|
|
1441
|
+
<h3>Web — VAPID / Web Push</h3>
|
|
1442
|
+
|
|
1443
|
+
<h4>When to call</h4>
|
|
1444
|
+
<p>
|
|
1445
|
+
Web push subscriptions are <strong>persistent</strong> — the browser stores them across page loads.
|
|
1446
|
+
Call <code>register()</code> in two situations:
|
|
1447
|
+
</p>
|
|
1448
|
+
<ul style="margin:0 0 12px 20px;color:#c8d0e0;font-size:14px;line-height:2">
|
|
1449
|
+
<li><strong>On app init (after login)</strong> — if a subscription already exists, re-register it to refresh <code>lastUsedAt</code> and catch any endpoint rotation the browser did silently.</li>
|
|
1450
|
+
<li><strong>When the user explicitly enables notifications</strong> — request permission, create a new subscription, then register.</li>
|
|
1451
|
+
</ul>
|
|
1452
|
+
<p>
|
|
1453
|
+
Unlike mobile, do <strong>not</strong> call <code>pushManager.subscribe()</code> on every page load —
|
|
1454
|
+
that would prompt the user repeatedly. Only call it when no subscription exists yet.
|
|
1455
|
+
</p>
|
|
1456
|
+
|
|
1457
|
+
<pre><code><span class="kw">import</span> { devicesApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1458
|
+
|
|
1459
|
+
<span class="kw">const</span> VAPID_PUBLIC_KEY = <span class="str">'YOUR_VAPID_PUBLIC_KEY'</span>; <span class="cm">// from server env</span>
|
|
1460
|
+
|
|
1461
|
+
<span class="cm">// Generated once, stored in localStorage, never regenerated.</span>
|
|
1462
|
+
<span class="kw">function</span> <span class="fn">getStableDeviceId</span>(): <span class="tp">string</span> {
|
|
1463
|
+
<span class="kw">let</span> id = localStorage.<span class="fn">getItem</span>(<span class="str">'chat-device-id'</span>);
|
|
1464
|
+
<span class="kw">if</span> (!id) { id = crypto.<span class="fn">randomUUID</span>(); localStorage.<span class="fn">setItem</span>(<span class="str">'chat-device-id'</span>, id); }
|
|
1465
|
+
<span class="kw">return</span> id;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
<span class="kw">function</span> <span class="fn">subToPayload</span>(sub: PushSubscription, deviceId: <span class="tp">string</span>) {
|
|
1469
|
+
<span class="kw">const</span> <span class="fn">b64</span> = (buf: ArrayBuffer | null) =>
|
|
1470
|
+
buf ? <span class="fn">btoa</span>(String.<span class="fn">fromCharCode</span>(...<span class="kw">new</span> <span class="fn">Uint8Array</span>(buf))) : <span class="str">''</span>;
|
|
1471
|
+
<span class="kw">return</span> {
|
|
1472
|
+
deviceId,
|
|
1473
|
+
platform: <span class="str">'web'</span> <span class="kw">as const</span>,
|
|
1474
|
+
provider: <span class="str">'web-push'</span> <span class="kw">as const</span>,
|
|
1475
|
+
endpoint: sub.endpoint,
|
|
1476
|
+
p256dh: <span class="fn">b64</span>(sub.<span class="fn">getKey</span>(<span class="str">'p256dh'</span>)),
|
|
1477
|
+
auth: <span class="fn">b64</span>(sub.<span class="fn">getKey</span>(<span class="str">'auth'</span>)),
|
|
1478
|
+
userAgent: navigator.userAgent,
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
<span class="cm">// Call on every app init after login.
|
|
1483
|
+
// Re-registers any existing subscription (upsert — no duplicate created).
|
|
1484
|
+
// Silently skips if push is not supported or not yet permitted.</span>
|
|
1485
|
+
<span class="kw">async function</span> <span class="fn">syncPushSubscription</span>(): <span class="tp">Promise</span><<span class="tp">void</span>> {
|
|
1486
|
+
<span class="kw">if</span> (!(<span class="str">'serviceWorker'</span> <span class="kw">in</span> navigator) || !(<span class="str">'PushManager'</span> <span class="kw">in</span> window)) <span class="kw">return</span>;
|
|
1487
|
+
<span class="kw">if</span> (Notification.permission !== <span class="str">'granted'</span>) <span class="kw">return</span>;
|
|
1488
|
+
|
|
1489
|
+
<span class="kw">const</span> reg = <span class="kw">await</span> navigator.serviceWorker.<span class="fn">ready</span>;
|
|
1490
|
+
<span class="kw">const</span> existing = <span class="kw">await</span> reg.pushManager.<span class="fn">getSubscription</span>();
|
|
1491
|
+
<span class="kw">if</span> (!existing) <span class="kw">return</span>; <span class="cm">// no subscription yet — user hasn't enabled push</span>
|
|
1492
|
+
|
|
1493
|
+
<span class="kw">await</span> devicesApi.<span class="fn">register</span>(<span class="fn">subToPayload</span>(existing, <span class="fn">getStableDeviceId</span>()));
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
<span class="cm">// Call when the user clicks "Enable notifications" in your UI.</span>
|
|
1497
|
+
<span class="kw">async function</span> <span class="fn">enablePushNotifications</span>(): <span class="tp">Promise</span><<span class="tp">void</span>> {
|
|
1498
|
+
<span class="kw">if</span> (!(<span class="str">'serviceWorker'</span> <span class="kw">in</span> navigator)) <span class="kw">return</span>;
|
|
1499
|
+
|
|
1500
|
+
<span class="kw">const</span> permission = <span class="kw">await</span> Notification.<span class="fn">requestPermission</span>();
|
|
1501
|
+
<span class="kw">if</span> (permission !== <span class="str">'granted'</span>) <span class="kw">return</span>;
|
|
1502
|
+
|
|
1503
|
+
<span class="kw">const</span> reg = <span class="kw">await</span> navigator.serviceWorker.<span class="fn">ready</span>;
|
|
1504
|
+
<span class="kw">const</span> sub = <span class="kw">await</span> reg.pushManager.<span class="fn">subscribe</span>({
|
|
1505
|
+
userVisibleOnly: <span class="kw">true</span>,
|
|
1506
|
+
applicationServerKey: VAPID_PUBLIC_KEY,
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
<span class="kw">await</span> devicesApi.<span class="fn">register</span>(<span class="fn">subToPayload</span>(sub, <span class="fn">getStableDeviceId</span>()));
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
<span class="cm">// Call when the user clicks "Disable notifications" or on logout.</span>
|
|
1513
|
+
<span class="kw">async function</span> <span class="fn">disablePushNotifications</span>(): <span class="tp">Promise</span><<span class="tp">void</span>> {
|
|
1514
|
+
<span class="kw">const</span> reg = <span class="kw">await</span> navigator.serviceWorker.<span class="fn">ready</span>;
|
|
1515
|
+
<span class="kw">const</span> sub = <span class="kw">await</span> reg.pushManager.<span class="fn">getSubscription</span>();
|
|
1516
|
+
<span class="kw">if</span> (sub) <span class="kw">await</span> sub.<span class="fn">unsubscribe</span>(); <span class="cm">// tells browser to drop the subscription</span>
|
|
1517
|
+
<span class="kw">await</span> devicesApi.<span class="fn">remove</span>(<span class="fn">getStableDeviceId</span>()); <span class="cm">// tells server to stop pushing</span>
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
<span class="cm">// ── Usage ──────────────────────────────────────────────────────────────
|
|
1521
|
+
// In your app init / auth provider — after every login:</span>
|
|
1522
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
1523
|
+
<span class="kw">await</span> <span class="fn">syncPushSubscription</span>(); <span class="cm">// re-registers existing subscription if any</span>
|
|
1524
|
+
|
|
1525
|
+
<span class="cm">// When user clicks "Enable notifications":</span>
|
|
1526
|
+
<span class="kw">await</span> <span class="fn">enablePushNotifications</span>();
|
|
1527
|
+
|
|
1528
|
+
<span class="cm">// On logout or "Disable notifications":</span>
|
|
1529
|
+
<span class="kw">await</span> <span class="fn">disablePushNotifications</span>();</code></pre>
|
|
1530
|
+
|
|
1531
|
+
<h4>Service worker — handle incoming push</h4>
|
|
1532
|
+
<p>You need a service worker file to receive push events when the tab is closed.</p>
|
|
1533
|
+
<pre><code><span class="cm">// public/sw.js</span>
|
|
1534
|
+
self.<span class="fn">addEventListener</span>(<span class="str">'push'</span>, (event) => {
|
|
1535
|
+
<span class="kw">const</span> data = event.data?.<span class="fn">json</span>() ?? {};
|
|
1536
|
+
event.<span class="fn">waitUntil</span>(
|
|
1537
|
+
self.registration.<span class="fn">showNotification</span>(data.title ?? <span class="str">'New message'</span>, {
|
|
1538
|
+
body: data.body,
|
|
1539
|
+
icon: <span class="str">'/icon-192.png'</span>,
|
|
1540
|
+
badge: <span class="str">'/badge-72.png'</span>,
|
|
1541
|
+
data: data.data, <span class="cm">// contains event, conversation_id, message_id, etc.</span>
|
|
1542
|
+
})
|
|
1543
|
+
);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
self.<span class="fn">addEventListener</span>(<span class="str">'notificationclick'</span>, (event) => {
|
|
1547
|
+
event.<span class="fn">notification</span>.<span class="fn">close</span>();
|
|
1548
|
+
<span class="kw">const</span> { conversation_id } = event.notification.data ?? {};
|
|
1549
|
+
<span class="kw">if</span> (conversation_id) {
|
|
1550
|
+
event.<span class="fn">waitUntil</span>(clients.<span class="fn">openWindow</span>(<span class="str">`/chat/${conversation_id}`</span>));
|
|
1551
|
+
}
|
|
1552
|
+
});</code></pre>
|
|
1553
|
+
|
|
1554
|
+
<h4>Token lifecycle summary — web</h4>
|
|
1555
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px;margin:12px 0">
|
|
1556
|
+
<thead><tr style="border-bottom:1px solid var(--border)">
|
|
1557
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Event</th>
|
|
1558
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Action</th>
|
|
1559
|
+
</tr></thead>
|
|
1560
|
+
<tbody>
|
|
1561
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">App init after login (subscription already exists)</td><td style="padding:6px 10px">Call <code>syncPushSubscription()</code> — upserts existing subscription</td></tr>
|
|
1562
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">User clicks "Enable notifications"</td><td style="padding:6px 10px">Call <code>enablePushNotifications()</code> — requests permission, subscribes, registers</td></tr>
|
|
1563
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">User logs out</td><td style="padding:6px 10px">Call <code>disablePushNotifications()</code> — unsubscribes browser + deactivates server token</td></tr>
|
|
1564
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">User clicks "Disable notifications" in your UI</td><td style="padding:6px 10px">Same — <code>disablePushNotifications()</code></td></tr>
|
|
1565
|
+
<tr><td style="padding:6px 10px">Browser silently rotates the endpoint</td><td style="padding:6px 10px">Handled — next <code>syncPushSubscription()</code> on login updates it via upsert</td></tr>
|
|
1566
|
+
</tbody>
|
|
1567
|
+
</table>
|
|
1568
|
+
</div>
|
|
1569
|
+
|
|
1570
|
+
<!-- ── Hook usage (both platforms) ───────────────────────────────────── -->
|
|
1571
|
+
<h3>Using the <code>useDeviceToken</code> hook</h3>
|
|
1572
|
+
<p>Both SDKs export <code>useDeviceToken</code> for use in React components when you need manual control:</p>
|
|
1573
|
+
<pre><code><span class="kw">import</span> { useDeviceToken } <span class="kw">from</span> <span class="str">'@antzsoft/chat-web-sdk'</span>; <span class="cm">// or chat-rn-sdk</span>
|
|
1574
|
+
|
|
1575
|
+
<span class="kw">function</span> <span class="fn">NotificationSettings</span>() {
|
|
1576
|
+
<span class="kw">const</span> { register, remove } = <span class="fn">useDeviceToken</span>();
|
|
1577
|
+
|
|
1578
|
+
<span class="kw">return</span> (
|
|
1579
|
+
<div>
|
|
1580
|
+
<button onClick={enablePushNotifications}>Enable notifications</button>
|
|
1581
|
+
<button onClick={disablePushNotifications}>Disable notifications</button>
|
|
1582
|
+
</div>
|
|
1583
|
+
);
|
|
1584
|
+
}</code></pre>
|
|
1585
|
+
|
|
1586
|
+
<h3>Multiple devices per user</h3>
|
|
1587
|
+
<p>
|
|
1588
|
+
Each registered <code>deviceId</code> is a separate record in the server. A user logged in on a phone,
|
|
1589
|
+
tablet, and browser will have three active token records — the server sends push to all of them
|
|
1590
|
+
simultaneously when a notification is triggered. Removing a token only deactivates that specific
|
|
1591
|
+
device; other devices continue to receive notifications.
|
|
1592
|
+
</p>
|
|
1593
|
+
</section>
|
|
1594
|
+
|
|
1595
|
+
<!-- ─── STEP 17.5: NOTIFICATION PREFERENCES ──────────────────────────── -->
|
|
1596
|
+
<section id="step-prefs" data-p="rn web node">
|
|
1597
|
+
<h2><span class="step">STEP 17.5</span> Notification Preferences</h2>
|
|
1598
|
+
|
|
1599
|
+
<p>
|
|
1600
|
+
User notification preferences are stored server-side in <code>chat_user_prefs</code>.
|
|
1601
|
+
A record with all defaults is created automatically when a push token is first registered —
|
|
1602
|
+
so this API is always available after push setup.
|
|
1603
|
+
</p>
|
|
1604
|
+
<p>
|
|
1605
|
+
All fields are optional on update — only send what changed.
|
|
1606
|
+
Future preference fields go into the same collection and the same API; no new endpoints needed.
|
|
1607
|
+
</p>
|
|
1608
|
+
|
|
1609
|
+
<h3>API</h3>
|
|
1610
|
+
<pre><code><span class="kw">import</span> { usersApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1611
|
+
<span class="kw">import type</span> { <span class="tp">UserPreferences</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1612
|
+
|
|
1613
|
+
<span class="cm">// Read current preferences (null if no record yet — all defaults apply)</span>
|
|
1614
|
+
<span class="kw">const</span> prefs = <span class="kw">await</span> usersApi.<span class="fn">getPreferences</span>();
|
|
1615
|
+
|
|
1616
|
+
<span class="cm">// Partial updates — only send what changed</span>
|
|
1617
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({ <span class="at">notifyOnReaction</span>: <span class="kw">false</span> });
|
|
1618
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({ <span class="at">messagePreview</span>: <span class="kw">false</span> }); <span class="cm">// privacy mode</span>
|
|
1619
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({ <span class="at">notificationsEnabled</span>: <span class="kw">false</span> }); <span class="cm">// master off</span>
|
|
1620
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({
|
|
1621
|
+
<span class="at">quietHours</span>: { <span class="at">enabled</span>: <span class="kw">true</span>, <span class="at">start</span>: <span class="str">'23:00'</span>, <span class="at">end</span>: <span class="str">'07:00'</span>, <span class="at">timezone</span>: <span class="str">'Asia/Kolkata'</span> },
|
|
1622
|
+
});</code></pre>
|
|
1623
|
+
|
|
1624
|
+
<h3>Preferences reference</h3>
|
|
1625
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px;margin:12px 0">
|
|
1626
|
+
<thead><tr style="border-bottom:1px solid var(--border)">
|
|
1627
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Field</th>
|
|
1628
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Default</th>
|
|
1629
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Description</th>
|
|
1630
|
+
</tr></thead>
|
|
1631
|
+
<tbody>
|
|
1632
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>notificationsEnabled</code></td><td style="padding:6px 10px"><code>true</code></td><td style="padding:6px 10px">Master switch — <code>false</code> disables all push</td></tr>
|
|
1633
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>soundEnabled</code></td><td style="padding:6px 10px"><code>true</code></td><td style="padding:6px 10px">Play sound with notifications</td></tr>
|
|
1634
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>messagePreview</code></td><td style="padding:6px 10px"><code>true</code></td><td style="padding:6px 10px">Show message text in body. <code>false</code> = "New message" only (privacy mode)</td></tr>
|
|
1635
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>notifyOnMention</code></td><td style="padding:6px 10px"><code>true</code></td><td style="padding:6px 10px">Notify when @mentioned in a group</td></tr>
|
|
1636
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>notifyOnReaction</code></td><td style="padding:6px 10px"><code>true</code></td><td style="padding:6px 10px">Notify when someone reacts to your message</td></tr>
|
|
1637
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>notifyOnGroupInvite</code></td><td style="padding:6px 10px"><code>true</code></td><td style="padding:6px 10px">Notify when added to a group</td></tr>
|
|
1638
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>quietHours.enabled</code></td><td style="padding:6px 10px"><code>false</code></td><td style="padding:6px 10px">Enable quiet hours window</td></tr>
|
|
1639
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>quietHours.start</code></td><td style="padding:6px 10px"><code>"22:00"</code></td><td style="padding:6px 10px">Start of quiet window (HH:MM)</td></tr>
|
|
1640
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px"><code>quietHours.end</code></td><td style="padding:6px 10px"><code>"08:00"</code></td><td style="padding:6px 10px">End of quiet window (HH:MM)</td></tr>
|
|
1641
|
+
<tr><td style="padding:6px 10px"><code>quietHours.timezone</code></td><td style="padding:6px 10px"><code>"UTC"</code></td><td style="padding:6px 10px">IANA timezone — e.g. <code>"Asia/Kolkata"</code></td></tr>
|
|
1642
|
+
</tbody>
|
|
1643
|
+
</table>
|
|
1644
|
+
|
|
1645
|
+
<h3>Settings UI</h3>
|
|
1646
|
+
<div data-p="rn">
|
|
1647
|
+
<pre><code><span class="kw">import</span> { useState, useEffect } <span class="kw">from</span> <span class="str">'react'</span>;
|
|
1648
|
+
<span class="kw">import</span> { Switch, Text, View } <span class="kw">from</span> <span class="str">'react-native'</span>;
|
|
1649
|
+
<span class="kw">import</span> { usersApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1650
|
+
|
|
1651
|
+
<span class="kw">export function</span> <span class="fn">NotificationSettingsScreen</span>() {
|
|
1652
|
+
<span class="kw">const</span> [prefs, setPrefs] = <span class="fn">useState</span>(<span class="kw">null</span>);
|
|
1653
|
+
|
|
1654
|
+
<span class="fn">useEffect</span>(() => { usersApi.<span class="fn">getPreferences</span>().<span class="fn">then</span>(setPrefs); }, []);
|
|
1655
|
+
|
|
1656
|
+
<span class="kw">async function</span> <span class="fn">toggle</span>(field, value) {
|
|
1657
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({ [field]: value });
|
|
1658
|
+
<span class="fn">setPrefs</span>(prev => ({ ...prev, [field]: value }));
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
<span class="kw">if</span> (!prefs) <span class="kw">return null</span>;
|
|
1662
|
+
|
|
1663
|
+
<span class="kw">return</span> (
|
|
1664
|
+
<View>
|
|
1665
|
+
{[
|
|
1666
|
+
[<span class="str">'notificationsEnabled'</span>, <span class="str">'Enable notifications'</span>],
|
|
1667
|
+
[<span class="str">'messagePreview'</span>, <span class="str">'Show message preview'</span>],
|
|
1668
|
+
[<span class="str">'notifyOnMention'</span>, <span class="str">'Mentions'</span>],
|
|
1669
|
+
[<span class="str">'notifyOnReaction'</span>, <span class="str">'Reactions'</span>],
|
|
1670
|
+
[<span class="str">'notifyOnGroupInvite'</span>, <span class="str">'Group invites'</span>],
|
|
1671
|
+
].<span class="fn">map</span>(([field, label]) => (
|
|
1672
|
+
<View key={field} style={{ flexDirection: <span class="str">'row'</span>, justifyContent: <span class="str">'space-between'</span>, padding: <span class="num">12</span> }}>
|
|
1673
|
+
<Text>{label}</Text>
|
|
1674
|
+
<Switch value={prefs[field] ?? <span class="kw">true</span>} onValueChange={v => <span class="fn">toggle</span>(field, v)} />
|
|
1675
|
+
</View>
|
|
1676
|
+
))}
|
|
1677
|
+
</View>
|
|
1678
|
+
);
|
|
1679
|
+
}</code></pre>
|
|
1680
|
+
</div>
|
|
1681
|
+
|
|
1682
|
+
<div data-p="web">
|
|
1683
|
+
<pre><code><span class="kw">import</span> { useState, useEffect } <span class="kw">from</span> <span class="str">'react'</span>;
|
|
1684
|
+
<span class="kw">import</span> { usersApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1685
|
+
|
|
1686
|
+
<span class="kw">function</span> <span class="fn">NotificationSettings</span>() {
|
|
1687
|
+
<span class="kw">const</span> [prefs, setPrefs] = <span class="fn">useState</span>(<span class="kw">null</span>);
|
|
1688
|
+
|
|
1689
|
+
<span class="fn">useEffect</span>(() => { usersApi.<span class="fn">getPreferences</span>().<span class="fn">then</span>(setPrefs); }, []);
|
|
1690
|
+
|
|
1691
|
+
<span class="kw">async function</span> <span class="fn">toggle</span>(field, value) {
|
|
1692
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({ [field]: value });
|
|
1693
|
+
<span class="fn">setPrefs</span>(prev => ({ ...prev, [field]: value }));
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
<span class="kw">if</span> (!prefs) <span class="kw">return null</span>;
|
|
1697
|
+
|
|
1698
|
+
<span class="kw">return</span> (
|
|
1699
|
+
<div>
|
|
1700
|
+
{[
|
|
1701
|
+
[<span class="str">'notificationsEnabled'</span>, <span class="str">'Enable notifications'</span>],
|
|
1702
|
+
[<span class="str">'messagePreview'</span>, <span class="str">'Show message preview'</span>],
|
|
1703
|
+
[<span class="str">'notifyOnMention'</span>, <span class="str">'Mentions'</span>],
|
|
1704
|
+
[<span class="str">'notifyOnReaction'</span>, <span class="str">'Reactions'</span>],
|
|
1705
|
+
[<span class="str">'notifyOnGroupInvite'</span>, <span class="str">'Group invites'</span>],
|
|
1706
|
+
].<span class="fn">map</span>(([field, label]) => (
|
|
1707
|
+
<label key={field} style={{ display: <span class="str">'flex'</span>, gap: <span class="num">8</span>, alignItems: <span class="str">'center'</span> }}>
|
|
1708
|
+
<input type=<span class="str">"checkbox"</span> checked={prefs[field] ?? <span class="kw">true</span>}
|
|
1709
|
+
onChange={e => <span class="fn">toggle</span>(field, e.target.checked)} />
|
|
1710
|
+
{label}
|
|
1711
|
+
</label>
|
|
1712
|
+
))}
|
|
1713
|
+
</div>
|
|
1714
|
+
);
|
|
1715
|
+
}</code></pre>
|
|
1716
|
+
</div>
|
|
1717
|
+
|
|
1718
|
+
<div data-p="node">
|
|
1719
|
+
<pre><code><span class="kw">import</span> { usersApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1720
|
+
|
|
1721
|
+
<span class="cm">// Read preferences</span>
|
|
1722
|
+
<span class="kw">const</span> prefs = <span class="kw">await</span> usersApi.<span class="fn">getPreferences</span>();
|
|
1723
|
+
|
|
1724
|
+
<span class="cm">// Update — e.g. disable all notifications for a bot user</span>
|
|
1725
|
+
<span class="kw">await</span> usersApi.<span class="fn">updatePreferences</span>({ <span class="at">notificationsEnabled</span>: <span class="kw">false</span> });</code></pre>
|
|
1726
|
+
</div>
|
|
1727
|
+
|
|
1728
|
+
<h3>Adding future preferences</h3>
|
|
1729
|
+
<p>
|
|
1730
|
+
Add the new field to the server schema (<code>user-prefs.schema.ts</code>) and the
|
|
1731
|
+
<code>UserPreferences</code> type in <code>@antzsoft/chat-core</code>.
|
|
1732
|
+
The same <code>updatePreferences()</code> / <code>getPreferences()</code> calls handle it —
|
|
1733
|
+
no new API endpoints, no new collections.
|
|
1734
|
+
</p>
|
|
1735
|
+
</section>
|
|
1736
|
+
|
|
1737
|
+
<!-- ─── STEP 18: FULL EXAMPLE ─────────────────────────────────────────── -->
|
|
1738
|
+
<section id="step-example">
|
|
1739
|
+
<h2><span class="step">STEP 18</span> Full Example</h2>
|
|
1740
|
+
|
|
1741
|
+
<div data-p="rn web">
|
|
1742
|
+
<pre><code><span class="cm">// src/screens/ConversationScreen.tsx</span>
|
|
1743
|
+
<span class="kw">import</span> React, { useEffect, useState, useCallback, useRef } <span class="kw">from</span> <span class="str">'react'</span>;
|
|
1744
|
+
<span class="kw">import</span> { FlatList, TextInput, Pressable, Text, View } <span class="kw">from</span> <span class="str">'react-native'</span>;
|
|
1745
|
+
<span class="kw">import</span> { useFocusEffect } <span class="kw">from</span> <span class="str">'@react-navigation/native'</span>;
|
|
1746
|
+
<span class="kw">import</span> { v4 <span class="kw">as</span> uuid } <span class="kw">from</span> <span class="str">'uuid'</span>;
|
|
1747
|
+
<span class="kw">import</span> {
|
|
1748
|
+
messagesApi, socketEmit, tryGetSocket, onSocketStatus,
|
|
1749
|
+
<span class="tp">Message</span>, <span class="tp">NewMessageEvent</span>, <span class="tp">MessageUpdatedEvent</span>, <span class="tp">MessageDeletedEvent</span>,
|
|
1750
|
+
} <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1751
|
+
|
|
1752
|
+
<span class="kw">export function</span> <span class="fn">ConversationScreen</span>({ route }: <span class="kw">any</span>) {
|
|
1753
|
+
<span class="kw">const</span> { conversationId } = route.params;
|
|
1754
|
+
<span class="kw">const</span> [messages, setMessages] = <span class="fn">useState</span><<span class="tp">Message</span>[]>([]);
|
|
1755
|
+
<span class="kw">const</span> [text, setText] = <span class="fn">useState</span>(<span class="str">''</span>);
|
|
1756
|
+
<span class="kw">const</span> [cursor, setCursor] = <span class="fn">useState</span><<span class="tp">string</span>|<span class="tp">undefined</span>>();
|
|
1757
|
+
<span class="kw">const</span> [hasMore, setHasMore] = <span class="fn">useState</span>(<span class="kw">true</span>);
|
|
1758
|
+
<span class="kw">const</span> timer = <span class="fn">useRef</span><<span class="tp">ReturnType</span><<span class="kw">typeof</span> setTimeout>>();
|
|
1759
|
+
|
|
1760
|
+
<span class="cm">// 1. Join room + re-join on reconnect</span>
|
|
1761
|
+
<span class="fn">useEffect</span>(() => {
|
|
1762
|
+
socketEmit.<span class="fn">joinRoom</span>(conversationId);
|
|
1763
|
+
<span class="kw">const</span> unsub = <span class="fn">onSocketStatus</span>(s => { <span class="kw">if</span> (s === <span class="str">'connected'</span>) socketEmit.<span class="fn">joinRoom</span>(conversationId); });
|
|
1764
|
+
<span class="kw">return</span> () => { socketEmit.<span class="fn">leaveRoom</span>(conversationId); unsub(); };
|
|
1765
|
+
}, [conversationId]);
|
|
1766
|
+
|
|
1767
|
+
<span class="cm">// 2. Load messages</span>
|
|
1768
|
+
<span class="fn">useEffect</span>(() => {
|
|
1769
|
+
<span class="kw">async function</span> <span class="fn">load</span>() {
|
|
1770
|
+
<span class="kw">const</span> { data, meta } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, { <span class="at">limit</span>: <span class="num">30</span> });
|
|
1771
|
+
<span class="fn">setMessages</span>(data); <span class="fn">setCursor</span>(meta.nextCursor); <span class="fn">setHasMore</span>(meta.hasMore);
|
|
1772
|
+
}
|
|
1773
|
+
<span class="fn">load</span>();
|
|
1774
|
+
}, [conversationId]);
|
|
1775
|
+
|
|
1776
|
+
<span class="cm">// 3. Real-time listeners</span>
|
|
1777
|
+
<span class="fn">useEffect</span>(() => {
|
|
1778
|
+
<span class="kw">const</span> socket = <span class="fn">tryGetSocket</span>();
|
|
1779
|
+
<span class="kw">if</span> (!socket) <span class="kw">return</span>;
|
|
1780
|
+
<span class="kw">const</span> <span class="fn">onNew</span> = ({ message, tempId }: <span class="tp">NewMessageEvent</span>) =>
|
|
1781
|
+
<span class="fn">setMessages</span>(prev => { <span class="kw">const</span> i = prev.<span class="fn">findIndex</span>(m => m.id === tempId); <span class="kw">if</span> (i !== -<span class="num">1</span>) { <span class="kw">const</span> n=[...prev]; n[i]=message; <span class="kw">return</span> n; } <span class="kw">return</span> [...prev,message]; });
|
|
1782
|
+
<span class="kw">const</span> <span class="fn">onUpd</span> = ({ messageId, text: t, editedAt }: <span class="tp">MessageUpdatedEvent</span>) =>
|
|
1783
|
+
<span class="fn">setMessages</span>(prev => prev.<span class="fn">map</span>(m => m.id===messageId ? {...m,content:{...m.content,text:t},isEdited:<span class="kw">true</span>,editedAt} : m));
|
|
1784
|
+
<span class="kw">const</span> <span class="fn">onDel</span> = ({ messageId }: <span class="tp">MessageDeletedEvent</span>) =>
|
|
1785
|
+
<span class="fn">setMessages</span>(prev => prev.<span class="fn">filter</span>(m => m.id !== messageId));
|
|
1786
|
+
socket.<span class="fn">on</span>(<span class="str">'new_message'</span>, onNew); socket.<span class="fn">on</span>(<span class="str">'message_updated'</span>, onUpd); socket.<span class="fn">on</span>(<span class="str">'message_deleted'</span>, onDel);
|
|
1787
|
+
<span class="kw">return</span> () => { socket.<span class="fn">off</span>(<span class="str">'new_message'</span>, onNew); socket.<span class="fn">off</span>(<span class="str">'message_updated'</span>, onUpd); socket.<span class="fn">off</span>(<span class="str">'message_deleted'</span>, onDel); };
|
|
1788
|
+
}, [conversationId]);
|
|
1789
|
+
|
|
1790
|
+
<span class="cm">// 4. Mark read on focus</span>
|
|
1791
|
+
<span class="fn">useFocusEffect</span>(<span class="fn">useCallback</span>(() => { socketEmit.<span class="fn">markRead</span>(conversationId); }, [conversationId]));
|
|
1792
|
+
|
|
1793
|
+
<span class="kw">const</span> <span class="fn">send</span> = <span class="kw">async</span> () => {
|
|
1794
|
+
<span class="kw">if</span> (!text.trim()) <span class="kw">return</span>;
|
|
1795
|
+
<span class="fn">setText</span>(<span class="str">''</span>); socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">false</span>);
|
|
1796
|
+
<span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">text</span>: text.trim(), <span class="at">tempId</span>: <span class="fn">uuid</span>() });
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
<span class="kw">const</span> <span class="fn">onChangeText</span> = (val: <span class="tp">string</span>) => {
|
|
1800
|
+
<span class="fn">setText</span>(val); socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">true</span>);
|
|
1801
|
+
<span class="fn">clearTimeout</span>(timer.current);
|
|
1802
|
+
timer.current = <span class="fn">setTimeout</span>(() => socketEmit.<span class="fn">typing</span>(conversationId, <span class="kw">false</span>), <span class="num">3000</span>);
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
<span class="kw">const</span> <span class="fn">loadMore</span> = <span class="kw">async</span> () => {
|
|
1806
|
+
<span class="kw">if</span> (!hasMore) <span class="kw">return</span>;
|
|
1807
|
+
<span class="kw">const</span> { data, meta } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, { cursor, <span class="at">direction</span>:<span class="str">'before'</span>, <span class="at">limit</span>:<span class="num">30</span> });
|
|
1808
|
+
<span class="fn">setMessages</span>(p => [...data, ...p]); <span class="fn">setCursor</span>(meta.nextCursor); <span class="fn">setHasMore</span>(meta.hasMore);
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
<span class="kw">return</span> (
|
|
1812
|
+
<View style={{ flex: <span class="num">1</span> }}>
|
|
1813
|
+
<FlatList data={messages} keyExtractor={m => m.id}
|
|
1814
|
+
onStartReached={loadMore}
|
|
1815
|
+
renderItem={({ item }) => <Text>{item.content.text}</Text>} />
|
|
1816
|
+
<TextInput value={text} onChangeText={onChangeText}/>
|
|
1817
|
+
<Pressable onPress={send}><Text>Send</Text></Pressable>
|
|
1818
|
+
</View>
|
|
1819
|
+
);
|
|
1820
|
+
}</code></pre>
|
|
1821
|
+
</div>
|
|
1822
|
+
|
|
1823
|
+
<div data-p="node">
|
|
1824
|
+
<pre><code><span class="kw">import</span> { chatClient, setToken } <span class="kw">from</span> <span class="str">'./client'</span>;
|
|
1825
|
+
<span class="kw">import</span> { messagesApi, conversationsApi, socketEmit, getSocket } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1826
|
+
<span class="kw">import</span> { v4 <span class="kw">as</span> uuid } <span class="kw">from</span> <span class="str">'uuid'</span>;
|
|
1827
|
+
|
|
1828
|
+
<span class="kw">async function</span> <span class="fn">main</span>() {
|
|
1829
|
+
<span class="fn">setToken</span>(process.env.CHAT_TOKEN!);
|
|
1830
|
+
<span class="kw">await</span> chatClient.<span class="fn">connect</span>();
|
|
1831
|
+
|
|
1832
|
+
<span class="kw">const</span> socket = <span class="fn">getSocket</span>();
|
|
1833
|
+
|
|
1834
|
+
<span class="cm">// Join a room and listen</span>
|
|
1835
|
+
socketEmit.<span class="fn">joinRoom</span>(<span class="str">'conv-abc'</span>);
|
|
1836
|
+
socket.<span class="fn">on</span>(<span class="str">'new_message'</span>, ({ message }) => console.<span class="fn">log</span>(<span class="str">'new:'</span>, message.content.text));
|
|
1837
|
+
|
|
1838
|
+
<span class="cm">// Send a message</span>
|
|
1839
|
+
<span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ <span class="at">conversationId</span>: <span class="str">'conv-abc'</span>, <span class="at">text</span>: <span class="str">'Hello from Node!'</span>, <span class="at">tempId</span>: <span class="fn">uuid</span>() });
|
|
1840
|
+
|
|
1841
|
+
<span class="cm">// Load recent messages</span>
|
|
1842
|
+
<span class="kw">const</span> { data } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(<span class="str">'conv-abc'</span>, { <span class="at">limit</span>: <span class="num">20</span> });
|
|
1843
|
+
console.<span class="fn">log</span>(<span class="str">'recent:'</span>, data.<span class="fn">map</span>(m => m.content.text));
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
<span class="fn">main</span>();</code></pre>
|
|
1847
|
+
</div>
|
|
1848
|
+
</section>
|
|
1849
|
+
|
|
1850
|
+
<!-- ─── STEP 19: UNREAD COUNTS ────────────────────────────────────────── -->
|
|
1851
|
+
<section id="step-unread" data-p="rn web node">
|
|
1852
|
+
<h2><span class="step">STEP 19</span> Unread Message Counts</h2>
|
|
1853
|
+
|
|
1854
|
+
<p>
|
|
1855
|
+
Unread counts come from two sources that work together:
|
|
1856
|
+
</p>
|
|
1857
|
+
<ul style="margin:0 0 16px 20px;color:#c8d0e0;font-size:14px;line-height:2">
|
|
1858
|
+
<li><strong>REST API</strong> — accurate count from the database. Call this on app start, foreground resume, and socket reconnect.</li>
|
|
1859
|
+
<li><strong>Socket events</strong> — real-time increments and resets while connected. The SDK handles these automatically; you only need to listen manually for side effects like badge counts.</li>
|
|
1860
|
+
</ul>
|
|
1861
|
+
|
|
1862
|
+
<h3>REST APIs</h3>
|
|
1863
|
+
<pre><code><span class="kw">import</span> { conversationsApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1864
|
+
<span class="kw">import type</span> { <span class="tp">UnreadSummary</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1865
|
+
|
|
1866
|
+
<span class="cm">// Total unread + per-conversation breakdown
|
|
1867
|
+
// Use on cold start, foreground resume, and after socket reconnect</span>
|
|
1868
|
+
<span class="kw">const</span> summary: <span class="tp">UnreadSummary</span> = <span class="kw">await</span> conversationsApi.<span class="fn">getUnreadSummary</span>();
|
|
1869
|
+
<span class="cm">// summary.totalUnread → 5
|
|
1870
|
+
// summary.byConversation → [{ conversationId: '...', unreadCount: 3 }, ...]</span>
|
|
1871
|
+
|
|
1872
|
+
<span class="cm">// Unread count for one specific conversation
|
|
1873
|
+
// Use after a push notification opens a specific chat</span>
|
|
1874
|
+
<span class="kw">const</span> { unreadCount } = <span class="kw">await</span> conversationsApi.<span class="fn">getUnreadCount</span>(conversationId);</code></pre>
|
|
1875
|
+
|
|
1876
|
+
<h3>When to use REST vs socket</h3>
|
|
1877
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px;margin:12px 0">
|
|
1878
|
+
<thead><tr style="border-bottom:1px solid var(--border)">
|
|
1879
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">Situation</th>
|
|
1880
|
+
<th style="text-align:left;padding:6px 10px;color:var(--muted)">What to do</th>
|
|
1881
|
+
</tr></thead>
|
|
1882
|
+
<tbody>
|
|
1883
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">App cold start</td><td style="padding:6px 10px">Call <code>getUnreadSummary()</code> — hydrate before socket connects</td></tr>
|
|
1884
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">App comes to foreground</td><td style="padding:6px 10px">Call <code>getUnreadSummary()</code> — catch up on messages received while backgrounded</td></tr>
|
|
1885
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">Socket reconnects after drop</td><td style="padding:6px 10px">Call <code>getUnreadSummary()</code> — reconcile any drift during outage</td></tr>
|
|
1886
|
+
<tr style="border-bottom:1px solid var(--border)"><td style="padding:6px 10px">Push notification opens a specific chat</td><td style="padding:6px 10px">Call <code>getUnreadCount(conversationId)</code> — refresh just that one</td></tr>
|
|
1887
|
+
<tr><td style="padding:6px 10px">User is actively chatting (socket connected)</td><td style="padding:6px 10px">Use <code>conversation.unreadCount</code> from the list — socket keeps it live</td></tr>
|
|
1888
|
+
</tbody>
|
|
1889
|
+
</table>
|
|
1890
|
+
|
|
1891
|
+
<h3>Socket events (handled automatically by the SDK)</h3>
|
|
1892
|
+
<p>
|
|
1893
|
+
Both events go to the user's <strong>private room</strong> — all devices of the same user receive them simultaneously.
|
|
1894
|
+
Reading on your phone automatically clears the badge on your browser tab.
|
|
1895
|
+
</p>
|
|
1896
|
+
<pre><code><span class="kw">import</span> { tryGetSocket } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
|
|
1897
|
+
|
|
1898
|
+
<span class="kw">const</span> socket = <span class="fn">tryGetSocket</span>();
|
|
1899
|
+
|
|
1900
|
+
<span class="cm">// Fires when a new message arrives in any of the user's conversations.
|
|
1901
|
+
// unreadCount is calculated server-side — always accurate, never optimistic.</span>
|
|
1902
|
+
socket?.<span class="fn">on</span>(<span class="str">'conversation_updated'</span>, ({ conversationId, unreadCount }) => {
|
|
1903
|
+
<span class="cm">// SDK handles this automatically — useConversations() updates in real-time.</span>
|
|
1904
|
+
<span class="cm">// Only listen here if you need a side effect (e.g. app badge).</span>
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
<span class="cm">// Fires when the user marks any conversation as read — on ALL their devices.
|
|
1908
|
+
// This is how reading on mobile clears the browser badge automatically.</span>
|
|
1909
|
+
socket?.<span class="fn">on</span>(<span class="str">'unread_count_changed'</span>, ({ conversationId, unreadCount }) => {
|
|
1910
|
+
<span class="cm">// unreadCount is 0 — the conversation was just read.</span>
|
|
1911
|
+
});</code></pre>
|
|
1912
|
+
|
|
1913
|
+
<h3>Platform-specific badge updates</h3>
|
|
1914
|
+
|
|
1915
|
+
<div data-p="rn">
|
|
1916
|
+
<pre><code><span class="kw">import</span> * <span class="kw">as</span> Notifications <span class="kw">from</span> <span class="str">'expo-notifications'</span>;
|
|
1917
|
+
<span class="kw">import</span> { AppState } <span class="kw">from</span> <span class="str">'react-native'</span>;
|
|
1918
|
+
|
|
1919
|
+
<span class="cm">// Keep badge in sync while app is active</span>
|
|
1920
|
+
<span class="fn">useEffect</span>(() => {
|
|
1921
|
+
<span class="kw">const</span> total = conversations.<span class="fn">reduce</span>((s, c) => s + (c.unreadCount ?? <span class="num">0</span>), <span class="num">0</span>);
|
|
1922
|
+
Notifications.<span class="fn">setBadgeCountAsync</span>(total);
|
|
1923
|
+
}, [conversations]);
|
|
1924
|
+
|
|
1925
|
+
<span class="cm">// Refresh when app comes to foreground (socket may have been down)</span>
|
|
1926
|
+
<span class="fn">useEffect</span>(() => {
|
|
1927
|
+
<span class="kw">const</span> sub = AppState.<span class="fn">addEventListener</span>(<span class="str">'change'</span>, <span class="kw">async</span> (state) => {
|
|
1928
|
+
<span class="kw">if</span> (state === <span class="str">'active'</span>) {
|
|
1929
|
+
<span class="kw">const</span> { totalUnread } = <span class="kw">await</span> conversationsApi.<span class="fn">getUnreadSummary</span>();
|
|
1930
|
+
Notifications.<span class="fn">setBadgeCountAsync</span>(totalUnread);
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
<span class="kw">return</span> () => sub.<span class="fn">remove</span>();
|
|
1934
|
+
}, []);</code></pre>
|
|
1935
|
+
</div>
|
|
1936
|
+
|
|
1937
|
+
<div data-p="web">
|
|
1938
|
+
<pre><code><span class="cm">// Browser tab title badge</span>
|
|
1939
|
+
<span class="fn">useEffect</span>(() => {
|
|
1940
|
+
<span class="kw">const</span> total = conversations.<span class="fn">reduce</span>((s, c) => s + (c.unreadCount ?? <span class="num">0</span>), <span class="num">0</span>);
|
|
1941
|
+
document.title = total > <span class="num">0</span> ? <span class="str">`(${total}) Antz Chat`</span> : <span class="str">'Antz Chat'</span>;
|
|
1942
|
+
}, [conversations]);
|
|
1943
|
+
|
|
1944
|
+
<span class="cm">// Refresh when tab becomes visible (socket may have been down)</span>
|
|
1945
|
+
<span class="fn">useEffect</span>(() => {
|
|
1946
|
+
<span class="kw">function</span> <span class="fn">onVisible</span>() {
|
|
1947
|
+
<span class="kw">if</span> (document.visibilityState === <span class="str">'visible'</span>) {
|
|
1948
|
+
conversationsApi.<span class="fn">getUnreadSummary</span>().<span class="fn">then</span>(({ totalUnread }) => {
|
|
1949
|
+
document.title = totalUnread > <span class="num">0</span> ? <span class="str">`(${totalUnread}) Antz Chat`</span> : <span class="str">'Antz Chat'</span>;
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
document.<span class="fn">addEventListener</span>(<span class="str">'visibilitychange'</span>, onVisible);
|
|
1954
|
+
<span class="kw">return</span> () => document.<span class="fn">removeEventListener</span>(<span class="str">'visibilitychange'</span>, onVisible);
|
|
1955
|
+
}, []);</code></pre>
|
|
1956
|
+
</div>
|
|
1957
|
+
|
|
1958
|
+
<div data-p="node">
|
|
1959
|
+
<pre><code><span class="cm">// Bot / server-side — check unread counts on startup</span>
|
|
1960
|
+
<span class="kw">const</span> summary = <span class="kw">await</span> conversationsApi.<span class="fn">getUnreadSummary</span>();
|
|
1961
|
+
console.<span class="fn">log</span>(<span class="str">`${summary.totalUnread} unread messages across ${summary.byConversation.length} conversations`</span>);</code></pre>
|
|
1962
|
+
</div>
|
|
1963
|
+
</section>
|
|
1964
|
+
|
|
1965
|
+
</main>
|
|
1966
|
+
|
|
1967
|
+
<script>
|
|
1968
|
+
// ── State ────────────────────────────────────────────────────────────────
|
|
1969
|
+
let _platform = 'rn';
|
|
1970
|
+
let _mode = 'builtin';
|
|
1971
|
+
|
|
1972
|
+
// ── Platform switch ──────────────────────────────────────────────────────
|
|
1973
|
+
function setPlatform(p) {
|
|
1974
|
+
_platform = p;
|
|
1975
|
+
document.querySelectorAll('.platform-btn').forEach(b => b.classList.toggle('active', b.dataset.platform === p));
|
|
1976
|
+
document.getElementById('nav-platform-label').textContent =
|
|
1977
|
+
p === 'rn' ? 'React Native' : p === 'web' ? 'React / Next.js' : 'Node.js';
|
|
1978
|
+
applyGating();
|
|
1979
|
+
localStorage.setItem('antz-platform', p);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// ── Mode switch ───────────────────────────────────────────────────────────
|
|
1983
|
+
function setMode(m) {
|
|
1984
|
+
_mode = m;
|
|
1985
|
+
document.querySelectorAll('.mode-opt').forEach(el => {
|
|
1986
|
+
el.classList.toggle('sel', el.onclick?.toString().includes(`'${m}'`));
|
|
1987
|
+
});
|
|
1988
|
+
applyGating();
|
|
1989
|
+
localStorage.setItem('antz-mode', m);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// ── Apply all gating ──────────────────────────────────────────────────────
|
|
1993
|
+
function applyGating() {
|
|
1994
|
+
const p = _platform, m = _mode, pm = `${p}-${m}`;
|
|
1995
|
+
|
|
1996
|
+
// [data-p] — platform only
|
|
1997
|
+
document.querySelectorAll('[data-p]').forEach(el => {
|
|
1998
|
+
const ps = el.dataset.p.split(' ');
|
|
1999
|
+
el.classList.toggle('pshow', ps.includes(p));
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
// [data-m] — mode only
|
|
2003
|
+
document.querySelectorAll('[data-m]').forEach(el => {
|
|
2004
|
+
el.classList.toggle('mshow', el.dataset.m === m);
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
// [data-pm] — platform+mode combo
|
|
2008
|
+
document.querySelectorAll('[data-pm]').forEach(el => {
|
|
2009
|
+
const pms = el.dataset.pm.split(' ');
|
|
2010
|
+
el.classList.toggle('pmshow', pms.includes(pm));
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
// Sidebar nav items
|
|
2014
|
+
document.querySelectorAll('li[data-nav]').forEach(el => {
|
|
2015
|
+
const tag = el.dataset.nav;
|
|
2016
|
+
let show = false;
|
|
2017
|
+
if (tag === 'builtin') show = m === 'builtin';
|
|
2018
|
+
else if (tag === 'external') show = m === 'external';
|
|
2019
|
+
else if (tag === 'rn-web') show = p !== 'node';
|
|
2020
|
+
el.classList.toggle('show', show);
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
// Push step — hide for node
|
|
2024
|
+
const pushSection = document.getElementById('step-push');
|
|
2025
|
+
if (pushSection) pushSection.style.display = p === 'node' ? 'none' : '';
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// ── Init ──────────────────────────────────────────────────────────────────
|
|
2029
|
+
const savedP = localStorage.getItem('antz-platform') || 'rn';
|
|
2030
|
+
const savedM = localStorage.getItem('antz-mode') || 'external';
|
|
2031
|
+
|
|
2032
|
+
// Fix mode-opt sel class based on saved value
|
|
2033
|
+
document.querySelectorAll('.mode-opt').forEach(el => {
|
|
2034
|
+
const isSel = el.onclick?.toString().includes(`'${savedM}'`);
|
|
2035
|
+
el.classList.toggle('sel', isSel);
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
_platform = savedP;
|
|
2039
|
+
_mode = savedM;
|
|
2040
|
+
document.querySelectorAll('.platform-btn').forEach(b => b.classList.toggle('active', b.dataset.platform === savedP));
|
|
2041
|
+
document.getElementById('nav-platform-label').textContent =
|
|
2042
|
+
savedP === 'rn' ? 'React Native' : savedP === 'web' ? 'React / Next.js' : 'Node.js';
|
|
2043
|
+
applyGating();
|
|
2044
|
+
|
|
2045
|
+
// ── Progress bar ──────────────────────────────────────────────────────────
|
|
2046
|
+
window.addEventListener('scroll', () => {
|
|
2047
|
+
const el = document.getElementById('prog');
|
|
2048
|
+
el.style.width = (window.scrollY / (document.body.scrollHeight - window.innerHeight) * 100) + '%';
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
// ── Sidebar active link ───────────────────────────────────────────────────
|
|
2052
|
+
new IntersectionObserver(entries => {
|
|
2053
|
+
entries.forEach(e => {
|
|
2054
|
+
if (e.isIntersecting) {
|
|
2055
|
+
document.querySelectorAll('nav a').forEach(l => l.classList.remove('active'));
|
|
2056
|
+
const a = document.querySelector(`nav a[href="#${e.target.id}"]`);
|
|
2057
|
+
if (a) a.classList.add('active');
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
}, { rootMargin: '-20% 0px -70% 0px' }).observe(...Array.from(document.querySelectorAll('section[id]')));
|
|
2061
|
+
|
|
2062
|
+
// Fix: observe all sections
|
|
2063
|
+
document.querySelectorAll('section[id]').forEach(s => {
|
|
2064
|
+
new IntersectionObserver(entries => {
|
|
2065
|
+
entries.forEach(e => {
|
|
2066
|
+
if (e.isIntersecting) {
|
|
2067
|
+
document.querySelectorAll('nav a').forEach(l => l.classList.remove('active'));
|
|
2068
|
+
const a = document.querySelector(`nav a[href="#${e.target.id}"]`);
|
|
2069
|
+
if (a) a.classList.add('active');
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
}, { rootMargin: '-20% 0px -70% 0px' }).observe(s);
|
|
2073
|
+
});
|
|
2074
|
+
</script>
|
|
2075
|
+
</body>
|
|
2076
|
+
</html>
|