Core Features

    Real-Time Messaging

    Ably real-time messaging for live chat, polls, presence, and collaborative features.

    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:

    npm install ably
    

    This adds the Ably SDK with React hooks support.


    Quick Start

    1. Create the Ably Client

    // 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:

    // 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

    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:

    // 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:

    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:

    // 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:

    FunctionUse CaseParameters
    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:

    // 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

    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

    const { channel } = useChannel('my-channel', handleMessage);
    
    const sendMessage = (text: string) => {
      channel.publish('message', { 
        text,
        timestamp: Date.now(),
        sender: userId 
      });
    };
    

    Presence (Who's Online)

    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

    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:

    ScopePatternExample
    Collectioncollection:${collectionId}:${feature}collection:abc123:updates
    Productproduct:${collectionId}:${productId}:${feature}product:abc123:prod456:chat
    Proofproof:${collectionId}:${productId}:${proofId}:${feature}proof:abc123:prod456:prf789:activity

    Helper for Channel Names

    // 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

    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:

    // 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:

    // 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:

    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:

    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

    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:

    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
    // 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

    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:

    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:

    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:

    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)