diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts index 13e50bb..7c9e6c1 100644 --- a/packages/db-types/src/index.ts +++ b/packages/db-types/src/index.ts @@ -582,6 +582,36 @@ export type Database = { } Relationships: [] } + conversation_watch_sessions: { + Row: { + id: string; + conversation_id: string; + owner_user_id: string; + video_id: string; + started_at: string; + ended_at: string | null; + current_state: { playing: boolean; position_seconds: number; updated_at_ms: number }; + }; + Insert: { + id?: string; + conversation_id: string; + owner_user_id: string; + video_id: string; + started_at?: string; + ended_at?: string | null; + current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number }; + }; + Update: { + id?: string; + conversation_id?: string; + owner_user_id?: string; + video_id?: string; + started_at?: string; + ended_at?: string | null; + current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number }; + }; + Relationships: []; + }; } Views: { [_ in never]: never diff --git a/packages/shared/src/chat/attachments.ts b/packages/shared/src/chat/attachments.ts index 816a875..6aa6f64 100644 --- a/packages/shared/src/chat/attachments.ts +++ b/packages/shared/src/chat/attachments.ts @@ -82,11 +82,18 @@ export interface WhiteboardPayload { whiteboard_id: string; } +export interface WatchTogetherPayload { + v: 1; + type: 'watch_together'; + session_id: string; +} + export type MessagePayload = | TextMessagePayload | CallEventPayload | PollPayload - | WhiteboardPayload; + | WhiteboardPayload + | WatchTogetherPayload; export type ParsedMessagePayload = | { @@ -109,6 +116,10 @@ export type ParsedMessagePayload = | { kind: 'whiteboard'; whiteboardId: string; + } + | { + kind: 'watch_together'; + sessionId: string; }; export function serializeMessagePayload(payload: MessagePayload): string { @@ -172,6 +183,13 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload { : ''; return { kind: 'whiteboard', whiteboardId: id }; } + if (obj.type === 'watch_together') { + const p = obj as Partial; + const id = typeof p.session_id === 'string' && p.session_id.length > 0 + ? p.session_id + : ''; + return { kind: 'watch_together', sessionId: id }; + } const t = obj as TextMessagePayload; return { kind: 'text', diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 1bedd9f..b58ae92 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -12,6 +12,7 @@ export * from './mentions'; export * from './viewOnceAttachments'; export * from './whiteboards'; export * from './soundboards'; +export * from './watchTogether'; // ----- RPC wrappers --------------------------------------------------------- diff --git a/packages/shared/src/chat/watchTogether.test.ts b/packages/shared/src/chat/watchTogether.test.ts new file mode 100644 index 0000000..55a874e --- /dev/null +++ b/packages/shared/src/chat/watchTogether.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createWatchSession, + getWatchSession, + parseYouTubeUrl, + updateWatchSessionState, +} from './watchTogether'; + +describe('parseYouTubeUrl', () => { + it.each([ + ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42', 'dQw4w9WgXcQ'], + ['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://youtu.be/dQw4w9WgXcQ?t=1', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/shorts/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://m.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ])('extracts the id from %s', (url, expected) => { + expect(parseYouTubeUrl(url)).toBe(expected); + }); + + it.each([ + '', + ' ', + 'https://vimeo.com/123', + 'not a url', + 'short_id', + 'https://www.youtube.com/playlist?list=PL123', + ])('returns null for %s', (input) => { + expect(parseYouTubeUrl(input)).toBeNull(); + }); +}); + +function makeClient(opts: { + user?: { id: string } | null; + insertReturn?: { data: unknown; error: unknown }; + selectReturn?: { data: unknown; error: unknown }; + updateReturn?: { error: unknown }; +}): any { + const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null }); + const insertSelect = vi.fn().mockReturnValue({ single }); + const insertChain = vi.fn().mockReturnValue({ select: insertSelect }); + const maybeSingle = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: null, error: null }); + const eqSelect = vi.fn().mockReturnValue({ maybeSingle }); + const selectChain = vi.fn().mockReturnValue({ eq: eqSelect }); + const eqUpdate = vi.fn().mockResolvedValue(opts.updateReturn ?? { error: null }); + const updateChain = vi.fn().mockReturnValue({ eq: eqUpdate }); + const from = vi.fn().mockReturnValue({ + insert: insertChain, + select: selectChain, + update: updateChain, + }); + return { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) }, + from, + }; +} + +describe('createWatchSession', () => { + it('inserts row + maps response to camelCase', async () => { + const client = makeClient({ + insertReturn: { + data: { + id: 'w-1', + conversation_id: 'c-1', + owner_user_id: 'u-1', + video_id: 'dQw4w9WgXcQ', + started_at: '2026-05-16T00:00:00Z', + ended_at: null, + current_state: { playing: false, position_seconds: 0, updated_at_ms: 0 }, + }, + error: null, + }, + }); + const out = await createWatchSession(client, { conversationId: 'c-1', videoId: 'dQw4w9WgXcQ' }); + expect(out.id).toBe('w-1'); + expect(out.ownerUserId).toBe('u-1'); + expect(out.videoId).toBe('dQw4w9WgXcQ'); + expect(out.endedAt).toBeNull(); + expect(out.currentState.playing).toBe(false); + }); +}); + +describe('getWatchSession', () => { + it('returns null when row not found', async () => { + const client = makeClient({ selectReturn: { data: null, error: null } }); + const out = await getWatchSession(client, 'w-missing'); + expect(out).toBeNull(); + }); + + it('coerces missing current_state fields to safe defaults', async () => { + const client = makeClient({ + selectReturn: { + data: { + id: 'w-1', + conversation_id: 'c-1', + owner_user_id: 'u-1', + video_id: 'dQw4w9WgXcQ', + started_at: '2026-05-16T00:00:00Z', + ended_at: null, + current_state: {}, + }, + error: null, + }, + }); + const out = await getWatchSession(client, 'w-1'); + expect(out?.currentState).toEqual({ playing: false, positionSeconds: 0, updatedAtMs: 0 }); + }); +}); + +describe('updateWatchSessionState', () => { + it('does not throw on success', async () => { + const client = makeClient({ updateReturn: { error: null } }); + await expect( + updateWatchSessionState(client, 'w-1', { + playing: true, + positionSeconds: 42.5, + updatedAtMs: Date.now(), + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/shared/src/chat/watchTogether.ts b/packages/shared/src/chat/watchTogether.ts new file mode 100644 index 0000000..5d4182a --- /dev/null +++ b/packages/shared/src/chat/watchTogether.ts @@ -0,0 +1,124 @@ +import type { AppSupabaseClient } from '../supabase/client'; + +export interface WatchSessionState { + playing: boolean; + positionSeconds: number; + updatedAtMs: number; +} + +export interface WatchSession { + id: string; + conversationId: string; + ownerUserId: string; + videoId: string; + startedAt: string; + endedAt: string | null; + currentState: WatchSessionState; +} + +export function parseYouTubeUrl(input: string): string | null { + const s = input.trim(); + if (!s) return null; + if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s; + const patterns = [ + /[?&]v=([A-Za-z0-9_-]{11})/, + /youtu\.be\/([A-Za-z0-9_-]{11})/, + /youtube\.com\/embed\/([A-Za-z0-9_-]{11})/, + /youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/, + ]; + for (const re of patterns) { + const m = re.exec(s); + if (m && m[1]) return m[1]; + } + return null; +} + +export async function createWatchSession( + client: AppSupabaseClient, + params: { conversationId: string; videoId: string }, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('conversation_watch_sessions') + .insert({ + conversation_id: params.conversationId, + owner_user_id: session.user.id, + video_id: params.videoId, + }) + .select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state') + .single(); + if (error) throw error; + return mapRow(data); +} + +export async function getWatchSession( + client: AppSupabaseClient, + sessionId: string, +): Promise { + const { data, error } = await client + .from('conversation_watch_sessions') + .select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state') + .eq('id', sessionId) + .maybeSingle(); + if (error) throw error; + return data ? mapRow(data) : null; +} + +export async function updateWatchSessionState( + client: AppSupabaseClient, + sessionId: string, + state: WatchSessionState, +): Promise { + const { error } = await client + .from('conversation_watch_sessions') + .update({ + current_state: { + playing: state.playing, + position_seconds: state.positionSeconds, + updated_at_ms: state.updatedAtMs, + }, + }) + .eq('id', sessionId); + if (error) throw error; +} + +export async function endWatchSession( + client: AppSupabaseClient, + sessionId: string, +): Promise { + const { error } = await client + .from('conversation_watch_sessions') + .update({ ended_at: new Date().toISOString() }) + .eq('id', sessionId); + if (error) throw error; +} + +function mapRow(row: { + id: string; + conversation_id: string; + owner_user_id: string; + video_id: string; + started_at: string; + ended_at: string | null; + current_state: unknown; +}): WatchSession { + const raw = (row.current_state ?? {}) as Partial<{ + playing: boolean; + position_seconds: number; + updated_at_ms: number; + }>; + return { + id: row.id, + conversationId: row.conversation_id, + ownerUserId: row.owner_user_id, + videoId: row.video_id, + startedAt: row.started_at, + endedAt: row.ended_at, + currentState: { + playing: typeof raw.playing === 'boolean' ? raw.playing : false, + positionSeconds: typeof raw.position_seconds === 'number' ? raw.position_seconds : 0, + updatedAtMs: typeof raw.updated_at_ms === 'number' ? raw.updated_at_ms : 0, + }, + }; +}