feat(P5A.T2): WatchTogetherPayload + wrappers + parseYouTubeUrl + tests
This commit is contained in:
@@ -582,6 +582,36 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
@@ -82,11 +82,18 @@ export interface WhiteboardPayload {
|
|||||||
whiteboard_id: string;
|
whiteboard_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchTogetherPayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'watch_together';
|
||||||
|
session_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type MessagePayload =
|
export type MessagePayload =
|
||||||
| TextMessagePayload
|
| TextMessagePayload
|
||||||
| CallEventPayload
|
| CallEventPayload
|
||||||
| PollPayload
|
| PollPayload
|
||||||
| WhiteboardPayload;
|
| WhiteboardPayload
|
||||||
|
| WatchTogetherPayload;
|
||||||
|
|
||||||
export type ParsedMessagePayload =
|
export type ParsedMessagePayload =
|
||||||
| {
|
| {
|
||||||
@@ -109,6 +116,10 @@ export type ParsedMessagePayload =
|
|||||||
| {
|
| {
|
||||||
kind: 'whiteboard';
|
kind: 'whiteboard';
|
||||||
whiteboardId: string;
|
whiteboardId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'watch_together';
|
||||||
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||||
@@ -172,6 +183,13 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
: '';
|
: '';
|
||||||
return { kind: 'whiteboard', whiteboardId: id };
|
return { kind: 'whiteboard', whiteboardId: id };
|
||||||
}
|
}
|
||||||
|
if (obj.type === 'watch_together') {
|
||||||
|
const p = obj as Partial<WatchTogetherPayload>;
|
||||||
|
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;
|
const t = obj as TextMessagePayload;
|
||||||
return {
|
return {
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from './mentions';
|
|||||||
export * from './viewOnceAttachments';
|
export * from './viewOnceAttachments';
|
||||||
export * from './whiteboards';
|
export * from './whiteboards';
|
||||||
export * from './soundboards';
|
export * from './soundboards';
|
||||||
|
export * from './watchTogether';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<WatchSession> {
|
||||||
|
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<WatchSession | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user