@proveanything/smartlinks 1.2.4 → 1.3.2

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.
@@ -0,0 +1,764 @@
1
+ # Real-Time Messaging with Ably
2
+
3
+ This guide covers adding Ably real-time messaging to SmartLinks apps that need live updates, chat, or presence features.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ Ably provides real-time pub/sub messaging over WebSockets. It's ideal for:
10
+
11
+ - **Live chat** between users
12
+ - **Real-time vote/poll results** that update instantly
13
+ - **Presence indicators** showing who's online
14
+ - **Live activity feeds** and notifications
15
+ - **Collaborative features** without polling
16
+
17
+ ### Why It's Not in the Base Template
18
+
19
+ Ably is intentionally excluded from the base template because:
20
+
21
+ 1. **Bundle size**: Ably SDK adds ~50KB+ to the bundle
22
+ 2. **Connection overhead**: Initializing Ably opens a WebSocket connection
23
+ 3. **Cost**: Ably charges per connection/message
24
+ 4. **Not always needed**: Most apps (pamphlets, manuals, warranties) don't need real-time
25
+
26
+ Apps that need real-time features should add Ably on-demand.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ Add Ably to your app:
33
+
34
+ ```bash
35
+ npm install ably
36
+ ```
37
+
38
+ This adds the Ably SDK with React hooks support.
39
+
40
+ ---
41
+
42
+ ## Quick Start
43
+
44
+ ### 1. Create the Ably Client
45
+
46
+ ```typescript
47
+ // src/lib/ably.ts
48
+ import Ably from 'ably';
49
+
50
+ export const createAblyClient = (apiKey: string) => {
51
+ return new Ably.Realtime({ key: apiKey });
52
+ };
53
+ ```
54
+
55
+ ### 2. Add the Provider
56
+
57
+ Wrap your app with the Ably provider:
58
+
59
+ ```typescript
60
+ // In PublicApp.tsx or AdminApp.tsx
61
+ import { AblyProvider } from 'ably/react';
62
+ import { createAblyClient } from '@/lib/ably';
63
+
64
+ const ablyClient = createAblyClient(import.meta.env.VITE_ABLY_API_KEY);
65
+
66
+ function App() {
67
+ return (
68
+ <AblyProvider client={ablyClient}>
69
+ <YourApp />
70
+ </AblyProvider>
71
+ );
72
+ }
73
+ ```
74
+
75
+ ### 3. Subscribe to a Channel
76
+
77
+ ```typescript
78
+ import { useChannel } from 'ably/react';
79
+ import { useState } from 'react';
80
+
81
+ export const LiveUpdates = ({ collectionId }: { collectionId: string }) => {
82
+ const [updates, setUpdates] = useState<string[]>([]);
83
+
84
+ const { channel } = useChannel(
85
+ `collection:${collectionId}:updates`,
86
+ (message) => {
87
+ setUpdates(prev => [...prev, message.data.text]);
88
+ }
89
+ );
90
+
91
+ const sendUpdate = (text: string) => {
92
+ channel.publish('update', { text });
93
+ };
94
+
95
+ return (
96
+ <div>
97
+ {updates.map((update, i) => (
98
+ <p key={i}>{update}</p>
99
+ ))}
100
+ <button onClick={() => sendUpdate('Hello!')}>
101
+ Send Update
102
+ </button>
103
+ </div>
104
+ );
105
+ };
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Setup Patterns
111
+
112
+ ### Option A: Direct API Key (Development)
113
+
114
+ For development or simple apps where the API key can be in environment variables:
115
+
116
+ ```typescript
117
+ // src/lib/ably.ts
118
+ import Ably from 'ably';
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
+ await SL.interactions.submitPublicEvent(collectionId, {
594
+ appId,
595
+ interactionId: 'poll',
596
+ outcome: option,
597
+ });
598
+
599
+ // Publish to Ably for real-time updates
600
+ channel.publish('vote', { option });
601
+ };
602
+ ```
603
+
604
+ ---
605
+
606
+ ## Best Practices
607
+
608
+ ### 1. Clean Up Subscriptions
609
+
610
+ React hooks automatically handle cleanup, but for manual subscriptions:
611
+
612
+ ```typescript
613
+ useEffect(() => {
614
+ const channel = ably.channels.get('my-channel');
615
+
616
+ const handler = (message: Ably.Message) => {
617
+ // Handle message
618
+ };
619
+
620
+ channel.subscribe('event', handler);
621
+
622
+ return () => {
623
+ channel.unsubscribe('event', handler);
624
+ };
625
+ }, []);
626
+ ```
627
+
628
+ ### 2. Handle Connection States
629
+
630
+ ```typescript
631
+ const { channel } = useChannel('my-channel', handleMessage);
632
+
633
+ useConnectionStateListener((stateChange) => {
634
+ if (stateChange.current === 'disconnected') {
635
+ // Show reconnecting UI
636
+ }
637
+ if (stateChange.current === 'connected') {
638
+ // Clear reconnecting UI, maybe refetch missed messages
639
+ }
640
+ });
641
+ ```
642
+
643
+ ### 3. Debounce Rapid Updates
644
+
645
+ For typing indicators or frequent updates:
646
+
647
+ ```typescript
648
+ import { useDebouncedCallback } from 'use-debounce';
649
+
650
+ const debouncedPublish = useDebouncedCallback((data) => {
651
+ channel.publish('typing', data);
652
+ }, 300);
653
+ ```
654
+
655
+ ### 4. Message Size Limits
656
+
657
+ Ably has a 64KB message size limit. For large data:
658
+ - Store in SmartLinks/database
659
+ - Send only the ID via Ably
660
+ - Fetch full data on receive
661
+
662
+ ```typescript
663
+ // Publishing
664
+ channel.publish('update', { recordId: '123' });
665
+
666
+ // Receiving
667
+ useChannel(channelName, async (message) => {
668
+ const fullData = await SL.appConfiguration.getDataItem({
669
+ collectionId,
670
+ appId,
671
+ itemId: message.data.recordId,
672
+ });
673
+ // Use fullData
674
+ });
675
+ ```
676
+
677
+ ### 5. Error Handling
678
+
679
+ ```typescript
680
+ try {
681
+ await channel.publish('event', data);
682
+ } catch (error) {
683
+ if (error instanceof Ably.ErrorInfo) {
684
+ console.error('Ably error:', error.code, error.message);
685
+ // Handle specific error codes
686
+ }
687
+ }
688
+ ```
689
+
690
+ ---
691
+
692
+ ## Environment Variables
693
+
694
+ For development, add to your local `.env`:
695
+
696
+ ```env
697
+ VITE_ABLY_API_KEY=your-api-key-here
698
+ ```
699
+
700
+ For production, use Lovable Cloud secrets or your deployment platform's environment variables.
701
+
702
+ ---
703
+
704
+ ## TypeScript Types
705
+
706
+ Ably provides full TypeScript support. Key types:
707
+
708
+ ```typescript
709
+ import Ably from 'ably';
710
+
711
+ // Message type
712
+ interface CustomMessage {
713
+ text: string;
714
+ sender: string;
715
+ timestamp: number;
716
+ }
717
+
718
+ // Typed channel usage
719
+ const { channel } = useChannel<CustomMessage>('my-channel', (message) => {
720
+ const data: CustomMessage = message.data;
721
+ console.log(data.text); // Typed!
722
+ });
723
+
724
+ // Presence data type
725
+ interface PresenceData {
726
+ id: string;
727
+ name: string;
728
+ status: 'active' | 'away';
729
+ }
730
+
731
+ const { presenceData } = usePresence<PresenceData>(channelName, {
732
+ id: userId,
733
+ name: userName,
734
+ status: 'active',
735
+ });
736
+ ```
737
+
738
+ ---
739
+
740
+ ## Troubleshooting
741
+
742
+ ### "Ably is not defined"
743
+
744
+ Ensure you've installed the package:
745
+ ```bash
746
+ npm install ably
747
+ ```
748
+
749
+ ### "No API key provided"
750
+
751
+ Check that `VITE_ABLY_API_KEY` is set in your environment.
752
+
753
+ ### Messages not received
754
+
755
+ 1. Check channel names match exactly
756
+ 2. Verify connection state is 'connected'
757
+ 3. Check browser console for Ably errors
758
+ 4. Ensure you're subscribed before publishing
759
+
760
+ ### Too many connections
761
+
762
+ Each browser tab opens a new connection. In development:
763
+ - Close unused tabs
764
+ - Use the same client instance across components (provider pattern)