Presence System
The presence system tracks which users are actively viewing or editing show notes sections in real-time.
Architecture
Presence Store
Location: src/lib/stores/presence.svelte.ts
The presence store is a Svelte 5 reactive store that manages:
- Active users per section
- Current authenticated user
- Connection status
State Shape
interface PresenceState {
/** Map of section IDs to their active users */
sections: Map<string, SectionPresence>;
/** Currently authenticated user */
currentUser: CollaborationUser | null;
/** Connection status */
status: 'connecting' | 'connected' | 'disconnected';
}
interface SectionPresence {
sectionId: string;
users: CollaborationUser[];
lastUpdate: Date;
}Usage
import { presenceStore, getInitials, getContrastColor } from '$lib/stores/presence.svelte';
// Get users in a specific section
const sectionUsers = presenceStore.getSectionUsers(sectionId);
// Get all active users (deduplicated across sections)
const allUsers = presenceStore.getAllActiveUsers();
// Check if other users are editing a section
if (presenceStore.hasOtherEditors(sectionId)) {
// Show "2 others editing" indicator
}
// Get count of other users (excluding current user)
const otherCount = presenceStore.getOtherUsersCount(sectionId);Methods
| Method | Returns | Description |
|---|---|---|
getSectionUsers(sectionId) | CollaborationUser[] | Users in a section |
getAllActiveUsers() | CollaborationUser[] | All users, deduplicated |
getOtherUsersCount(sectionId) | number | Other users in section |
hasOtherEditors(sectionId) | boolean | Whether others are editing |
setCurrentUser(user) | void | Set authenticated user |
setStatus(status) | void | Update connection status |
updateSectionPresence(id, users) | void | Replace the section roster |
removeSectionPresence(id) | void | Clear section tracking |
addUserToSection(id, user) | void | Add single user |
removeUserFromSection(id, userId) | void | Remove single user |
reset() | void | Clear all state |
Utility Functions
getInitials(name: string): string
Generates initials from a user's name for avatar display.
getInitials('Dan Maby'); // 'DM'
getInitials('Jane'); // 'JA'
getInitials('John Doe Smith'); // 'JD' (first + second word)getContrastColor(hexColor: string): string
Returns black or white text color for optimal contrast against a background.
getContrastColor('#FF6B6B'); // '#FFFFFF' (white text on red)
getContrastColor('#FFEAA7'); // '#000000' (black text on yellow)UI Components
Presence Indicator
Display active users with color-coded avatars:
<script lang="ts">
import { presenceStore, getInitials, getContrastColor } from '$lib/stores/presence.svelte';
let { sectionId }: { sectionId: string } = $props();
let users = $derived(presenceStore.getSectionUsers(sectionId));
</script>
<div class="flex -space-x-2">
{#each users as user}
<div
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium"
style="background-color: {user.color}; color: {getContrastColor(user.color)}"
title={user.name}
>
{getInitials(user.name)}
</div>
{/each}
</div>Section Header with Presence
<script lang="ts">
import { presenceStore } from '$lib/stores/presence.svelte';
let { sectionId, title }: { sectionId: string; title: string } = $props();
let otherEditors = $derived(presenceStore.getOtherUsersCount(sectionId));
</script>
<div class="flex items-center justify-between">
<h3>{title}</h3>
{#if otherEditors > 0}
<span class="text-xs text-muted-foreground">
{otherEditors}
{otherEditors === 1 ? 'other' : 'others'} editing
</span>
{/if}
</div>Integration with Collaboration Provider
The SupabaseRealtimeProvider automatically syncs with the presence store:
// On connect
presenceStore.setCurrentUser(this.user);
presenceStore.setStatus('connected');
// On users change (from Supabase Presence)
presenceStore.updateSectionPresence(this.sectionId, deduplicatedUsers);
// On disconnect/destroy
presenceStore.removeSectionPresence(this.sectionId);Presence is not cursors
The avatar stacks and "Currently editing" dots on this page come from presenceStore, keyed by section. Remote carets are a separate path: dedicated cursor-update broadcasts rendered by CustomCursorExtension, with their own rules (see Collaboration Provider):
- Cursor render state is per editor, never module-level. A page mounts one editor per section.
- The local caret is broadcast only while its editor has focus, and retracted on blur, so an unfocused or hidden editor never advertises a position it does not really hold.
Database Presence Table
For persistent presence tracking (optional), the show_note_presence table stores:
| Column | Purpose |
|---|---|
show_notes_id | Which document |
user_id / guest_id | Who is present |
name | Display name |
color | Cursor color |
current_section_id | Active section |
cursor_position | Selection range |
last_seen_at | Heartbeat |
The database table provides:
- Fallback when Realtime Presence API isn't suitable
- Persistence for analytics/audit
- Cross-device presence tracking
Note: Primary presence is tracked via Supabase Realtime Presence API, which is more real-time. The database table is supplementary.
Color Generation
User colors are generated deterministically based on user ID using generateUserColor():
const colors = [
'#FF6B6B', // Red
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#96CEB4', // Green
'#FFEAA7', // Yellow
'#DDA0DD', // Plum
'#98D8C8', // Mint
'#F7DC6F', // Gold
'#BB8FCE', // Purple
'#85C1E9' // Sky Blue
];
// Hash user ID to get consistent color index
let hash = 0;
for (let i = 0; i < userId.length; i++) {
const char = userId.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return colors[Math.abs(hash) % colors.length];Benefits:
- Same user always gets same color (deterministic)
- No color assignment state needed
- Works across sessions and devices
Roster Reconciliation
updateSectionPresence replaces a section's roster, it does not merge into it. Callers always pass the complete current set of users, so anyone absent from the incoming list has left and must disappear from "Currently editing". The store previously merged, which left departed users on screen until the page was reloaded.
Avatar URLs are the one thing carried across an update: Supabase presence payloads do not always include one, so a known avatar for a user who is still present is kept.
// Previous roster, consulted only to carry avatarUrl forward
const previous = new Map(existing?.users.map((u) => [u.id, u]) ?? []);
const userMap = new Map<string, CollaborationUser>();
for (const user of users) {
const known = userMap.get(user.id) ?? previous.get(user.id);
userMap.set(user.id, {
...user,
avatarUrl: user.avatarUrl || known?.avatarUrl
});
}A user who leaves and returns without an avatar comes back without one: their old avatar is not resurrected, because they were dropped from the roster in between.
User Deduplication
When collecting users from multiple sources (current user plus every section's roster), getAllActiveUsers() deduplicates by ID and preserves whichever source carries an avatar:
const userMap = new Map<string, CollaborationUser>();
for (const user of allUsers) {
const existing = userMap.get(user.id);
if (!existing) {
userMap.set(user.id, user);
} else {
userMap.set(user.id, {
...existing,
...user,
avatarUrl: user.avatarUrl || existing.avatarUrl
});
}
}