diff --git a/src/hooks.server.ts b/src/hooks.server.ts index d4ce477..ad3582a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,9 +4,11 @@ import { getAccentColor } from '$lib/colors'; import { startScheduler } from '$lib/server/scheduler'; import { createLogger } from '$lib/server/logger'; import { checkRateLimit, rateLimitResponse } from '$lib/server/rate-limit'; +import { env } from '$env/dynamic/private'; const log = createLogger('http'); const verboseRequests = process.env.VERBOSE_REQUESTS === 'true'; +const secureSuffix = env.NODE_ENV === 'production' ? ';Secure' : ''; startScheduler(); @@ -92,7 +94,7 @@ function setThemeCookies(event: RequestEvent, response: Response): void { if (event.locals.user?.themePreference && !cookies.includes('scrolly_theme=')) { response.headers.append( 'Set-Cookie', - `scrolly_theme=${event.locals.user.themePreference};Path=/;Max-Age=31536000;SameSite=Lax;Secure` + `scrolly_theme=${event.locals.user.themePreference};Path=/;Max-Age=31536000;SameSite=Lax${secureSuffix}` ); } @@ -101,7 +103,7 @@ function setThemeCookies(event: RequestEvent, response: Response): void { const accentValue = encodeURIComponent(JSON.stringify({ hex: accent.hex, dark: accent.dark })); response.headers.append( 'Set-Cookie', - `scrolly_accent=${accentValue};Path=/;Max-Age=31536000;SameSite=Lax;Secure` + `scrolly_accent=${accentValue};Path=/;Max-Age=31536000;SameSite=Lax${secureSuffix}` ); } } diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 6405627..e531964 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -100,7 +100,6 @@ if (!holdFired) { onsave(); } - holdFired = false; } diff --git a/src/lib/components/BaseSheet.svelte b/src/lib/components/BaseSheet.svelte index 31693ea..9318230 100644 --- a/src/lib/components/BaseSheet.svelte +++ b/src/lib/components/BaseSheet.svelte @@ -11,12 +11,14 @@ title = '', sheetId = 'sheet', ondismiss, + onclose, header, children }: { title?: string; sheetId?: string; ondismiss: () => void; + onclose?: () => void; header?: Snippet; children: Snippet; } = $props(); @@ -122,7 +124,7 @@ {:else if title}
{title} -
diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 114965c..8d5f98e 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -241,6 +241,7 @@ height: calc(1.4em + 16px); -webkit-appearance: none; appearance: none; + overflow: hidden; } /* Action buttons sit to the right of the textarea, vertically centered by the flex parent. */ diff --git a/src/lib/components/CommentRow.svelte b/src/lib/components/CommentRow.svelte index 6e27275..a9a9db6 100644 --- a/src/lib/components/CommentRow.svelte +++ b/src/lib/components/CommentRow.svelte @@ -53,7 +53,12 @@ } -
+
{#if comment.avatarPath} @@ -90,7 +95,7 @@ {/if} {/if}
- {#if !isReply} + {#if onreply} {/if} {#if comment.userId !== currentUserId} diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 77fcf67..66957d2 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -1,5 +1,5 @@ -
- +
+ { + showGifPicker = false; + requestAnimationFrame(() => commentInput?.focus()); + } + : undefined} + >
{#if showGifPicker} No comments yet

{:else} - {#if reactionEvents.length > 0} + {#if groupedReactions.length > 0}
- {#each reactionEvents as r (`reaction-${r.emoji}-${r.username}-${r.createdAt}`)} -
- {r.emoji} - {r.username} reacted - {relativeTime(r.createdAt)} -
+ {#each groupedReactions as gr (gr.emoji)} + {/each}
{#if comments.length > 0} @@ -313,6 +413,12 @@ bind:editText isJustHearted={justHeartedIds.has(reply.id)} heartPopoverVisible={heartPopoverId === reply.id} + onreply={() => { + // Reply goes under the parent (no deeper nesting), + // but show the nested reply author's name in the indicator + replyingTo = { id: comment.id, username: reply.username }; + requestAnimationFrame(() => commentInput?.focus()); + }} ontoggleheart={() => toggleHeart(reply)} onstartedit={() => startEdit(reply)} onsaveedit={() => handleEdit(reply.id)} @@ -374,7 +480,6 @@ background: transparent; } .comments-sheet-wrapper :global(.base-sheet) { - height: 50vh; height: 50dvh; background: var(--_sheet-bg); /* Animate height when GIF picker opens/closes */ @@ -382,10 +487,11 @@ transform 300ms cubic-bezier(0.32, 0.72, 0, 1), height 300ms cubic-bezier(0.32, 0.72, 0, 1); } - /* Expand to fill the full visible viewport so the GIF grid is never - hidden behind the keyboard — dvh always equals the above-keyboard space */ - .comments-sheet-wrapper.gif-open :global(.base-sheet) { - height: 100vh; + /* Expand to fill the viewport when GIF picker is open or keyboard is up. + With resizes-content, dvh shrinks when the keyboard opens, so + 100dvh = the full space above the keyboard. */ + .comments-sheet-wrapper.gif-open :global(.base-sheet), + .comments-sheet-wrapper.keyboard-open :global(.base-sheet) { height: 100dvh; } /* Keep drag-to-dismiss instant while dragging */ @@ -413,37 +519,105 @@ } .reactions-section { display: flex; - flex-direction: column; - gap: var(--space-xs); + flex-wrap: wrap; + gap: var(--space-sm); padding-bottom: var(--space-xs); } - .section-divider { - height: 1px; - background: var(--bg-subtle); - margin: var(--space-sm) 0; + .reaction-pill { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: var(--bg-surface); + border: none; + border-radius: var(--radius-full); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + -webkit-user-select: none; + user-select: none; } - .reaction-event { + .pill-emoji { + font-size: 0.875rem; + line-height: 1; + } + .pill-count { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + } + .reaction-popover { + position: absolute; + top: 100%; + left: 0; + background: var(--bg-elevated); + border-radius: var(--radius-md); + padding: var(--space-xs); + z-index: 10; + margin-top: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + min-width: 120px; + animation: popover-in 200ms cubic-bezier(0.32, 0.72, 0, 1); + } + @keyframes popover-in { + from { + opacity: 0; + transform: scale(0.92); + } + to { + opacity: 1; + transform: scale(1); + } + } + .popover-row { display: flex; align-items: center; gap: var(--space-sm); - padding: var(--space-xs) 0; + padding: var(--space-xs) var(--space-sm); + animation: popover-row-in 250ms cubic-bezier(0.32, 0.72, 0, 1) both; } - .reaction-emoji { - font-size: 1.125rem; - line-height: 1; + @keyframes popover-row-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } - .reaction-text { - font-size: 0.8125rem; + .popover-avatar { + width: 24px; + height: 24px; + border-radius: var(--radius-full); + overflow: hidden; + flex-shrink: 0; + background: var(--bg-surface); + display: flex; + align-items: center; + justify-content: center; + } + .popover-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + } + .popover-avatar-initial { color: var(--text-secondary); + font-family: var(--font-display); + font-weight: 700; + font-size: 0.625rem; } - .reaction-actor { + .popover-name { + font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); + white-space: nowrap; } - .reaction-time { - font-size: 0.6875rem; - color: var(--text-muted); - margin-left: auto; + .section-divider { + height: 1px; + background: var(--bg-subtle); + margin: var(--space-sm) 0; } .replies { margin-top: var(--space-sm); diff --git a/src/lib/components/FilterBar.svelte b/src/lib/components/FilterBar.svelte index bbfb8e9..f8f3349 100644 --- a/src/lib/components/FilterBar.svelte +++ b/src/lib/components/FilterBar.svelte @@ -8,7 +8,6 @@ swipeProgress = 0, swiping = false, hidden = false, - unwatchedCount = 0, pullOffset = 0 }: { filter: FeedFilter; @@ -16,7 +15,6 @@ swipeProgress?: number; swiping?: boolean; hidden?: boolean; - unwatchedCount?: number; pullOffset?: number; } = $props(); @@ -42,8 +40,6 @@ $effect(() => { const idx = activeIndex; const progress = swipeProgress; - // eslint-disable-next-line sonarjs/void-use -- re-run when badge appears/disappears so width updates - void unwatchedCount; const base = getLabelPos(idx); if (!base) return; @@ -81,9 +77,6 @@ > {labels[i]} - {#if f === 'unwatched' && unwatchedCount > 0} - {unwatchedCount > 99 ? '99+' : unwatchedCount} - {/if} {/each} @@ -155,24 +148,6 @@ align-items: center; } - .badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - margin-left: 4px; - background: var(--accent-magenta); - color: var(--constant-white); - font-family: var(--font-body); - font-size: 0.6875rem; - font-weight: 700; - line-height: 1; - border-radius: var(--radius-full); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4); - } - .tab-indicator { position: absolute; bottom: 0; diff --git a/src/lib/components/ProgressBar.svelte b/src/lib/components/ProgressBar.svelte index d944493..7ad96ec 100644 --- a/src/lib/components/ProgressBar.svelte +++ b/src/lib/components/ProgressBar.svelte @@ -111,7 +111,7 @@ display: flex; align-items: flex-end; cursor: pointer; - padding: 12px 0 0; + padding: 28px 0 12px; touch-action: none; transition: opacity 0.3s ease; } @@ -155,11 +155,21 @@ transition: transform 0.15s ease; } + /* On touch devices, show thumb at reduced size so it's discoverable */ + .progress-bar:not(.desktop) .progress-thumb { + transform: translateY(-50%) scale(0.6); + } + .progress-bar:hover .progress-thumb, .progress-bar.scrubbing .progress-thumb { transform: translateY(-50%) scale(1); } + /* Slightly thicker track on mobile for easier grabbing */ + .progress-bar:not(.desktop) .progress-track { + height: 4px; + } + .progress-hover { height: 100%; background: rgba(255, 255, 255, 0.3); diff --git a/src/lib/components/ReactionPicker.svelte b/src/lib/components/ReactionPicker.svelte index 490b6ee..279ed65 100644 --- a/src/lib/components/ReactionPicker.svelte +++ b/src/lib/components/ReactionPicker.svelte @@ -25,10 +25,22 @@ let hoveredIndex = $state(-1); const btnEls: HTMLButtonElement[] = $state([]); - // Block feed swipe while picker is open + // Block feed swipe and page scroll while picker is open openSheet(); onDestroy(closeSheet); + $effect(() => { + function preventScroll(e: TouchEvent) { + e.preventDefault(); + } + document.addEventListener('touchmove', preventScroll, { passive: false }); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('touchmove', preventScroll); + document.body.style.overflow = ''; + }; + }); + // Animate in $effect(() => { const raf = requestAnimationFrame(() => { @@ -92,8 +104,10 @@ function handleUp(e: PointerEvent) { const idx = hitTestEmoji(e.clientX, e.clientY); if (idx >= 0) { + // Finger is on an emoji — send it onpick(BAR_EMOJIS[idx]); } else { + // Finger released outside all emojis — dismiss without reacting ondismiss(); } } diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 00597b7..dbb418f 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -239,15 +239,15 @@ $effect(() => { if (active) feedUiHidden.set(uiHidden); }); - let hasMarkedReactionsRead = $state(false); + let hasClearedReactionNotifs = $state(false); $effect(() => { - if (!active || hasMarkedReactionsRead) return; + if (!active || hasClearedReactionNotifs) return; const timer = setTimeout(() => { - hasMarkedReactionsRead = true; + hasClearedReactionNotifs = true; fetch('/api/notifications/mark-read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clipId: clip.id, type: 'reaction' }) + body: JSON.stringify({ clipId: clip.id, type: 'reaction', delete: true }) }) .then(() => fetchUnreadCount()) .catch(() => {}); @@ -465,15 +465,15 @@ const NEGATIVE_EMOJIS = new Set(['👎', '❓']); - function handlePickEmoji(emoji: string) { + async function handlePickEmoji(emoji: string) { showPicker = false; if (isOwn) return; showerEmoji = emoji; showerX = pickerX; showerY = pickerY; showShower = true; - if (!clip.reactions[emoji]?.reacted) onreaction(clip.id, emoji); - if (!clip.favorited && !NEGATIVE_EMOJIS.has(emoji)) onfavorited(clip.id); + if (!clip.reactions[emoji]?.reacted) await onreaction(clip.id, emoji); + if (emoji !== '❤️' && !clip.favorited && !NEGATIVE_EMOJIS.has(emoji)) onfavorited(clip.id); } function triggerReactionPickerHold(bx: number, by: number) { if (isOwn) return; @@ -616,7 +616,7 @@ previews={commentPreviews} onclick={(e) => { e.stopPropagation(); - commentsAutoFocus = true; + commentsAutoFocus = false; showComments = true; }} /> @@ -715,7 +715,7 @@ .top-right-row { position: absolute; top: calc(max(var(--space-md), env(safe-area-inset-top)) - 1px); - right: calc(var(--space-sm) + 40px); + right: var(--space-sm); z-index: 6; display: flex; align-items: center; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index ed1b963..088d4fc 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -37,7 +37,8 @@ export function createSessionToken(userId: string): string { export function createSessionCookie(userId: string): string { const token = sign(userId); - return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`; + const secure = env.NODE_ENV === 'production' ? ' Secure;' : ''; + return `${COOKIE_NAME}=${token}; HttpOnly;${secure} SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`; } export function getUserIdFromCookies(cookieHeader: string | null): string | null { diff --git a/src/lib/server/reactionDebounce.ts b/src/lib/server/reactionDebounce.ts new file mode 100644 index 0000000..782cc54 --- /dev/null +++ b/src/lib/server/reactionDebounce.ts @@ -0,0 +1,43 @@ +/** + * In-memory debounce for reaction notifications. + * + * When a user reacts (or changes their reaction) rapidly, we delay the + * notification dispatch by `DELAY_MS` so only the *final* reaction triggers + * a push / in-app notification. If the user toggles the reaction off within + * the window, the pending notification is cancelled entirely. + */ + +const DELAY_MS = 5_000; + +const pending = new Map(); + +/** Build a stable key for a user's reaction on a clip. */ +export function reactionKey(clipId: string, userId: string): string { + return `${clipId}:${userId}`; +} + +/** + * Schedule a reaction notification. Any previously pending notification for + * the same key is cancelled first, so only the latest reaction fires. + */ +export function scheduleReactionNotification(key: string, dispatch: () => Promise): void { + cancelReactionNotification(key); + const timer = setTimeout(() => { + pending.delete(key); + dispatch().catch(() => {}); + }, DELAY_MS); + // Prevent the timer from keeping the process alive during shutdown + timer.unref?.(); + pending.set(key, timer); +} + +/** + * Cancel a pending reaction notification (e.g. user toggled the reaction off). + */ +export function cancelReactionNotification(key: string): void { + const existing = pending.get(key); + if (existing) { + clearTimeout(existing); + pending.delete(key); + } +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index b46d8a3..cf15b9c 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -3,18 +3,15 @@ import type { Snippet } from 'svelte'; import { onMount } from 'svelte'; import { addVideoModalOpen } from '$lib/stores/addVideoModal'; - import { activitySheetOpen } from '$lib/stores/activitySheet'; import { queueSheetOpen } from '$lib/stores/queueSheet'; import { homeTapSignal } from '$lib/stores/homeTap'; import { unreadCount, startPolling, stopPolling } from '$lib/stores/notifications'; import { queueCount } from '$lib/stores/queue'; 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 { cloutChange } from '$lib/stores/cloutChange'; import { addToast } from '$lib/stores/toasts'; - import ActivitySheet from '$lib/components/ActivitySheet.svelte'; import QueueSheet from '$lib/components/QueueSheet.svelte'; import AddVideoModal from '$lib/components/AddVideoModal.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; @@ -28,10 +25,12 @@ const isFeed = $derived(page.url.pathname === '/'); const isSettings = $derived(page.url.pathname === '/settings'); const isMe = $derived(page.url.pathname === '/me'); + const isActivity = $derived(page.url.pathname === '/activity'); const pageTitle = $derived.by(() => { if (isSettings) return 'Settings'; if (isMe) return 'Me'; + if (isActivity) return 'Activity'; return ''; }); @@ -168,30 +167,10 @@
- {#if isFeed} - - - {:else} + {#if !isFeed} {/if}
@@ -209,6 +188,15 @@ Home {/if} + + + + {#if $unreadCount > 0} + {$unreadCount > 9 ? '9+' : $unreadCount} + {/if} + + Activity + + + {/each} +
+
+ {/each} + {/if} +
+ + diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 74fd464..3d21aa6 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -10,7 +10,6 @@ } from '$lib/push'; import { type AccentColorKey } from '$lib/colors'; import { globalMuted } from '$lib/stores/mute'; - import CameraIcon from 'phosphor-svelte/lib/CameraIcon'; import SortAscendingIcon from 'phosphor-svelte/lib/SortAscendingIcon'; import ShuffleIcon from 'phosphor-svelte/lib/ShuffleIcon'; import CrownIcon from 'phosphor-svelte/lib/CrownIcon'; @@ -40,9 +39,7 @@ import ShortcutManager from '$lib/components/settings/ShortcutManager.svelte'; import SharePacingPicker from '$lib/components/settings/SharePacingPicker.svelte'; import GettingStartedChecklist from '$lib/components/settings/GettingStartedChecklist.svelte'; - import UsernameEdit from '$lib/components/settings/UsernameEdit.svelte'; import SkippedClips from '$lib/components/settings/SkippedClips.svelte'; - import AvatarCropModal from '$lib/components/AvatarCropModal.svelte'; import Toggle from '$lib/components/settings/Toggle.svelte'; import SettingRow from '$lib/components/settings/SettingRow.svelte'; import ShortcutGuideSheet from '$lib/components/ShortcutGuideSheet.svelte'; @@ -58,39 +55,6 @@ let activeTab = $state<'me' | 'group'>('me'); let showShortcutGuide = $state(false); - let avatarCropImage = $state(null); - let avatarOverride = $state(undefined); - let avatarCacheBust = $state(0); - let avatarFileInput = $state(null); - const avatarPath = $derived( - avatarOverride !== undefined ? avatarOverride : (user?.avatarPath ?? null) - ); - const avatarUrl = $derived( - avatarPath ? `/api/profile/avatar/${avatarPath}?v=${avatarCacheBust}` : null - ); - - function handleAvatarFileSelect(e: Event) { - const input = e.target as HTMLInputElement; - const file = input.files?.[0]; - if (file) { - avatarCropImage = URL.createObjectURL(file); - } - // Reset so the same file can be re-selected - input.value = ''; - } - - function handleAvatarUploaded(path: string) { - avatarOverride = path; - avatarCacheBust = Date.now(); - avatarCropImage = null; - } - - async function handleRemoveAvatar() { - const res = await fetch('/api/profile/avatar', { method: 'DELETE' }); - if (res.ok) { - avatarOverride = null; - } - } let themeOverride = $state<'system' | 'light' | 'dark' | null>(null); let autoScrollOverride = $state(null); let mutedByDefaultOverride = $state(null); @@ -231,36 +195,6 @@ {#if activeTab === 'me'}
-
- - - {#if avatarPath} - - {/if} - - {user?.phone} - {#if group} - {group.name} - {/if} -
- {#if (platform === 'ios' || platform === 'macos') && group?.shortcutUrl}
{/if} - {#if avatarCropImage} - { - if (avatarCropImage) { - URL.revokeObjectURL(avatarCropImage); - avatarCropImage = null; - } - }} - onuploaded={handleAvatarUploaded} - /> - {/if} - {#if showShortcutGuide && group?.shortcutUrl} }; }); - // Format reaction events (read-only, not stored as comments) - const reactionEvents = clipReactions + // Format reaction events — deduplicate to latest reaction per user + const latestByUser = new Map(); + for (const r of clipReactions) { + const existing = latestByUser.get(r.userId); + if (!existing || r.createdAt > existing.createdAt) { + latestByUser.set(r.userId, r); + } + } + const reactionEvents = Array.from(latestByUser.values()) .map((r) => ({ emoji: r.emoji, username: usersMap.get(r.userId)?.username || 'Unknown', diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index 4631e89..d02c12a 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -21,6 +21,11 @@ import { import { sendNotification } from '$lib/server/push'; import { env } from '$env/dynamic/private'; import { ALLOWED_EMOJIS } from '$lib/server/constants'; +import { + reactionKey, + scheduleReactionNotification, + cancelReactionNotification +} from '$lib/server/reactionDebounce'; function buildAvatarIconUrl(avatarPath: string | null, username: string): string | undefined { if (!env.ORIGIN) return undefined; @@ -154,6 +159,9 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u // Remove the old reaction await db.delete(reactions).where(eq(reactions.id, existing.id)); + // Cancel any pending debounced notification for the old reaction + cancelReactionNotification(reactionKey(clipId, userId)); + // Remove the old notification entry for this reaction await db .delete(notifications) @@ -167,6 +175,8 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u ); } + const key = reactionKey(clipId, userId); + if (!sameEmoji) { // Add the new reaction (either fresh or replacing a different emoji) const reactionId = uuid(); @@ -177,7 +187,11 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u emoji, createdAt: new Date() }); - await dispatchReactionNotification(clipId, emoji, user); + // Debounce notification — only fires after 5s of stability + scheduleReactionNotification(key, () => dispatchReactionNotification(clipId, emoji, user)); + } else { + // Toggled off — cancel any pending notification + cancelReactionNotification(key); } // Return updated reactions for this clip diff --git a/src/routes/api/notifications/mark-read/+server.ts b/src/routes/api/notifications/mark-read/+server.ts index 05145f5..a9a6e66 100644 --- a/src/routes/api/notifications/mark-read/+server.ts +++ b/src/routes/api/notifications/mark-read/+server.ts @@ -6,7 +6,12 @@ import { eq, and, isNull } from 'drizzle-orm'; import { withAuth, parseBody, isResponse } from '$lib/server/api-utils'; export const POST: RequestHandler = withAuth(async ({ request }, { user }) => { - const body = await parseBody<{ all?: boolean; clipId?: string; type?: string }>(request); + const body = await parseBody<{ + all?: boolean; + clipId?: string; + type?: string; + delete?: boolean; + }>(request); if (isResponse(body)) return body; const now = new Date(); @@ -17,17 +22,21 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user }) => { .set({ readAt: now }) .where(and(eq(notifications.userId, user.id), isNull(notifications.readAt))); } else if (body.clipId && body.type) { - await db - .update(notifications) - .set({ readAt: now }) - .where( - and( - eq(notifications.userId, user.id), - eq(notifications.clipId, body.clipId), - eq(notifications.type, body.type), - isNull(notifications.readAt) - ) - ); + const condition = and( + eq(notifications.userId, user.id), + eq(notifications.clipId, body.clipId), + eq(notifications.type, body.type) + ); + + if (body.delete) { + // Delete matching notifications instead of marking read + await db.delete(notifications).where(condition); + } else { + await db + .update(notifications) + .set({ readAt: now }) + .where(and(condition, isNull(notifications.readAt))); + } } else { return json({ error: 'Provide either { all: true } or { clipId, type }' }, { status: 400 }); }