UNPKG

@proveanything/smartlinks

Version:

Official JavaScript/TypeScript SDK for the Smartlinks API

765 lines (595 loc) 18.8 kB
# Real-Time Messaging with Ably This guide covers adding Ably real-time messaging to SmartLinks apps that need live updates, chat, or presence features. --- ## Overview Ably provides real-time pub/sub messaging over WebSockets. It's ideal for: - **Live chat** between users - **Real-time vote/poll results** that update instantly - **Presence indicators** showing who's online - **Live activity feeds** and notifications - **Collaborative features** without polling ### Why It's Not in the Base Template Ably is intentionally excluded from the base template because: 1. **Bundle size**: Ably SDK adds ~50KB+ to the bundle 2. **Connection overhead**: Initializing Ably opens a WebSocket connection 3. **Cost**: Ably charges per connection/message 4. **Not always needed**: Most apps (pamphlets, manuals, warranties) don't need real-time Apps that need real-time features should add Ably on-demand. --- ## Installation Add Ably to your app: ```bash npm install ably ``` This adds the Ably SDK with React hooks support. --- ## Quick Start ### 1. Create the Ably Client ```typescript // src/lib/ably.ts import Ably from 'ably'; export const createAblyClient = (apiKey: string) => { return new Ably.Realtime({ key: apiKey }); }; ``` ### 2. Add the Provider Wrap your app with the Ably provider: ```typescript // In PublicApp.tsx or AdminApp.tsx import { AblyProvider } from 'ably/react'; import { createAblyClient } from '@/lib/ably'; const ablyClient = createAblyClient(import.meta.env.VITE_ABLY_API_KEY); function App() { return ( <AblyProvider client={ablyClient}> <YourApp /> </AblyProvider> ); } ``` ### 3. Subscribe to a Channel ```typescript import { useChannel } from 'ably/react'; import { useState } from 'react'; export const LiveUpdates = ({ collectionId }: { collectionId: string }) => { const [updates, setUpdates] = useState<string[]>([]); const { channel } = useChannel( `collection:${collectionId}:updates`, (message) => { setUpdates(prev => [...prev, message.data.text]); } ); const sendUpdate = (text: string) => { channel.publish('update', { text }); }; return ( <div> {updates.map((update, i) => ( <p key={i}>{update}</p> ))} <button onClick={() => sendUpdate('Hello!')}> Send Update </button> </div> ); }; ``` --- ## Setup Patterns ### Option A: Direct API Key (Development) For development or simple apps where the API key can be in environment variables: ```typescript // src/lib/ably.ts import Ably from 'ably'; export const createAblyClient = () => { const apiKey = import.meta.env.VITE_ABLY_API_KEY; if (!apiKey) { console.warn('VITE_ABLY_API_KEY not set - real-time features disabled'); return null; } return new Ably.Realtime({ key: apiKey }); }; ``` Add to your `.env`: ```env VITE_ABLY_API_KEY=your-ably-api-key ``` ### Option B: Token Auth (Production) - SmartLinks SDK For production apps, use the SmartLinks SDK's built-in token authentication: ```typescript // src/lib/ably.ts import Ably from 'ably'; import * as SL from '@proveanything/smartlinks'; /** * Create an Ably client using SmartLinks token authentication. * This is the recommended approach for production apps. */ export const createAblyClientWithSmartLinks = (collectionId: string, appId?: string) => { return new Ably.Realtime({ authCallback: async (tokenParams, callback) => { try { // Use the SmartLinks SDK to get a scoped token const tokenRequest = await SL.realtime.getPublicToken({ collectionId, appId, }); callback(null, tokenRequest); } catch (error) { callback(error as Error, null); } } }); }; /** * Create an Ably client for admin real-time features. * Provides subscribe-only access to interaction channels. */ export const createAblyClientForAdmin = () => { return new Ably.Realtime({ authCallback: async (tokenParams, callback) => { try { const tokenRequest = await SL.realtime.getAdminToken(); callback(null, tokenRequest); } catch (error) { callback(error as Error, null); } } }); }; ``` ### SmartLinks SDK Real-Time Functions The SDK provides two token endpoints: | Function | Use Case | Parameters | |----------|----------|------------| | `SL.realtime.getPublicToken()` | User-scoped real-time (chat, votes, presence) | `collectionId` (required), `appId` (optional) | | `SL.realtime.getAdminToken()` | Admin real-time (interaction monitoring) | None | Both require the user to be authenticated via the parent SmartLinks platform. Token auth benefits: - API key never exposed to client - Tokens can have capability restrictions - Tokens expire automatically ### Conditional Provider Setup Handle cases where Ably isn't configured: ```typescript // src/providers/AblyProvider.tsx import { AblyProvider as AblyReactProvider } from 'ably/react'; import { createAblyClient } from '@/lib/ably'; import { ReactNode, useMemo } from 'react'; interface Props { children: ReactNode; } export const AblyProvider = ({ children }: Props) => { const client = useMemo(() => createAblyClient(), []); // If no client (no API key), render children without provider if (!client) { return <>{children}</>; } return ( <AblyReactProvider client={client}> {children} </AblyReactProvider> ); }; ``` --- ## Common Patterns ### Subscribing to a Channel ```typescript import { useChannel } from 'ably/react'; const MyComponent = () => { const { channel } = useChannel('my-channel', (message) => { console.log('Received:', message.name, message.data); }); return <div>Listening...</div>; }; ``` ### Publishing Messages ```typescript const { channel } = useChannel('my-channel', handleMessage); const sendMessage = (text: string) => { channel.publish('message', { text, timestamp: Date.now(), sender: userId }); }; ``` ### Presence (Who's Online) ```typescript import { usePresence } from 'ably/react'; const OnlineUsers = ({ roomId }: { roomId: string }) => { const { presenceData, updateStatus } = usePresence(roomId, { id: currentUser.id, name: currentUser.name, }); return ( <div> <h3>Online ({presenceData.length})</h3> <ul> {presenceData.map((member) => ( <li key={member.clientId}>{member.data.name}</li> ))} </ul> </div> ); }; ``` ### Connection State ```typescript import { useConnectionStateListener } from 'ably/react'; const ConnectionStatus = () => { const [status, setStatus] = useState('connecting'); useConnectionStateListener((stateChange) => { setStatus(stateChange.current); }); return ( <div className={status === 'connected' ? 'text-green-500' : 'text-yellow-500'}> {status} </div> ); }; ``` --- ## SmartLinks Integration ### Channel Naming Conventions Use consistent channel names that include SmartLinks context: | Scope | Pattern | Example | |-------|---------|---------| | Collection | `collection:${collectionId}:${feature}` | `collection:abc123:updates` | | Product | `product:${collectionId}:${productId}:${feature}` | `product:abc123:prod456:chat` | | Proof | `proof:${collectionId}:${productId}:${proofId}:${feature}` | `proof:abc123:prod456:prf789:activity` | ### Helper for Channel Names ```typescript // src/lib/ably-channels.ts export const channels = { collectionUpdates: (collectionId: string) => `collection:${collectionId}:updates`, productChat: (collectionId: string, productId: string) => `product:${collectionId}:${productId}:chat`, proofActivity: (collectionId: string, productId: string, proofId: string) => `proof:${collectionId}:${productId}:${proofId}:activity`, voteResults: (collectionId: string, appId: string, interactionId: string) => `votes:${collectionId}:${appId}:${interactionId}`, }; ``` ### Usage with SmartLinks Context ```typescript import { useChannel } from 'ably/react'; import { usePersistentQueryParams } from '@/hooks/usePersistentQueryParams'; import { channels } from '@/lib/ably-channels'; const LiveProductChat = () => { const { persistentQueryParams } = usePersistentQueryParams(); const { collectionId, productId } = persistentQueryParams; const channelName = channels.productChat(collectionId!, productId!); const { channel } = useChannel(channelName, (message) => { // Handle incoming chat message }); // ... }; ``` --- ## Example: Live Chat Component Full working example of a chat component: ```typescript // src/components/LiveChat.tsx import { useChannel, usePresence } from 'ably/react'; import { useState, useRef, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; interface ChatMessage { id: string; text: string; sender: string; senderName: string; timestamp: number; } interface LiveChatProps { channelName: string; userId: string; userName: string; } export const LiveChat = ({ channelName, userId, userName }: LiveChatProps) => { const [messages, setMessages] = useState<ChatMessage[]>([]); const [inputValue, setInputValue] = useState(''); const scrollRef = useRef<HTMLDivElement>(null); // Subscribe to chat messages const { channel } = useChannel(channelName, (message) => { if (message.name === 'chat') { setMessages(prev => [...prev, message.data as ChatMessage]); } }); // Track presence const { presenceData } = usePresence(channelName, { id: userId, name: userName, }); // Auto-scroll to bottom useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const sendMessage = () => { if (!inputValue.trim()) return; const message: ChatMessage = { id: `${userId}-${Date.now()}`, text: inputValue.trim(), sender: userId, senderName: userName, timestamp: Date.now(), }; channel.publish('chat', message); setInputValue(''); }; return ( <div className="flex flex-col h-[400px] border rounded-lg"> {/* Header with presence */} <div className="p-3 border-b bg-muted/50"> <div className="flex items-center gap-2"> <span className="text-sm font-medium">Live Chat</span> <span className="text-xs text-muted-foreground"> ({presenceData.length} online) </span> </div> </div> {/* Messages */} <ScrollArea className="flex-1 p-4"> <div className="space-y-4"> {messages.map((msg) => ( <div key={msg.id} className={`flex gap-2 ${ msg.sender === userId ? 'flex-row-reverse' : '' }`} > <Avatar className="h-8 w-8"> <AvatarFallback> {msg.senderName.charAt(0).toUpperCase()} </AvatarFallback> </Avatar> <div className={`rounded-lg px-3 py-2 max-w-[70%] ${ msg.sender === userId ? 'bg-primary text-primary-foreground' : 'bg-muted' }`} > <p className="text-sm">{msg.text}</p> <span className="text-xs opacity-70"> {new Date(msg.timestamp).toLocaleTimeString()} </span> </div> </div> ))} <div ref={scrollRef} /> </div> </ScrollArea> {/* Input */} <div className="p-3 border-t"> <form onSubmit={(e) => { e.preventDefault(); sendMessage(); }} className="flex gap-2" > <Input value={inputValue} onChange={(e) => setInputValue(e.target.value)} placeholder="Type a message..." className="flex-1" /> <Button type="submit" size="sm"> Send </Button> </form> </div> </div> ); }; ``` --- ## Example: Live Vote Counter Display real-time vote tallies: ```typescript // src/components/LiveVoteCounter.tsx import { useChannel } from 'ably/react'; import { useState, useEffect } from 'react'; import { Progress } from '@/components/ui/progress'; import * as SL from '@proveanything/smartlinks'; interface VoteCounts { [option: string]: number; } interface LiveVoteCounterProps { collectionId: string; appId: string; interactionId: string; options: string[]; } export const LiveVoteCounter = ({ collectionId, appId, interactionId, options, }: LiveVoteCounterProps) => { const [votes, setVotes] = useState<VoteCounts>({}); const channelName = `votes:${collectionId}:${appId}:${interactionId}`; // Fetch initial counts useEffect(() => { const fetchCounts = async () => { const counts = await SL.interactions.countsByOutcome(collectionId, { appId, interactionId, }); setVotes(counts || {}); }; fetchCounts(); }, [collectionId, appId, interactionId]); // Subscribe to real-time vote updates useChannel(channelName, (message) => { if (message.name === 'vote') { const { option } = message.data; setVotes(prev => ({ ...prev, [option]: (prev[option] || 0) + 1, })); } }); const totalVotes = Object.values(votes).reduce((sum, n) => sum + n, 0); return ( <div className="space-y-4 p-4"> <h3 className="font-semibold">Live Results</h3> {options.map((option) => { const count = votes[option] || 0; const percentage = totalVotes > 0 ? (count / totalVotes) * 100 : 0; return ( <div key={option} className="space-y-1"> <div className="flex justify-between text-sm"> <span>{option}</span> <span className="text-muted-foreground"> {count} ({percentage.toFixed(1)}%) </span> </div> <Progress value={percentage} className="h-2" /> </div> ); })} <p className="text-sm text-muted-foreground text-center"> Total votes: {totalVotes} </p> </div> ); }; ``` ### Publishing Vote Updates When a user votes, publish to the channel: ```typescript const submitVote = async (option: string) => { // Record in SmartLinks await SL.interactions.submitPublicEvent(collectionId, { appId, interactionId: 'poll', outcome: option, }); // Publish to Ably for real-time updates channel.publish('vote', { option }); }; ``` --- ## Best Practices ### 1. Clean Up Subscriptions React hooks automatically handle cleanup, but for manual subscriptions: ```typescript useEffect(() => { const channel = ably.channels.get('my-channel'); const handler = (message: Ably.Message) => { // Handle message }; channel.subscribe('event', handler); return () => { channel.unsubscribe('event', handler); }; }, []); ``` ### 2. Handle Connection States ```typescript const { channel } = useChannel('my-channel', handleMessage); useConnectionStateListener((stateChange) => { if (stateChange.current === 'disconnected') { // Show reconnecting UI } if (stateChange.current === 'connected') { // Clear reconnecting UI, maybe refetch missed messages } }); ``` ### 3. Debounce Rapid Updates For typing indicators or frequent updates: ```typescript import { useDebouncedCallback } from 'use-debounce'; const debouncedPublish = useDebouncedCallback((data) => { channel.publish('typing', data); }, 300); ``` ### 4. Message Size Limits Ably has a 64KB message size limit. For large data: - Store in SmartLinks/database - Send only the ID via Ably - Fetch full data on receive ```typescript // Publishing channel.publish('update', { recordId: '123' }); // Receiving useChannel(channelName, async (message) => { const fullData = await SL.appConfiguration.getDataItem({ collectionId, appId, itemId: message.data.recordId, }); // Use fullData }); ``` ### 5. Error Handling ```typescript try { await channel.publish('event', data); } catch (error) { if (error instanceof Ably.ErrorInfo) { console.error('Ably error:', error.code, error.message); // Handle specific error codes } } ``` --- ## Environment Variables For development, add to your local `.env`: ```env VITE_ABLY_API_KEY=your-api-key-here ``` For production, use Lovable Cloud secrets or your deployment platform's environment variables. --- ## TypeScript Types Ably provides full TypeScript support. Key types: ```typescript import Ably from 'ably'; // Message type interface CustomMessage { text: string; sender: string; timestamp: number; } // Typed channel usage const { channel } = useChannel<CustomMessage>('my-channel', (message) => { const data: CustomMessage = message.data; console.log(data.text); // Typed! }); // Presence data type interface PresenceData { id: string; name: string; status: 'active' | 'away'; } const { presenceData } = usePresence<PresenceData>(channelName, { id: userId, name: userName, status: 'active', }); ``` --- ## Troubleshooting ### "Ably is not defined" Ensure you've installed the package: ```bash npm install ably ``` ### "No API key provided" Check that `VITE_ABLY_API_KEY` is set in your environment. ### Messages not received 1. Check channel names match exactly 2. Verify connection state is 'connected' 3. Check browser console for Ably errors 4. Ensure you're subscribed before publishing ### Too many connections Each browser tab opens a new connection. In development: - Close unused tabs - Use the same client instance across components (provider pattern)