diff --git a/src/lib/components/ActivitySheet.svelte b/src/lib/components/ActivitySheet.svelte index 64078fa..c72a5e5 100644 --- a/src/lib/components/ActivitySheet.svelte +++ b/src/lib/components/ActivitySheet.svelte @@ -10,7 +10,7 @@ interface Notification { id: string; - type: 'reaction' | 'comment'; + type: 'reaction' | 'comment' | 'mention'; clipId: string; emoji: string | null; commentPreview: string | null; @@ -129,6 +129,9 @@ if (n.type === 'reaction') { return `reacted ${n.emoji} to your clip`; } + if (n.type === 'mention') { + return 'mentioned you'; + } return 'commented on your clip'; } @@ -183,7 +186,7 @@ {n.actorUsername} {description(n)}

- {#if n.type === 'comment' && n.commentPreview} + {#if (n.type === 'comment' || n.type === 'mention') && n.commentPreview}

{n.commentPreview}

{/if} {relativeTime(n.createdAt)} diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index f1899e5..8acb401 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -1,5 +1,6 @@ + +
+ {#if showDropdown} +
+ {#each filteredMembers as member, i (member.id)} + + {/each} +
+ {/if} + +
+ + + {#if singleLine} + (isFocused = true)} + onblur={() => (isFocused = false)} + onscroll={syncScroll} + /> + {:else} + + {/if} +
+
+ + diff --git a/src/lib/components/MentionText.svelte b/src/lib/components/MentionText.svelte new file mode 100644 index 0000000..4870e80 --- /dev/null +++ b/src/lib/components/MentionText.svelte @@ -0,0 +1,47 @@ + + +{#each segments as segment, i (i)} + {#if segment.type === 'mention'} + {segment.value} + {:else} + {segment.value} + {/if} +{/each} + + diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 9b4ddc4..2f3074f 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -13,6 +13,7 @@ } from '$lib/reelInteractions'; import { trackVideoTime, sendWatchPercent, flashIndicator } from '$lib/reelPlayback'; import { feedUiHidden } from '$lib/stores/uiHidden'; + import { groupMembers } from '$lib/stores/members'; import ReelVideo from './ReelVideo.svelte'; import ReelMusic from './ReelMusic.svelte'; import ActionSidebar from './ActionSidebar.svelte'; @@ -415,6 +416,7 @@ {currentUserId} {gifEnabled} autoFocus={commentsAutoFocus} + members={$groupMembers} ondismiss={() => { showComments = false; unreadOverride = 0; diff --git a/src/lib/components/settings/NotificationSettings.svelte b/src/lib/components/settings/NotificationSettings.svelte index 99cb405..4be87a4 100644 --- a/src/lib/components/settings/NotificationSettings.svelte +++ b/src/lib/components/settings/NotificationSettings.svelte @@ -93,6 +93,22 @@ +
+
+ Mentions + When someone @mentions you +
+ +
+
Daily reminder diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 07a8c1a..8eaf6be 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -3,7 +3,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import * as schema from './schema'; import { resolve } from 'path'; -import { copyFileSync, existsSync, mkdirSync } from 'fs'; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { createHash } from 'node:crypto'; import { createLogger } from '$lib/server/logger'; const dataDir = resolve(process.env.DATA_DIR || 'data'); @@ -44,8 +45,79 @@ if (existsSync(dbPath)) { } /* eslint-enable security/detect-non-literal-fs-filename */ +function isAlreadyAppliedError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : ''; + return ( + msg.includes('duplicate column name') || + msg.includes('no such column') || + msg.includes('already exists') + ); +} + +function execIdempotent(sql: string) { + for (const stmt of sql.split('--> statement-breakpoint')) { + const trimmed = stmt.trim(); + if (!trimmed) continue; + try { + sqlite.exec(trimmed); + } catch (err: unknown) { + if (isAlreadyAppliedError(err)) { + log.warn({ statement: trimmed.slice(0, 80) }, 'Statement already applied — skipping'); + } else { + throw err; + } + } + } +} + +/* eslint-disable security/detect-non-literal-fs-filename */ +function recoverPendingMigrations(folder: string) { + const journalPath = resolve(folder, 'meta/_journal.json'); + const journal = JSON.parse(readFileSync(journalPath, 'utf-8')) as { + entries: { when: number; tag: string }[]; + }; + + const lastRow = sqlite + .prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1') + .get() as { created_at: number } | undefined; + const lastAppliedAt = lastRow?.created_at ?? 0; + + for (const entry of journal.entries) { + if (entry.when <= lastAppliedAt) continue; + + const sqlFile = readFileSync(resolve(folder, `${entry.tag}.sql`), 'utf-8'); + const hash = createHash('sha256').update(sqlFile).digest('hex'); + + execIdempotent(sqlFile); + + sqlite + .prepare('INSERT INTO __drizzle_migrations ("hash", "created_at") VALUES (?, ?)') + .run(hash, entry.when); + log.info({ tag: entry.tag }, 'Migration recovered'); + } +} +/* eslint-enable security/detect-non-literal-fs-filename */ + const startMs = Date.now(); -migrate(db, { migrationsFolder }); +try { + migrate(db, { migrationsFolder }); +} catch (err) { + // If a migration file was regenerated after being applied (e.g. schema change + drizzle-kit generate), + // the journal timestamp changes but the DB already has the schema changes from the old version. + // Drizzle sees the new timestamp as unapplied and tries to re-run it, hitting "duplicate column" etc. + // Recovery: run each pending statement idempotently and record the migration. + const cause = err instanceof Error ? (err as { cause?: Error }).cause : undefined; + const isSchemaConflict = + cause instanceof Error && + 'code' in cause && + cause.code === 'SQLITE_ERROR' && + isAlreadyAppliedError(cause); + + if (!isSchemaConflict) throw err; + + log.warn({ error: cause?.message }, 'Migration conflict — schema already matches, recovering'); + recoverPendingMigrations(migrationsFolder); +} // Backfill shortcut tokens for existing groups that don't have one import { isNull, eq } from 'drizzle-orm'; diff --git a/src/lib/server/db/migrations/0020_concerned_toad_men.sql b/src/lib/server/db/migrations/0020_concerned_toad_men.sql new file mode 100644 index 0000000..9251222 --- /dev/null +++ b/src/lib/server/db/migrations/0020_concerned_toad_men.sql @@ -0,0 +1,2 @@ +ALTER TABLE `notification_preferences` ADD `mentions` integer DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `max_duration_seconds`; \ No newline at end of file diff --git a/src/lib/server/db/migrations/meta/0020_snapshot.json b/src/lib/server/db/migrations/meta/0020_snapshot.json new file mode 100644 index 0000000..bb5d0c3 --- /dev/null +++ b/src/lib/server/db/migrations/meta/0020_snapshot.json @@ -0,0 +1,1211 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9beecb53-a092-49a5-b7bf-caddbb66474c", + "prevId": "56623836-95ff-4543-9b4b-c6687d4d9d24", + "tables": { + "clips": { + "name": "clips", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_url": { + "name": "original_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_path": { + "name": "video_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'downloading'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'video'" + }, + "audio_path": { + "name": "audio_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "album_art": { + "name": "album_art", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spotify_url": { + "name": "spotify_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "apple_music_url": { + "name": "apple_music_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "youtube_music_url": { + "name": "youtube_music_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "clips_group_url": { + "name": "clips_group_url", + "columns": [ + "group_id", + "original_url" + ], + "isUnique": true + } + }, + "foreignKeys": { + "clips_group_id_groups_id_fk": { + "name": "clips_group_id_groups_id_fk", + "tableFrom": "clips", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clips_added_by_users_id_fk": { + "name": "clips_added_by_users_id_fk", + "tableFrom": "clips", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_hearts": { + "name": "comment_hearts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comment_hearts_unique": { + "name": "comment_hearts_unique", + "columns": [ + "comment_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comment_hearts_comment_id_comments_id_fk": { + "name": "comment_hearts_comment_id_comments_id_fk", + "tableFrom": "comment_hearts", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comment_hearts_user_id_users_id_fk": { + "name": "comment_hearts_user_id_users_id_fk", + "tableFrom": "comment_hearts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_views": { + "name": "comment_views", + "columns": { + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comment_views_unique": { + "name": "comment_views_unique", + "columns": [ + "clip_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comment_views_clip_id_clips_id_fk": { + "name": "comment_views_clip_id_clips_id_fk", + "tableFrom": "comment_views", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comment_views_user_id_users_id_fk": { + "name": "comment_views_user_id_users_id_fk", + "tableFrom": "comment_views", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gif_url": { + "name": "gif_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "comments_clip_id_clips_id_fk": { + "name": "comments_clip_id_clips_id_fk", + "tableFrom": "comments", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_clip_id_clips_id_fk": { + "name": "favorites_clip_id_clips_id_fk", + "tableFrom": "favorites", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_storage_mb": { + "name": "max_storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_file_size_mb": { + "name": "max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 500 + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'coral'" + }, + "download_provider": { + "name": "download_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_filter_mode": { + "name": "platform_filter_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "platform_filter_list": { + "name": "platform_filter_list", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortcut_token": { + "name": "shortcut_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortcut_url": { + "name": "shortcut_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "groups_invite_code_unique": { + "name": "groups_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + }, + "groups_shortcut_token_unique": { + "name": "groups_shortcut_token_unique", + "columns": [ + "shortcut_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "new_adds": { + "name": "new_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "reactions": { + "name": "reactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "comments": { + "name": "comments", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "mentions": { + "name": "mentions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "daily_reminder": { + "name": "daily_reminder", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_preview": { + "name": "comment_preview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created": { + "name": "notifications_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_clip_id_clips_id_fk": { + "name": "notifications_clip_id_clips_id_fk", + "tableFrom": "notifications", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keys_p256dh": { + "name": "keys_p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keys_auth": { + "name": "keys_auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reactions": { + "name": "reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reactions_unique": { + "name": "reactions_unique", + "columns": [ + "clip_id", + "user_id", + "emoji" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reactions_clip_id_clips_id_fk": { + "name": "reactions_clip_id_clips_id_fk", + "tableFrom": "reactions", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme_preference": { + "name": "theme_preference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "auto_scroll": { + "name": "auto_scroll", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "muted_by_default": { + "name": "muted_by_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "avatar_path": { + "name": "avatar_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_phone_unique": { + "name": "users_phone_unique", + "columns": [ + "phone" + ], + "isUnique": true + } + }, + "foreignKeys": { + "users_group_id_groups_id_fk": { + "name": "users_group_id_groups_id_fk", + "tableFrom": "users", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_codes": { + "name": "verification_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "verification_codes_user_id_users_id_fk": { + "name": "verification_codes_user_id_users_id_fk", + "tableFrom": "verification_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "watched": { + "name": "watched", + "columns": { + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "watch_percent": { + "name": "watch_percent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "watched_at": { + "name": "watched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "watched_clip_user": { + "name": "watched_clip_user", + "columns": [ + "clip_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "watched_clip_id_clips_id_fk": { + "name": "watched_clip_id_clips_id_fk", + "tableFrom": "watched", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "watched_user_id_users_id_fk": { + "name": "watched_user_id_users_id_fk", + "tableFrom": "watched", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json index 56490aa..ed74af1 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1772318561461, "tag": "0019_small_edwin_jarvis", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1772347099686, + "tag": "0020_concerned_toad_men", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 2bb2c00..1cbfe98 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -199,5 +199,6 @@ export const notificationPreferences = sqliteTable('notification_preferences', { newAdds: integer('new_adds', { mode: 'boolean' }).notNull().default(true), reactions: integer('reactions', { mode: 'boolean' }).notNull().default(true), comments: integer('comments', { mode: 'boolean' }).notNull().default(true), + mentions: integer('mentions', { mode: 'boolean' }).notNull().default(true), dailyReminder: integer('daily_reminder', { mode: 'boolean' }).notNull().default(false) }); diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts new file mode 100644 index 0000000..dfad705 --- /dev/null +++ b/src/lib/server/mentions.ts @@ -0,0 +1,80 @@ +import { db } from '$lib/server/db'; +import { users, notificationPreferences, notifications } from '$lib/server/db/schema'; +import { eq, and, isNull } from 'drizzle-orm'; +import { sendNotification } from '$lib/server/push'; +import { v4 as uuid } from 'uuid'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('mentions'); + +/** + * Extract @username mentions from text. + * Returns an array of unique lowercase usernames. + */ +export function extractMentions(text: string): string[] { + const regex = /@(\w+)/g; + const mentions = new Set(); + let match; + while ((match = regex.exec(text)) !== null) { + mentions.add(match[1].toLowerCase()); + } + return [...mentions]; +} + +/** + * Send mention notifications to all mentioned users. + * Checks preferences, skips self-mentions and excluded users, sends push + in-app. + */ +export async function notifyMentions(opts: { + mentionedUsernames: string[]; + actorId: string; + actorUsername: string; + clipId: string; + groupId: string; + commentPreview: string; + excludeUserIds?: string[]; +}): Promise { + if (opts.mentionedUsernames.length === 0) return; + + const excludeSet = new Set(opts.excludeUserIds ?? []); + excludeSet.add(opts.actorId); // never notify self + + // Look up all active members in the group + const groupMembers = await db.query.users.findMany({ + where: and(eq(users.groupId, opts.groupId), isNull(users.removedAt)) + }); + + // Match mentioned usernames to actual users (case-insensitive) + const mentionedSet = new Set(opts.mentionedUsernames); + const matchedUsers = groupMembers.filter( + (m) => mentionedSet.has(m.username.toLowerCase()) && !excludeSet.has(m.id) + ); + + for (const recipient of matchedUsers) { + // Check notification preferences + const prefs = await db.query.notificationPreferences.findFirst({ + where: eq(notificationPreferences.userId, recipient.id) + }); + + // Send push if preference allows (default true if no prefs row) + if (!prefs || prefs.mentions) { + sendNotification(recipient.id, { + title: `${opts.actorUsername} mentioned you`, + body: opts.commentPreview, + url: '/', + tag: `mention-${opts.clipId}` + }).catch((err) => log.error({ err }, 'mention push notification failed')); + } + + // Insert in-app notification + await db.insert(notifications).values({ + id: uuid(), + userId: recipient.id, + type: 'mention', + clipId: opts.clipId, + actorId: opts.actorId, + commentPreview: opts.commentPreview, + createdAt: new Date() + }); + } +} diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts index 8979675..3cd8ec0 100644 --- a/src/lib/server/push.ts +++ b/src/lib/server/push.ts @@ -95,7 +95,7 @@ export async function notifyNewClip(clipId: string): Promise { export async function sendGroupNotification( groupId: string, payload: NotificationPayload, - preferenceKey: 'newAdds' | 'reactions' | 'comments' | 'dailyReminder', + preferenceKey: 'newAdds' | 'reactions' | 'comments' | 'mentions' | 'dailyReminder', excludeUserId?: string ): Promise { const groupUsers = await db.query.users.findMany({ diff --git a/src/lib/settingsApi.ts b/src/lib/settingsApi.ts index 123a533..bfa2aef 100644 --- a/src/lib/settingsApi.ts +++ b/src/lib/settingsApi.ts @@ -4,6 +4,7 @@ export type NotificationPrefs = { newAdds: boolean; reactions: boolean; comments: boolean; + mentions: boolean; dailyReminder: boolean; }; @@ -66,7 +67,7 @@ export async function saveAccentColor(key: AccentColorKey): Promise { export async function fetchNotificationPrefs(): Promise { const res = await fetch('/api/notifications/preferences'); if (res.ok) return res.json(); - return { newAdds: true, reactions: true, comments: true, dailyReminder: false }; + return { newAdds: true, reactions: true, comments: true, mentions: true, dailyReminder: false }; } export async function updateNotificationPref(key: string, value: boolean): Promise { diff --git a/src/lib/stores/members.ts b/src/lib/stores/members.ts new file mode 100644 index 0000000..56429c3 --- /dev/null +++ b/src/lib/stores/members.ts @@ -0,0 +1,18 @@ +import { writable } from 'svelte/store'; +import type { GroupMember } from '$lib/types'; + +export const groupMembers = writable([]); + +export async function fetchGroupMembers(): Promise { + try { + const res = await fetch('/api/group/members'); + if (res.ok) { + const data: { id: string; username: string; avatarPath: string | null }[] = await res.json(); + groupMembers.set( + data.map((m) => ({ id: m.id, username: m.username, avatarPath: m.avatarPath })) + ); + } + } catch { + // silently fail — members list is optional for core functionality + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 68ffcad..5460e2e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -27,6 +27,12 @@ export interface FeedClip { createdAt: string; } +export interface GroupMember { + id: string; + username: string; + avatarPath: string | null; +} + export interface ClipSummary { id: string; title: string | null; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index a82d1ce..89bf61a 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -9,6 +9,7 @@ import { globalMuted } from '$lib/stores/mute'; import { initAudioContext } from '$lib/audio/normalizer'; import { feedUiHidden } from '$lib/stores/uiHidden'; + import { fetchGroupMembers } from '$lib/stores/members'; import ActivitySheet from '$lib/components/ActivitySheet.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; import HouseIcon from 'phosphor-svelte/lib/HouseIcon'; @@ -26,6 +27,7 @@ onMount(() => { startPolling(); + fetchGroupMembers(); // Initialize mute state from user preference const user = page.data?.user; if (user) { diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 17d4fb3..c19f815 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -92,6 +92,7 @@ newAdds: true, reactions: true, comments: true, + mentions: true, dailyReminder: false }); diff --git a/src/routes/api/__tests__/notifications.test.ts b/src/routes/api/__tests__/notifications.test.ts index 4d63726..9fe0fce 100644 --- a/src/routes/api/__tests__/notifications.test.ts +++ b/src/routes/api/__tests__/notifications.test.ts @@ -130,6 +130,7 @@ describe('GET /api/notifications/preferences', () => { newAdds: true, reactions: true, comments: true, + mentions: true, dailyReminder: false }); }); diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 99c6e4c..6a5c5d3 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -31,6 +31,7 @@ import { mapUsersByIds, safeInt } from '$lib/server/api-utils'; +import { extractMentions, notifyMentions } from '$lib/server/mentions'; import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); @@ -179,11 +180,13 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } ); } - const body = await parseBody<{ url?: string; title?: string }>(request); + const body = await parseBody<{ url?: string; title?: string; message?: string }>(request); if (isResponse(body)) return body; const { url: videoUrl } = body; const title = typeof body.title === 'string' ? body.title.trim().slice(0, 500) || null : null; + const message = + typeof body.message === 'string' ? body.message.trim().slice(0, 500) || null : null; if (!videoUrl) return json({ error: 'URL required' }, { status: 400 }); @@ -266,5 +269,32 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } // Push notification is sent after download succeeds (see video/download.ts, music/download.ts) + // Auto-post message as the first comment on the clip + if (message) { + const commentId = uuid(); + await db.insert(comments).values({ + id: commentId, + clipId, + userId: user.id, + parentId: null, + text: message, + gifUrl: null, + createdAt: now + }); + + // Notify @mentioned users + const mentionedUsernames = extractMentions(message); + if (mentionedUsernames.length > 0) { + notifyMentions({ + mentionedUsernames, + actorId: user.id, + actorUsername: user.username, + clipId, + groupId: user.groupId, + commentPreview: message.slice(0, 80) + }).catch((err) => log.error({ err, clipId }, 'mention notifications failed')); + } + } + return json({ clip: { id: clipId, status: 'downloading', contentType } }, { status: 201 }); }); diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts index 111a594..e4a9792 100644 --- a/src/routes/api/clips/[id]/comments/+server.ts +++ b/src/routes/api/clips/[id]/comments/+server.ts @@ -11,6 +11,7 @@ import { mapUsersByIds, notifyClipOwner } from '$lib/server/api-utils'; +import { extractMentions, notifyMentions } from '$lib/server/mentions'; export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => { const clipId = params.id; @@ -87,13 +88,13 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => return json({ comments: formatted }); }); -/** Determine the notification recipient and dispatch. */ +/** Determine the notification recipient and dispatch. Returns recipient ID or null. */ async function dispatchCommentNotification( clipId: string, parentId: string | null, actor: { id: string; username: string }, preview: string -) { +): Promise { if (parentId) { const parentComment = await db.query.comments.findFirst({ where: eq(comments.id, parentId) @@ -111,6 +112,7 @@ async function dispatchCommentNotification( pushTag: `reply-${clipId}`, commentPreview: preview }); + return parentComment.userId; } } else { const clip = await db.query.clips.findFirst({ where: eq(clips.id, clipId) }); @@ -127,8 +129,10 @@ async function dispatchCommentNotification( pushTag: `comment-${clipId}`, commentPreview: preview }); + return clip.addedBy; } } + return null; } function isValidGiphyUrl(url: string): boolean { @@ -188,7 +192,27 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u }); const preview = hasText ? trimmed.slice(0, 80) : '[GIF]'; - await dispatchCommentNotification(clipId, body.parentId || null, user, preview); + const commentRecipientId = await dispatchCommentNotification( + clipId, + body.parentId || null, + user, + preview + ); + + // Dispatch @mention notifications (exclude user who already got comment/reply notification) + const mentionedUsernames = extractMentions(trimmed); + if (mentionedUsernames.length > 0) { + const excludeUserIds = commentRecipientId ? [commentRecipientId] : []; + notifyMentions({ + mentionedUsernames, + actorId: user.id, + actorUsername: user.username, + clipId, + groupId: user.groupId, + commentPreview: preview, + excludeUserIds + }).catch(() => {}); + } return json( { diff --git a/src/routes/api/notifications/preferences/+server.ts b/src/routes/api/notifications/preferences/+server.ts index 84e8fbd..6990d26 100644 --- a/src/routes/api/notifications/preferences/+server.ts +++ b/src/routes/api/notifications/preferences/+server.ts @@ -11,13 +11,20 @@ export const GET: RequestHandler = withAuth(async (_event, { user }) => { }); if (!prefs) { - return json({ newAdds: true, reactions: true, comments: true, dailyReminder: false }); + return json({ + newAdds: true, + reactions: true, + comments: true, + mentions: true, + dailyReminder: false + }); } return json({ newAdds: prefs.newAdds, reactions: prefs.reactions, comments: prefs.comments, + mentions: prefs.mentions, dailyReminder: prefs.dailyReminder }); }); @@ -26,7 +33,7 @@ export const PATCH: RequestHandler = withAuth(async ({ request }, { user }) => { const body = await parseBody>(request); if (isResponse(body)) return body; - const allowed = ['newAdds', 'reactions', 'comments', 'dailyReminder'] as const; + const allowed = ['newAdds', 'reactions', 'comments', 'mentions', 'dailyReminder'] as const; const updates: Record = {}; for (const key of allowed) { @@ -52,6 +59,7 @@ export const PATCH: RequestHandler = withAuth(async ({ request }, { user }) => { newAdds: updated!.newAdds, reactions: updated!.reactions, comments: updated!.comments, + mentions: updated!.mentions, dailyReminder: updated!.dailyReminder }); });