Skip to content

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

typescript
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

typescript
const MAX_MESSAGE_LENGTH = 5000;

Functions

Message Operations

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
subscribeToMessages(supabase, episodeId, onMessage, options)Subscribe to new messages via postgres_changes (authenticated) + broadcasts (all). Returns RealtimeChannel.

The options parameter accepts:

  • onReactionUpdate: (payload) => void
  • onTyping: (payload) => void
  • onMessageDeleted: (messageId) => void
  • presenceUser: PresenceUser - tracks presence on the channel
  • onPresenceSync: (users) => void
  • accessToken: string - for guest portal

Read Tracking

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

typescript
// 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_limit on episode_messages
  • Guests: Rate check inside guest_send_message RPC
  • 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

PropertyTypeDescription
messagesChatMessage[]All loaded messages
isLoadingbooleanLoading state for fetch operations
hasMorebooleanWhether more messages exist for pagination
unreadCountnumberUnread message count
currentTab'chat' | 'people'Active sidebar tab

Derived Getters

GetterReturnsDescription
chatMessagesChatMessage[]Messages where messageType === 'chat'
activityMessagesChatMessage[]Messages where messageType === 'activity'

Methods

MethodDescription
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

PropertyTypeDescription
typingUsersSvelteMap<string, { name, timestamp }>Currently typing users
typingTextstringFormatted display text
isAnyoneTypingbooleanWhether 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
  • typingText generates 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

PropertyTypeDescription
onlineUsersSvelteMap<string, PresenceUser>Currently online users
onlineCountnumberCount of online users
onlineListPresenceUser[]Array of online users for UI iteration

Methods

MethodDescription
updatePresence(users)Replace all presence data from Realtime sync
reset()Clear all presence data

Internal documentation - Not for public distribution