Chat Service Layer
The chat service layer consists of one service module and three Svelte 5 reactive stores.
episode-chat-service.ts
Location: src/lib/services/episode-chat-service.ts
Core business logic for all messaging operations. Handles both authenticated user and guest portal flows.
Types
interface ChatMessage {
id: string;
episodeId: string;
userId: string | null;
guestId: string | null;
messageType: 'chat' | 'activity' | 'note';
senderName: string;
senderAvatarUrl: string | null;
content: string;
activityData: Record<string, unknown> | null;
isPinned: boolean;
isSystem: boolean;
createdAt: string;
editedAt: string | null;
mentions: MentionData[];
}
interface ChatParticipant {
id: string;
name: string;
roleLabel: string;
avatarUrl: string | null;
sourceType: 'team' | 'guest';
userId: string | null;
guestId: string | null;
}
interface MentionData {
id: string;
name: string;
type: 'team' | 'guest';
}
interface ReactionSummary {
emoji: string;
count: number;
userReacted: boolean;
}
interface PresenceUser {
userId?: string;
guestId?: string;
name: string;
avatarUrl?: string;
color: string;
}
interface TypingPayload {
userId?: string;
guestId?: string;
senderName: string;
isTyping: boolean;
}
interface ReactionUpdatePayload {
messageId: string;
emoji: string;
userId?: string;
guestId?: string;
senderName: string;
action: 'add' | 'remove';
}Constants
const MAX_MESSAGE_LENGTH = 5000;Functions
Message Operations
| Function | Signature | Description |
|---|---|---|
fetchMessages | (supabase, episodeId, options?) | Fetch paginated messages. Uses RPC get_guest_episode_messages for guests, direct query for authenticated users. |
sendMessage | (supabase, params) | Send a message with optional mentions. Uses RPC guest_send_message for guests. Returns the created ChatMessage. |
deleteMessage | (supabase, params) | Delete a message. Uses RPC guest_delete_message for guests, direct .delete() for authenticated users. |
validateMessageContent | (content) | Validates content length and non-empty. Throws descriptive errors. |
Reaction Operations
| Function | Signature | Description |
|---|---|---|
fetchReactions | (supabase, messageIds, options) | Fetch reactions for multiple messages. Returns Map<string, ReactionSummary[]>. Uses RPC guest_fetch_reactions for guests. |
toggleReaction | (supabase, params) | Add or remove a reaction. Uses RPC guest_toggle_reaction for guests. |
Broadcast Operations
| Function | Signature | Description |
|---|---|---|
broadcastMessage | (supabase, episodeId, message) | Broadcast a new message to all channel subscribers. |
broadcastReaction | (supabase, episodeId, payload) | Broadcast a reaction update. |
broadcastTyping | (supabase, episodeId, payload) | Broadcast typing indicator state. |
broadcastMessageDeleted | (supabase, episodeId, messageId) | Broadcast message deletion. |
Subscription
| Function | Signature | Description |
|---|---|---|
subscribeToMessages | (supabase, episodeId, onMessage, options) | Subscribe to new messages via postgres_changes (authenticated) + broadcasts (all). Returns RealtimeChannel. |
The options parameter accepts:
onReactionUpdate: (payload) => voidonTyping: (payload) => voidonMessageDeleted: (messageId) => voidpresenceUser: PresenceUser- tracks presence on the channelonPresenceSync: (users) => voidaccessToken: string- for guest portal
Read Tracking
| Function | Signature | Description |
|---|---|---|
updateLastRead | (supabase, episodeId) | Upsert last-read timestamp for authenticated users. |
fetchLastRead | (supabase, episodeId) | Fetch last-read timestamp for unread counting. |
Dual Auth Handling
Every function that accesses the database checks for guest context:
// Pattern used throughout the service
if (options?.accessToken && options?.guestId) {
// Use SECURITY DEFINER RPC (bypasses RLS)
const { data } = await supabase.rpc('guest_send_message', {
p_episode_id: episodeId,
p_access_token: accessToken,
p_content: content,
p_mentions: mentions
});
} else {
// Direct DB access (RLS enforced)
const { data } = await supabase
.from('episode_messages')
.insert({ episode_id: episodeId, content, mentions, ... });
}Rate Limiting
- Authenticated users: Database trigger
enforce_chat_message_rate_limitonepisode_messages - Guests: Rate check inside
guest_send_messageRPC - Error format:
"RATE_LIMITED retry_after=60"- parsed by the service and surfaced as"Too many messages. Try again in Xs."
Deduplication
The subscription handler maintains a seenMessageIds Set to prevent duplicate messages from appearing when both postgres_changes and broadcast events fire for the same message.
Stores
chatStore
Location: src/lib/stores/chat.svelte.ts
Module-level reactive state for messages and reactions.
State
| Property | Type | Description |
|---|---|---|
messages | ChatMessage[] | All loaded messages |
isLoading | boolean | Loading state for fetch operations |
hasMore | boolean | Whether more messages exist for pagination |
unreadCount | number | Unread message count |
currentTab | 'chat' | 'people' | Active sidebar tab |
Derived Getters
| Getter | Returns | Description |
|---|---|---|
chatMessages | ChatMessage[] | Messages where messageType === 'chat' |
activityMessages | ChatMessage[] | Messages where messageType === 'activity' |
Methods
| Method | Description |
|---|---|
setMessages(msgs) | Replace all messages |
addMessage(msg) | Append message (deduplicates by ID) |
removeMessage(id) | Remove message, returns index + reactions for rollback |
restoreMessage(msg, index, reactions) | Restore an optimistically-deleted message |
prependMessages(msgs) | Prepend older messages (for infinite scroll) |
markAsRead() | Reset unread count, call persist callback |
setPersistCallback(fn) | Set function to persist read state to DB |
getReactions(messageId) | Get reactions for a message |
setReactions(messageId, reactions) | Set reactions for a message |
updateReaction(messageId, emoji, userReacted) | Toggle a specific reaction |
reset() | Clear all state |
Reactions Storage
Reactions are stored in a SvelteMap<string, ReactionSummary[]> keyed by message ID, ensuring reactive updates.
typingStore
Location: src/lib/stores/typing.svelte.ts
Tracks who is currently typing with automatic expiry.
State
| Property | Type | Description |
|---|---|---|
typingUsers | SvelteMap<string, { name, timestamp }> | Currently typing users |
typingText | string | Formatted display text |
isAnyoneTyping | boolean | Whether any users are typing |
Behavior
setTyping(id, name)adds a user with current timestamp- Auto-cleanup interval runs while anyone is typing
- Entries older than 3 seconds are automatically removed
typingTextgenerates grammatically correct output:"Alice is typing...""Alice and Bob are typing...""3 people are typing..."
chatPresenceStore
Location: src/lib/stores/chat-presence.svelte.ts
Tracks online users in the chat channel.
State
| Property | Type | Description |
|---|---|---|
onlineUsers | SvelteMap<string, PresenceUser> | Currently online users |
onlineCount | number | Count of online users |
onlineList | PresenceUser[] | Array of online users for UI iteration |
Methods
| Method | Description |
|---|---|
updatePresence(users) | Replace all presence data from Realtime sync |
reset() | Clear all presence data |