@proveanything/smartlinks 2.0.55 → 2.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/integrations.d.ts +21 -1
- package/dist/api/integrations.js +25 -0
- package/dist/api/realtime.d.ts +31 -88
- package/dist/api/realtime.js +239 -93
- package/dist/docs/API_SUMMARY.md +264 -4
- package/dist/docs/app-manifest.md +7 -0
- package/dist/docs/connectors.md +204 -0
- package/dist/docs/integrations.md +4 -0
- package/dist/docs/overview.md +3 -2
- package/dist/docs/realtime.md +118 -765
- package/dist/http.js +3 -0
- package/dist/openapi.yaml +435 -3
- package/dist/types/appManifest.d.ts +166 -0
- package/dist/types/integrations.d.ts +43 -1
- package/dist/types/realtime.d.ts +44 -0
- package/docs/API_SUMMARY.md +264 -4
- package/docs/app-manifest.md +7 -0
- package/docs/connectors.md +204 -0
- package/docs/integrations.md +4 -0
- package/docs/overview.md +3 -2
- package/docs/realtime.md +118 -765
- package/openapi.yaml +435 -3
- package/package.json +1 -1
- package/scripts/doctor.mjs +25 -0
package/docs/realtime.md
CHANGED
|
@@ -1,765 +1,118 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
export const createAblyClient = () => {
|
|
121
|
-
const apiKey = import.meta.env.VITE_ABLY_API_KEY;
|
|
122
|
-
|
|
123
|
-
if (!apiKey) {
|
|
124
|
-
console.warn('VITE_ABLY_API_KEY not set - real-time features disabled');
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return new Ably.Realtime({ key: apiKey });
|
|
129
|
-
};
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Add to your `.env`:
|
|
133
|
-
|
|
134
|
-
```env
|
|
135
|
-
VITE_ABLY_API_KEY=your-ably-api-key
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
### Option B: Token Auth (Production) - SmartLinks SDK
|
|
139
|
-
|
|
140
|
-
For production apps, use the SmartLinks SDK's built-in token authentication:
|
|
141
|
-
|
|
142
|
-
```typescript
|
|
143
|
-
// src/lib/ably.ts
|
|
144
|
-
import Ably from 'ably';
|
|
145
|
-
import * as SL from '@proveanything/smartlinks';
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Create an Ably client using SmartLinks token authentication.
|
|
149
|
-
* This is the recommended approach for production apps.
|
|
150
|
-
*/
|
|
151
|
-
export const createAblyClientWithSmartLinks = (collectionId: string, appId?: string) => {
|
|
152
|
-
return new Ably.Realtime({
|
|
153
|
-
authCallback: async (tokenParams, callback) => {
|
|
154
|
-
try {
|
|
155
|
-
// Use the SmartLinks SDK to get a scoped token
|
|
156
|
-
const tokenRequest = await SL.realtime.getPublicToken({
|
|
157
|
-
collectionId,
|
|
158
|
-
appId,
|
|
159
|
-
});
|
|
160
|
-
callback(null, tokenRequest);
|
|
161
|
-
} catch (error) {
|
|
162
|
-
callback(error as Error, null);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Create an Ably client for admin real-time features.
|
|
170
|
-
* Provides subscribe-only access to interaction channels.
|
|
171
|
-
*/
|
|
172
|
-
export const createAblyClientForAdmin = () => {
|
|
173
|
-
return new Ably.Realtime({
|
|
174
|
-
authCallback: async (tokenParams, callback) => {
|
|
175
|
-
try {
|
|
176
|
-
const tokenRequest = await SL.realtime.getAdminToken();
|
|
177
|
-
callback(null, tokenRequest);
|
|
178
|
-
} catch (error) {
|
|
179
|
-
callback(error as Error, null);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
};
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
### SmartLinks SDK Real-Time Functions
|
|
187
|
-
|
|
188
|
-
The SDK provides two token endpoints:
|
|
189
|
-
|
|
190
|
-
| Function | Use Case | Parameters |
|
|
191
|
-
|----------|----------|------------|
|
|
192
|
-
| `SL.realtime.getPublicToken()` | User-scoped real-time (chat, votes, presence) | `collectionId` (required), `appId` (optional) |
|
|
193
|
-
| `SL.realtime.getAdminToken()` | Admin real-time (interaction monitoring) | None |
|
|
194
|
-
|
|
195
|
-
Both require the user to be authenticated via the parent SmartLinks platform.
|
|
196
|
-
|
|
197
|
-
Token auth benefits:
|
|
198
|
-
- API key never exposed to client
|
|
199
|
-
- Tokens can have capability restrictions
|
|
200
|
-
- Tokens expire automatically
|
|
201
|
-
|
|
202
|
-
### Conditional Provider Setup
|
|
203
|
-
|
|
204
|
-
Handle cases where Ably isn't configured:
|
|
205
|
-
|
|
206
|
-
```typescript
|
|
207
|
-
// src/providers/AblyProvider.tsx
|
|
208
|
-
import { AblyProvider as AblyReactProvider } from 'ably/react';
|
|
209
|
-
import { createAblyClient } from '@/lib/ably';
|
|
210
|
-
import { ReactNode, useMemo } from 'react';
|
|
211
|
-
|
|
212
|
-
interface Props {
|
|
213
|
-
children: ReactNode;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export const AblyProvider = ({ children }: Props) => {
|
|
217
|
-
const client = useMemo(() => createAblyClient(), []);
|
|
218
|
-
|
|
219
|
-
// If no client (no API key), render children without provider
|
|
220
|
-
if (!client) {
|
|
221
|
-
return <>{children}</>;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
<AblyReactProvider client={client}>
|
|
226
|
-
{children}
|
|
227
|
-
</AblyReactProvider>
|
|
228
|
-
);
|
|
229
|
-
};
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
---
|
|
233
|
-
|
|
234
|
-
## Common Patterns
|
|
235
|
-
|
|
236
|
-
### Subscribing to a Channel
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
import { useChannel } from 'ably/react';
|
|
240
|
-
|
|
241
|
-
const MyComponent = () => {
|
|
242
|
-
const { channel } = useChannel('my-channel', (message) => {
|
|
243
|
-
console.log('Received:', message.name, message.data);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return <div>Listening...</div>;
|
|
247
|
-
};
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
### Publishing Messages
|
|
251
|
-
|
|
252
|
-
```typescript
|
|
253
|
-
const { channel } = useChannel('my-channel', handleMessage);
|
|
254
|
-
|
|
255
|
-
const sendMessage = (text: string) => {
|
|
256
|
-
channel.publish('message', {
|
|
257
|
-
text,
|
|
258
|
-
timestamp: Date.now(),
|
|
259
|
-
sender: userId
|
|
260
|
-
});
|
|
261
|
-
};
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
### Presence (Who's Online)
|
|
265
|
-
|
|
266
|
-
```typescript
|
|
267
|
-
import { usePresence } from 'ably/react';
|
|
268
|
-
|
|
269
|
-
const OnlineUsers = ({ roomId }: { roomId: string }) => {
|
|
270
|
-
const { presenceData, updateStatus } = usePresence(roomId, {
|
|
271
|
-
id: currentUser.id,
|
|
272
|
-
name: currentUser.name,
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
return (
|
|
276
|
-
<div>
|
|
277
|
-
<h3>Online ({presenceData.length})</h3>
|
|
278
|
-
<ul>
|
|
279
|
-
{presenceData.map((member) => (
|
|
280
|
-
<li key={member.clientId}>{member.data.name}</li>
|
|
281
|
-
))}
|
|
282
|
-
</ul>
|
|
283
|
-
</div>
|
|
284
|
-
);
|
|
285
|
-
};
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
### Connection State
|
|
289
|
-
|
|
290
|
-
```typescript
|
|
291
|
-
import { useConnectionStateListener } from 'ably/react';
|
|
292
|
-
|
|
293
|
-
const ConnectionStatus = () => {
|
|
294
|
-
const [status, setStatus] = useState('connecting');
|
|
295
|
-
|
|
296
|
-
useConnectionStateListener((stateChange) => {
|
|
297
|
-
setStatus(stateChange.current);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
return (
|
|
301
|
-
<div className={status === 'connected' ? 'text-green-500' : 'text-yellow-500'}>
|
|
302
|
-
{status}
|
|
303
|
-
</div>
|
|
304
|
-
);
|
|
305
|
-
};
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
---
|
|
309
|
-
|
|
310
|
-
## SmartLinks Integration
|
|
311
|
-
|
|
312
|
-
### Channel Naming Conventions
|
|
313
|
-
|
|
314
|
-
Use consistent channel names that include SmartLinks context:
|
|
315
|
-
|
|
316
|
-
| Scope | Pattern | Example |
|
|
317
|
-
|-------|---------|---------|
|
|
318
|
-
| Collection | `collection:${collectionId}:${feature}` | `collection:abc123:updates` |
|
|
319
|
-
| Product | `product:${collectionId}:${productId}:${feature}` | `product:abc123:prod456:chat` |
|
|
320
|
-
| Proof | `proof:${collectionId}:${productId}:${proofId}:${feature}` | `proof:abc123:prod456:prf789:activity` |
|
|
321
|
-
|
|
322
|
-
### Helper for Channel Names
|
|
323
|
-
|
|
324
|
-
```typescript
|
|
325
|
-
// src/lib/ably-channels.ts
|
|
326
|
-
export const channels = {
|
|
327
|
-
collectionUpdates: (collectionId: string) =>
|
|
328
|
-
`collection:${collectionId}:updates`,
|
|
329
|
-
|
|
330
|
-
productChat: (collectionId: string, productId: string) =>
|
|
331
|
-
`product:${collectionId}:${productId}:chat`,
|
|
332
|
-
|
|
333
|
-
proofActivity: (collectionId: string, productId: string, proofId: string) =>
|
|
334
|
-
`proof:${collectionId}:${productId}:${proofId}:activity`,
|
|
335
|
-
|
|
336
|
-
voteResults: (collectionId: string, appId: string, interactionId: string) =>
|
|
337
|
-
`votes:${collectionId}:${appId}:${interactionId}`,
|
|
338
|
-
};
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### Usage with SmartLinks Context
|
|
342
|
-
|
|
343
|
-
```typescript
|
|
344
|
-
import { useChannel } from 'ably/react';
|
|
345
|
-
import { usePersistentQueryParams } from '@/hooks/usePersistentQueryParams';
|
|
346
|
-
import { channels } from '@/lib/ably-channels';
|
|
347
|
-
|
|
348
|
-
const LiveProductChat = () => {
|
|
349
|
-
const { persistentQueryParams } = usePersistentQueryParams();
|
|
350
|
-
const { collectionId, productId } = persistentQueryParams;
|
|
351
|
-
|
|
352
|
-
const channelName = channels.productChat(collectionId!, productId!);
|
|
353
|
-
|
|
354
|
-
const { channel } = useChannel(channelName, (message) => {
|
|
355
|
-
// Handle incoming chat message
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
// ...
|
|
359
|
-
};
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
---
|
|
363
|
-
|
|
364
|
-
## Example: Live Chat Component
|
|
365
|
-
|
|
366
|
-
Full working example of a chat component:
|
|
367
|
-
|
|
368
|
-
```typescript
|
|
369
|
-
// src/components/LiveChat.tsx
|
|
370
|
-
import { useChannel, usePresence } from 'ably/react';
|
|
371
|
-
import { useState, useRef, useEffect } from 'react';
|
|
372
|
-
import { Button } from '@/components/ui/button';
|
|
373
|
-
import { Input } from '@/components/ui/input';
|
|
374
|
-
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
375
|
-
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
376
|
-
|
|
377
|
-
interface ChatMessage {
|
|
378
|
-
id: string;
|
|
379
|
-
text: string;
|
|
380
|
-
sender: string;
|
|
381
|
-
senderName: string;
|
|
382
|
-
timestamp: number;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
interface LiveChatProps {
|
|
386
|
-
channelName: string;
|
|
387
|
-
userId: string;
|
|
388
|
-
userName: string;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
export const LiveChat = ({ channelName, userId, userName }: LiveChatProps) => {
|
|
392
|
-
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
393
|
-
const [inputValue, setInputValue] = useState('');
|
|
394
|
-
const scrollRef = useRef<HTMLDivElement>(null);
|
|
395
|
-
|
|
396
|
-
// Subscribe to chat messages
|
|
397
|
-
const { channel } = useChannel(channelName, (message) => {
|
|
398
|
-
if (message.name === 'chat') {
|
|
399
|
-
setMessages(prev => [...prev, message.data as ChatMessage]);
|
|
400
|
-
}
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Track presence
|
|
404
|
-
const { presenceData } = usePresence(channelName, {
|
|
405
|
-
id: userId,
|
|
406
|
-
name: userName,
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
// Auto-scroll to bottom
|
|
410
|
-
useEffect(() => {
|
|
411
|
-
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
412
|
-
}, [messages]);
|
|
413
|
-
|
|
414
|
-
const sendMessage = () => {
|
|
415
|
-
if (!inputValue.trim()) return;
|
|
416
|
-
|
|
417
|
-
const message: ChatMessage = {
|
|
418
|
-
id: `${userId}-${Date.now()}`,
|
|
419
|
-
text: inputValue.trim(),
|
|
420
|
-
sender: userId,
|
|
421
|
-
senderName: userName,
|
|
422
|
-
timestamp: Date.now(),
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
channel.publish('chat', message);
|
|
426
|
-
setInputValue('');
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
return (
|
|
430
|
-
<div className="flex flex-col h-[400px] border rounded-lg">
|
|
431
|
-
{/* Header with presence */}
|
|
432
|
-
<div className="p-3 border-b bg-muted/50">
|
|
433
|
-
<div className="flex items-center gap-2">
|
|
434
|
-
<span className="text-sm font-medium">Live Chat</span>
|
|
435
|
-
<span className="text-xs text-muted-foreground">
|
|
436
|
-
({presenceData.length} online)
|
|
437
|
-
</span>
|
|
438
|
-
</div>
|
|
439
|
-
</div>
|
|
440
|
-
|
|
441
|
-
{/* Messages */}
|
|
442
|
-
<ScrollArea className="flex-1 p-4">
|
|
443
|
-
<div className="space-y-4">
|
|
444
|
-
{messages.map((msg) => (
|
|
445
|
-
<div
|
|
446
|
-
key={msg.id}
|
|
447
|
-
className={`flex gap-2 ${
|
|
448
|
-
msg.sender === userId ? 'flex-row-reverse' : ''
|
|
449
|
-
}`}
|
|
450
|
-
>
|
|
451
|
-
<Avatar className="h-8 w-8">
|
|
452
|
-
<AvatarFallback>
|
|
453
|
-
{msg.senderName.charAt(0).toUpperCase()}
|
|
454
|
-
</AvatarFallback>
|
|
455
|
-
</Avatar>
|
|
456
|
-
<div
|
|
457
|
-
className={`rounded-lg px-3 py-2 max-w-[70%] ${
|
|
458
|
-
msg.sender === userId
|
|
459
|
-
? 'bg-primary text-primary-foreground'
|
|
460
|
-
: 'bg-muted'
|
|
461
|
-
}`}
|
|
462
|
-
>
|
|
463
|
-
<p className="text-sm">{msg.text}</p>
|
|
464
|
-
<span className="text-xs opacity-70">
|
|
465
|
-
{new Date(msg.timestamp).toLocaleTimeString()}
|
|
466
|
-
</span>
|
|
467
|
-
</div>
|
|
468
|
-
</div>
|
|
469
|
-
))}
|
|
470
|
-
<div ref={scrollRef} />
|
|
471
|
-
</div>
|
|
472
|
-
</ScrollArea>
|
|
473
|
-
|
|
474
|
-
{/* Input */}
|
|
475
|
-
<div className="p-3 border-t">
|
|
476
|
-
<form
|
|
477
|
-
onSubmit={(e) => {
|
|
478
|
-
e.preventDefault();
|
|
479
|
-
sendMessage();
|
|
480
|
-
}}
|
|
481
|
-
className="flex gap-2"
|
|
482
|
-
>
|
|
483
|
-
<Input
|
|
484
|
-
value={inputValue}
|
|
485
|
-
onChange={(e) => setInputValue(e.target.value)}
|
|
486
|
-
placeholder="Type a message..."
|
|
487
|
-
className="flex-1"
|
|
488
|
-
/>
|
|
489
|
-
<Button type="submit" size="sm">
|
|
490
|
-
Send
|
|
491
|
-
</Button>
|
|
492
|
-
</form>
|
|
493
|
-
</div>
|
|
494
|
-
</div>
|
|
495
|
-
);
|
|
496
|
-
};
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
---
|
|
500
|
-
|
|
501
|
-
## Example: Live Vote Counter
|
|
502
|
-
|
|
503
|
-
Display real-time vote tallies:
|
|
504
|
-
|
|
505
|
-
```typescript
|
|
506
|
-
// src/components/LiveVoteCounter.tsx
|
|
507
|
-
import { useChannel } from 'ably/react';
|
|
508
|
-
import { useState, useEffect } from 'react';
|
|
509
|
-
import { Progress } from '@/components/ui/progress';
|
|
510
|
-
import * as SL from '@proveanything/smartlinks';
|
|
511
|
-
|
|
512
|
-
interface VoteCounts {
|
|
513
|
-
[option: string]: number;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
interface LiveVoteCounterProps {
|
|
517
|
-
collectionId: string;
|
|
518
|
-
appId: string;
|
|
519
|
-
interactionId: string;
|
|
520
|
-
options: string[];
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
export const LiveVoteCounter = ({
|
|
524
|
-
collectionId,
|
|
525
|
-
appId,
|
|
526
|
-
interactionId,
|
|
527
|
-
options,
|
|
528
|
-
}: LiveVoteCounterProps) => {
|
|
529
|
-
const [votes, setVotes] = useState<VoteCounts>({});
|
|
530
|
-
const channelName = `votes:${collectionId}:${appId}:${interactionId}`;
|
|
531
|
-
|
|
532
|
-
// Fetch initial counts
|
|
533
|
-
useEffect(() => {
|
|
534
|
-
const fetchCounts = async () => {
|
|
535
|
-
const counts = await SL.interactions.countsByOutcome(collectionId, {
|
|
536
|
-
appId,
|
|
537
|
-
interactionId,
|
|
538
|
-
});
|
|
539
|
-
setVotes(counts || {});
|
|
540
|
-
};
|
|
541
|
-
fetchCounts();
|
|
542
|
-
}, [collectionId, appId, interactionId]);
|
|
543
|
-
|
|
544
|
-
// Subscribe to real-time vote updates
|
|
545
|
-
useChannel(channelName, (message) => {
|
|
546
|
-
if (message.name === 'vote') {
|
|
547
|
-
const { option } = message.data;
|
|
548
|
-
setVotes(prev => ({
|
|
549
|
-
...prev,
|
|
550
|
-
[option]: (prev[option] || 0) + 1,
|
|
551
|
-
}));
|
|
552
|
-
}
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
const totalVotes = Object.values(votes).reduce((sum, n) => sum + n, 0);
|
|
556
|
-
|
|
557
|
-
return (
|
|
558
|
-
<div className="space-y-4 p-4">
|
|
559
|
-
<h3 className="font-semibold">Live Results</h3>
|
|
560
|
-
|
|
561
|
-
{options.map((option) => {
|
|
562
|
-
const count = votes[option] || 0;
|
|
563
|
-
const percentage = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
|
|
564
|
-
|
|
565
|
-
return (
|
|
566
|
-
<div key={option} className="space-y-1">
|
|
567
|
-
<div className="flex justify-between text-sm">
|
|
568
|
-
<span>{option}</span>
|
|
569
|
-
<span className="text-muted-foreground">
|
|
570
|
-
{count} ({percentage.toFixed(1)}%)
|
|
571
|
-
</span>
|
|
572
|
-
</div>
|
|
573
|
-
<Progress value={percentage} className="h-2" />
|
|
574
|
-
</div>
|
|
575
|
-
);
|
|
576
|
-
})}
|
|
577
|
-
|
|
578
|
-
<p className="text-sm text-muted-foreground text-center">
|
|
579
|
-
Total votes: {totalVotes}
|
|
580
|
-
</p>
|
|
581
|
-
</div>
|
|
582
|
-
);
|
|
583
|
-
};
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
### Publishing Vote Updates
|
|
587
|
-
|
|
588
|
-
When a user votes, publish to the channel:
|
|
589
|
-
|
|
590
|
-
```typescript
|
|
591
|
-
const submitVote = async (option: string) => {
|
|
592
|
-
// Record in SmartLinks
|
|
593
|
-
// 'poll' must already exist as an interaction type definition for this collection.
|
|
594
|
-
await SL.interactions.submitPublicEvent(collectionId, {
|
|
595
|
-
appId,
|
|
596
|
-
interactionId: 'poll',
|
|
597
|
-
outcome: option,
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
// Publish to Ably for real-time updates
|
|
601
|
-
channel.publish('vote', { option });
|
|
602
|
-
};
|
|
603
|
-
```
|
|
604
|
-
|
|
605
|
-
---
|
|
606
|
-
|
|
607
|
-
## Best Practices
|
|
608
|
-
|
|
609
|
-
### 1. Clean Up Subscriptions
|
|
610
|
-
|
|
611
|
-
React hooks automatically handle cleanup, but for manual subscriptions:
|
|
612
|
-
|
|
613
|
-
```typescript
|
|
614
|
-
useEffect(() => {
|
|
615
|
-
const channel = ably.channels.get('my-channel');
|
|
616
|
-
|
|
617
|
-
const handler = (message: Ably.Message) => {
|
|
618
|
-
// Handle message
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
channel.subscribe('event', handler);
|
|
622
|
-
|
|
623
|
-
return () => {
|
|
624
|
-
channel.unsubscribe('event', handler);
|
|
625
|
-
};
|
|
626
|
-
}, []);
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
### 2. Handle Connection States
|
|
630
|
-
|
|
631
|
-
```typescript
|
|
632
|
-
const { channel } = useChannel('my-channel', handleMessage);
|
|
633
|
-
|
|
634
|
-
useConnectionStateListener((stateChange) => {
|
|
635
|
-
if (stateChange.current === 'disconnected') {
|
|
636
|
-
// Show reconnecting UI
|
|
637
|
-
}
|
|
638
|
-
if (stateChange.current === 'connected') {
|
|
639
|
-
// Clear reconnecting UI, maybe refetch missed messages
|
|
640
|
-
}
|
|
641
|
-
});
|
|
642
|
-
```
|
|
643
|
-
|
|
644
|
-
### 3. Debounce Rapid Updates
|
|
645
|
-
|
|
646
|
-
For typing indicators or frequent updates:
|
|
647
|
-
|
|
648
|
-
```typescript
|
|
649
|
-
import { useDebouncedCallback } from 'use-debounce';
|
|
650
|
-
|
|
651
|
-
const debouncedPublish = useDebouncedCallback((data) => {
|
|
652
|
-
channel.publish('typing', data);
|
|
653
|
-
}, 300);
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
### 4. Message Size Limits
|
|
657
|
-
|
|
658
|
-
Ably has a 64KB message size limit. For large data:
|
|
659
|
-
- Store in SmartLinks/database
|
|
660
|
-
- Send only the ID via Ably
|
|
661
|
-
- Fetch full data on receive
|
|
662
|
-
|
|
663
|
-
```typescript
|
|
664
|
-
// Publishing
|
|
665
|
-
channel.publish('update', { recordId: '123' });
|
|
666
|
-
|
|
667
|
-
// Receiving
|
|
668
|
-
useChannel(channelName, async (message) => {
|
|
669
|
-
const fullData = await SL.appConfiguration.getDataItem({
|
|
670
|
-
collectionId,
|
|
671
|
-
appId,
|
|
672
|
-
itemId: message.data.recordId,
|
|
673
|
-
});
|
|
674
|
-
// Use fullData
|
|
675
|
-
});
|
|
676
|
-
```
|
|
677
|
-
|
|
678
|
-
### 5. Error Handling
|
|
679
|
-
|
|
680
|
-
```typescript
|
|
681
|
-
try {
|
|
682
|
-
await channel.publish('event', data);
|
|
683
|
-
} catch (error) {
|
|
684
|
-
if (error instanceof Ably.ErrorInfo) {
|
|
685
|
-
console.error('Ably error:', error.code, error.message);
|
|
686
|
-
// Handle specific error codes
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
---
|
|
692
|
-
|
|
693
|
-
## Environment Variables
|
|
694
|
-
|
|
695
|
-
For development, add to your local `.env`:
|
|
696
|
-
|
|
697
|
-
```env
|
|
698
|
-
VITE_ABLY_API_KEY=your-api-key-here
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
For production, use Lovable Cloud secrets or your deployment platform's environment variables.
|
|
702
|
-
|
|
703
|
-
---
|
|
704
|
-
|
|
705
|
-
## TypeScript Types
|
|
706
|
-
|
|
707
|
-
Ably provides full TypeScript support. Key types:
|
|
708
|
-
|
|
709
|
-
```typescript
|
|
710
|
-
import Ably from 'ably';
|
|
711
|
-
|
|
712
|
-
// Message type
|
|
713
|
-
interface CustomMessage {
|
|
714
|
-
text: string;
|
|
715
|
-
sender: string;
|
|
716
|
-
timestamp: number;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// Typed channel usage
|
|
720
|
-
const { channel } = useChannel<CustomMessage>('my-channel', (message) => {
|
|
721
|
-
const data: CustomMessage = message.data;
|
|
722
|
-
console.log(data.text); // Typed!
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
// Presence data type
|
|
726
|
-
interface PresenceData {
|
|
727
|
-
id: string;
|
|
728
|
-
name: string;
|
|
729
|
-
status: 'active' | 'away';
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const { presenceData } = usePresence<PresenceData>(channelName, {
|
|
733
|
-
id: userId,
|
|
734
|
-
name: userName,
|
|
735
|
-
status: 'active',
|
|
736
|
-
});
|
|
737
|
-
```
|
|
738
|
-
|
|
739
|
-
---
|
|
740
|
-
|
|
741
|
-
## Troubleshooting
|
|
742
|
-
|
|
743
|
-
### "Ably is not defined"
|
|
744
|
-
|
|
745
|
-
Ensure you've installed the package:
|
|
746
|
-
```bash
|
|
747
|
-
npm install ably
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
### "No API key provided"
|
|
751
|
-
|
|
752
|
-
Check that `VITE_ABLY_API_KEY` is set in your environment.
|
|
753
|
-
|
|
754
|
-
### Messages not received
|
|
755
|
-
|
|
756
|
-
1. Check channel names match exactly
|
|
757
|
-
2. Verify connection state is 'connected'
|
|
758
|
-
3. Check browser console for Ably errors
|
|
759
|
-
4. Ensure you're subscribed before publishing
|
|
760
|
-
|
|
761
|
-
### Too many connections
|
|
762
|
-
|
|
763
|
-
Each browser tab opens a new connection. In development:
|
|
764
|
-
- Close unused tabs
|
|
765
|
-
- Use the same client instance across components (provider pattern)
|
|
1
|
+
# SmartLinks Realtime
|
|
2
|
+
|
|
3
|
+
Live updates for apps: a big screen showing the raffle draw, an admin list that updates as records
|
|
4
|
+
arrive, a menu that refreshes when an app is installed. **No provider library, no keys, no channel
|
|
5
|
+
strings in your app.** SmartLinks hands out a stream for exactly what the viewer may see, and messages
|
|
6
|
+
you send go through SmartLinks, which checks the channel's rule.
|
|
7
|
+
|
|
8
|
+
There are two kinds of realtime.
|
|
9
|
+
|
|
10
|
+
| | What it is | Use it for |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| **Change events** | "this changed, here's its id": ids only, never data | refetching a list or a record when it changes |
|
|
13
|
+
| **App channels** | messages your app sends, on channels it declares | a draw result, a live status, a chat line |
|
|
14
|
+
|
|
15
|
+
## Change events: refetch when something changes
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { realtime } from '@proveanything/smartlinks'
|
|
19
|
+
|
|
20
|
+
// Refetch the raffle's entries whenever one is added, changed or removed.
|
|
21
|
+
const off = realtime.onChange(
|
|
22
|
+
{ collectionId },
|
|
23
|
+
{ type: 'record', appId: 'quickRaffle', recordType: `entry:${raffleId}` },
|
|
24
|
+
() => refetchEntries(),
|
|
25
|
+
)
|
|
26
|
+
// …later
|
|
27
|
+
off()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Types:** `product`, `proof`, `asset`, `record`, `collection`, `collection.apps` (an app was
|
|
31
|
+
installed, removed or switched), `settings`.
|
|
32
|
+
- **Filter fields:** `type`, `id`, `appId`, `recordType`, `productId`, `proofId`. Every field you give
|
|
33
|
+
must match.
|
|
34
|
+
- **Who hears what:**
|
|
35
|
+
- a collection's **admins** hear every change;
|
|
36
|
+
- **everyone else** hears changes to public things (public app records, products).
|
|
37
|
+
- **A change carries ids, never data.** Refetch through the normal API, which applies the normal
|
|
38
|
+
permissions, so realtime can never show someone data they couldn't read anyway.
|
|
39
|
+
|
|
40
|
+
With React Query, invalidate on change:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
useEffect(() => realtime.onChange({ collectionId }, { type: 'record', appId }, () => {
|
|
44
|
+
queryClient.invalidateQueries({ queryKey: ['records', appId] })
|
|
45
|
+
}), [collectionId, appId])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## App channels: declare them, then subscribe and publish
|
|
49
|
+
|
|
50
|
+
**1. Declare** each channel in `app.manifest.json`, with who may listen and who may send:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
"realtime": {
|
|
54
|
+
"channels": [
|
|
55
|
+
{ "name": "draw", "subscribe": "public", "publish": "admin" },
|
|
56
|
+
{ "name": "chat", "subscribe": "signedIn", "publish": "signedIn" },
|
|
57
|
+
{ "name": "result","subscribe": "public", "publish": "server" }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Level | Who |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `public` | anyone, signed in or not: a big screen, a kiosk |
|
|
65
|
+
| `signedIn` | anyone signed in |
|
|
66
|
+
| `admin` | the collection's admins |
|
|
67
|
+
| `server` (publish only) | only your app's server functions: the authoritative way to announce a result |
|
|
68
|
+
|
|
69
|
+
- **Names:** lowercase letters, numbers, `-` and `_`, up to 40 characters.
|
|
70
|
+
- **Defaults:** if a level is left out, `subscribe` defaults to `admin` and `publish` to `server`.
|
|
71
|
+
- **Undeclared channels don't exist,** and anything sent to them is refused.
|
|
72
|
+
|
|
73
|
+
**2. Subscribe and publish** from your app:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const draw = realtime.channel({ collectionId, appId }, 'draw')
|
|
77
|
+
|
|
78
|
+
const off = draw.subscribe('status', (msg) => setStatus(msg.data)) // or '*' for every event
|
|
79
|
+
await draw.publish('status', { phase: 'spinning' }) // 403 unless the rule allows it
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**3. From a server function**, publish to any of your declared channels. This needs the
|
|
83
|
+
`sl:realtime:write` capability:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await ctx.sl.realtime.publish('result', 'winner', { number: 42 })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Messages are limited to 32 KB. Keep them small: send ids and status, and let clients fetch the rest.
|
|
90
|
+
|
|
91
|
+
## Messages for one person
|
|
92
|
+
|
|
93
|
+
`realtime.onMine({ collectionId }, (msg) => …)` hears messages meant only for the signed-in person. For
|
|
94
|
+
example, `scan`: a tag they just scanned with Sidekick on another device.
|
|
95
|
+
|
|
96
|
+
## How it behaves
|
|
97
|
+
|
|
98
|
+
- **One connection per page** (per collection and app), shared by everything listening. It opens with the
|
|
99
|
+
first listener and closes after the last.
|
|
100
|
+
- **It reconnects on its own** and renews its access before it expires.
|
|
101
|
+
- **Anonymous viewers** get public channels only. That's enough for a big screen, with no polling.
|
|
102
|
+
- **Outside a browser** (SSR, tests) there's no stream: listeners simply never fire.
|
|
103
|
+
- **Delivery is best effort.** A message sent while a viewer was offline isn't replayed, so on
|
|
104
|
+
reconnect, refetch what you show.
|
|
105
|
+
|
|
106
|
+
## Don't
|
|
107
|
+
|
|
108
|
+
- **Don't add a realtime provider's library or put an API key in your app.** Apps never talk to the
|
|
109
|
+
provider directly. A key in a bundle is a key for everyone.
|
|
110
|
+
- **Don't announce authoritative results from the browser.** Use a `server` channel and publish from a
|
|
111
|
+
server function.
|
|
112
|
+
- **Don't put personal data in messages.** Publish an id; fetch the details with permissions.
|
|
113
|
+
|
|
114
|
+
## Legacy
|
|
115
|
+
|
|
116
|
+
`realtime.getPublicToken()` and `realtime.getAdminToken()` return raw provider tokens for the old
|
|
117
|
+
`collection:{id}:*` and `interaction:{uid}` channels. They still work, but publishing is now limited to
|
|
118
|
+
the collection's admins. Move to `channel` / `onChange` / `onMine`.
|