Skip to content

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

typescript
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

typescript
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

MethodReturnsDescription
getSectionUsers(sectionId)CollaborationUser[]Users in a section
getAllActiveUsers()CollaborationUser[]All users, deduplicated
getOtherUsersCount(sectionId)numberOther users in section
hasOtherEditors(sectionId)booleanWhether others are editing
setCurrentUser(user)voidSet authenticated user
setStatus(status)voidUpdate connection status
updateSectionPresence(id, users)voidReplace the section roster
removeSectionPresence(id)voidClear section tracking
addUserToSection(id, user)voidAdd single user
removeUserFromSection(id, userId)voidRemove single user
reset()voidClear all state

Utility Functions

getInitials(name: string): string

Generates initials from a user's name for avatar display.

typescript
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.

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

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

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

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

ColumnPurpose
show_notes_idWhich document
user_id / guest_idWho is present
nameDisplay name
colorCursor color
current_section_idActive section
cursor_positionSelection range
last_seen_atHeartbeat

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():

typescript
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.

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

typescript
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
		});
	}
}

Internal documentation - Not for public distribution