From 6853a6cede4f0804a7ac89397b6cf0765fe966da Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:36:08 -0600 Subject: [PATCH 01/33] fix: hide bottom nav and suppress pull-to-refresh when any sheet is open - Add sheetOpen store that tracks the count of mounted BaseSheet instances - BaseSheet increments/decrements the count on mount/unmount - Bottom nav gets display:none via .sheet-hidden when anySheetOpen is true, eliminating the z-index stacking context conflict on iOS Safari - Feed pull-to-refresh ignores touchstart events while a sheet is open, preventing the drag-to-dismiss gesture from triggering a feed refresh - CommentsSheet auto-focus is now decoupled from the async comments fetch so the keyboard appears promptly after the sheet animation on iOS --- src/lib/components/BaseSheet.svelte | 3 +++ src/lib/components/CommentsSheet.svelte | 9 ++++++++- src/lib/stores/sheetOpen.ts | 13 +++++++++++++ src/routes/(app)/+layout.svelte | 6 ++++++ src/routes/(app)/+page.svelte | 3 +++ 5 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/lib/stores/sheetOpen.ts diff --git a/src/lib/components/BaseSheet.svelte b/src/lib/components/BaseSheet.svelte index 85383b4..6b21ba0 100644 --- a/src/lib/components/BaseSheet.svelte +++ b/src/lib/components/BaseSheet.svelte @@ -2,6 +2,7 @@ import type { Snippet } from 'svelte'; import { pushState, beforeNavigate } from '$app/navigation'; import { onDestroy } from 'svelte'; + import { openSheet, closeSheet } from '$lib/stores/sheetOpen'; let { title = '', @@ -36,6 +37,7 @@ // Animate in, lock scroll, manage history $effect(() => { + openSheet(); requestAnimationFrame(() => { visible = true; }); @@ -49,6 +51,7 @@ window.addEventListener('popstate', handlePopState); return () => { + closeSheet(); document.body.style.overflow = ''; window.removeEventListener('popstate', handlePopState); if (!closedViaBack) history.back(); diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index c459abe..8874e39 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -76,6 +76,14 @@ loadComments(); }); + // Auto-focus the input independently of data loading so iOS doesn't lose + // the gesture context by the time the fetch finishes. + $effect(() => { + if (!autoFocus) return; + const t = safeTimeout(() => commentInput?.focus(), 350); + return () => clearTimeout(t); + }); + $effect(() => { function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { @@ -97,7 +105,6 @@ reactionEvents = result.reactionEvents; loading = false; markCommentsRead(clipId); - if (autoFocus) safeTimeout(() => commentInput?.focus(), 350); } async function handleSubmit(text: string, gifUrl?: string) { diff --git a/src/lib/stores/sheetOpen.ts b/src/lib/stores/sheetOpen.ts new file mode 100644 index 0000000..73ec708 --- /dev/null +++ b/src/lib/stores/sheetOpen.ts @@ -0,0 +1,13 @@ +import { writable, derived } from 'svelte/store'; + +const _depth = writable(0); + +export const anySheetOpen = derived(_depth, ($d) => $d > 0); + +export function openSheet() { + _depth.update((n) => n + 1); +} + +export function closeSheet() { + _depth.update((n) => Math.max(0, n - 1)); +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index b1f26ba..afc7a67 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -10,6 +10,7 @@ import { initAudioContext } from '$lib/audio/normalizer'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { fetchGroupMembers } from '$lib/stores/members'; + import { anySheetOpen } from '$lib/stores/sheetOpen'; import ActivitySheet from '$lib/components/ActivitySheet.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; import HouseIcon from 'phosphor-svelte/lib/HouseIcon'; @@ -129,6 +130,7 @@ class="bottom-tabs" class:overlay-mode={isFeed} class:ui-hidden={isFeed && $feedUiHidden} + class:sheet-hidden={$anySheetOpen} bind:this={bottomTabsEl} > {#if isFeed} @@ -304,6 +306,10 @@ pointer-events: none; } + .bottom-tabs.sheet-hidden { + display: none; + } + .bottom-tabs.overlay-mode { background: linear-gradient(transparent, var(--reel-gradient-heavy)); border-top: none; diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 68e8925..2d455ed 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -14,6 +14,8 @@ import { homeTapSignal } from '$lib/stores/homeTap'; import { unwatchedCount, fetchUnwatchedCount } from '$lib/stores/notifications'; import { feedUiHidden } from '$lib/stores/uiHidden'; + import { anySheetOpen } from '$lib/stores/sheetOpen'; + import { get } from 'svelte/store'; import { onMount, onDestroy } from 'svelte'; import { page } from '$app/state'; import type { FeedClip } from '$lib/types'; @@ -211,6 +213,7 @@ const getScrollTop = () => scrollContainer?.scrollTop ?? 0; function handleTouchStart(e: TouchEvent) { + if (get(anySheetOpen)) return; if (getScrollTop() <= 0 && !isRefreshing) { touchStartY = e.touches[0].clientY; isPullingActive = true; From 7b12c93718236c873fea5e2a9a91782ed0d2a34e Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:47:16 -0600 Subject: [PATCH 02/33] fix: scroll inputs into view when iOS keyboard appears on sign-in screens --- src/routes/join/+page.svelte | 10 +++++++++- src/routes/onboard/+page.svelte | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte index 29fc981..a7e6979 100644 --- a/src/routes/join/+page.svelte +++ b/src/routes/join/+page.svelte @@ -135,6 +135,12 @@ code = input.value.replace(/\D/g, '').slice(0, 6); input.value = code; } + + function scrollToInput(e: FocusEvent) { + setTimeout(() => { + (e.target as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 350); + } @@ -175,6 +181,7 @@ value={phoneDisplay} oninput={handlePhoneInput} onkeydown={handlePhoneKeydown} + onfocus={scrollToInput} placeholder="(555) 123-4567" autocomplete="tel" disabled={loading} @@ -231,6 +238,7 @@ autocomplete="one-time-code" value={code} oninput={handleCodeInput} + onfocus={scrollToInput} disabled={loading} class="code-hidden-input" bind:this={codeInputEl} @@ -297,7 +305,7 @@ padding: var(--space-xl); padding-bottom: calc(var(--space-xl) + 72px); background: var(--bg-primary); - overflow: hidden; + overflow-y: auto; } /* --- Background grain texture --- */ diff --git a/src/routes/onboard/+page.svelte b/src/routes/onboard/+page.svelte index 89b5d1a..58a5a62 100644 --- a/src/routes/onboard/+page.svelte +++ b/src/routes/onboard/+page.svelte @@ -118,6 +118,12 @@ } } + function scrollToInput(e: FocusEvent) { + setTimeout(() => { + (e.target as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 350); + } + async function handleResend() { error = ''; loading = true; @@ -163,6 +169,7 @@ Date: Mon, 2 Mar 2026 10:53:06 -0600 Subject: [PATCH 03/33] fix: theme-aware comment sheet bg and GIF pill inside input --- src/lib/components/CommentInput.svelte | 123 ++++++++++++------------ src/lib/components/CommentsSheet.svelte | 16 ++- 2 files changed, 79 insertions(+), 60 deletions(-) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 3675467..4f75a15 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -1,6 +1,4 @@ {#if !pushSupported} -

Install scrolly to your home screen to enable push notifications.

+ {#if isIos && !isStandaloneMode} +

Install scrolly to your home screen to enable push notifications.

+ {:else} +

Push notifications aren't supported on this device or browser.

+ {/if} {:else}
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index ef994ae..cf328c8 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -340,6 +340,8 @@ {pushLoading} {prefs} {prefsLoading} + isStandaloneMode={$isStandalone} + isIos={platform === 'ios'} onTogglePush={togglePush} onUpdatePref={handleUpdatePref} /> From 94d09bcbf75d215f3289f784178922f67f4d40f9 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:31:39 -0600 Subject: [PATCH 07/33] fix: pin textarea to 1-line height so placeholder aligns with icons --- src/lib/components/CommentInput.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 2603009..89e19da 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -231,6 +231,14 @@ padding-bottom: 6px; padding-right: 38px; } + /* + * Pin the initial textarea height to exactly 1 line so UA stylesheets + * can't inflate it and push icons below the text. autoResize() overrides + * this via inline style once the user starts typing. + */ + .input-wrapper :global(textarea.overlay-input) { + height: calc(1.4em + 12px); + } /* More right space when GIF pill is also showing */ .input-wrapper.has-gif :global(.overlay-input), .input-wrapper.has-gif :global(.highlight-mirror) { From d8fffb77d0abe62472e11ce1434d50ee3a6eaf24 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:44:48 -0600 Subject: [PATCH 08/33] feat: tiktok-style layout with progress bar above comment bar move comment bar to bottom (nav+8px), progress bar above it (nav+36px), captions/sidebar above progress bar (nav+92px). music disc aligns on same row as comment bar. CommentPrompt extracted to own component. ClipOverlay baseline simplified to safe-area-only. --- src/lib/components/ActionSidebar.svelte | 13 +++-- src/lib/components/ClipOverlay.svelte | 24 +++++++-- src/lib/components/CommentPrompt.svelte | 70 +++++++++++++++++++++++++ src/lib/components/MusicDisc.svelte | 2 +- src/lib/components/ProgressBar.svelte | 2 +- src/lib/components/ReelItem.svelte | 15 ++++-- src/lib/components/ReelOverlay.svelte | 70 +------------------------ 7 files changed, 113 insertions(+), 83 deletions(-) create mode 100644 src/lib/components/CommentPrompt.svelte diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index b0a7232..9bfe5f0 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -121,9 +121,7 @@ > {#if reactedEmoji && reactedEmoji !== '❤️' && REACTION_MAP.has(reactedEmoji)} - {@const def = REACTION_MAP.get(reactedEmoji)!} - {@const ReactionIcon = def.component} - + {reactedEmoji} {:else} {/if} @@ -167,7 +165,7 @@ .action-sidebar { position: absolute; right: var(--space-lg); - bottom: calc(var(--bottom-nav-height, 64px) + 88px); + bottom: calc(var(--bottom-nav-height, 64px) + 92px); display: flex; flex-direction: column; align-items: center; @@ -221,6 +219,13 @@ filter: drop-shadow(0 1px 2px var(--reel-icon-shadow)); } + .reaction-emoji { + font-size: 22px; + line-height: 1; + user-select: none; + pointer-events: none; + } + .sidebar-btn.active .icon-circle { background: color-mix(in srgb, var(--accent-magenta) 20%, transparent); } diff --git a/src/lib/components/ClipOverlay.svelte b/src/lib/components/ClipOverlay.svelte index 098c31d..f0e2a78 100644 --- a/src/lib/components/ClipOverlay.svelte +++ b/src/lib/components/ClipOverlay.svelte @@ -270,6 +270,9 @@ inset: 0; z-index: 40; background: var(--bg-primary); + /* No tab bar here — use just the safe area inset as the baseline. + Comment bar sits at +8px above this, progress bar at +36px, captions at +92px. */ + --bottom-nav-height: env(safe-area-inset-bottom, 0px); } .clip-overlay.animating { @@ -278,13 +281,18 @@ .overlay-top-bar { position: absolute; - top: max(var(--space-md), env(safe-area-inset-top)); - left: var(--space-lg); + top: 0; + left: 0; + right: 0; + padding-top: max(var(--space-md), env(safe-area-inset-top)); + padding-left: var(--space-lg); + padding-right: var(--space-lg); z-index: 6; display: flex; - align-items: center; - gap: var(--space-sm); - min-height: 40px; + align-items: flex-end; + justify-content: space-between; + min-height: calc(max(var(--space-md), env(safe-area-inset-top)) + 40px); + pointer-events: none; } .back-btn { @@ -301,6 +309,7 @@ color: var(--reel-text); cursor: pointer; transition: transform 0.1s ease; + pointer-events: auto; } .back-btn:active { @@ -311,6 +320,11 @@ filter: drop-shadow(0 1px 2px var(--reel-text-shadow)); } + /* ViewBadge sits in the pointer-events:none bar — re-enable for its button */ + .overlay-top-bar :global(.view-badge) { + pointer-events: auto; + } + .overlay-reel { height: 100dvh; width: 100%; diff --git a/src/lib/components/CommentPrompt.svelte b/src/lib/components/CommentPrompt.svelte new file mode 100644 index 0000000..80b9c8f --- /dev/null +++ b/src/lib/components/CommentPrompt.svelte @@ -0,0 +1,70 @@ + + + + + diff --git a/src/lib/components/MusicDisc.svelte b/src/lib/components/MusicDisc.svelte index 7958d43..ccbd2b0 100644 --- a/src/lib/components/MusicDisc.svelte +++ b/src/lib/components/MusicDisc.svelte @@ -102,7 +102,7 @@ .music-disc-area { position: absolute; right: var(--space-lg); - bottom: calc(var(--bottom-nav-height, 64px) + 28px); + bottom: calc(var(--bottom-nav-height, 64px) + 8px); z-index: 10; transition: opacity 0.3s ease; } diff --git a/src/lib/components/ProgressBar.svelte b/src/lib/components/ProgressBar.svelte index 16f7202..21aa682 100644 --- a/src/lib/components/ProgressBar.svelte +++ b/src/lib/components/ProgressBar.svelte @@ -108,7 +108,7 @@ From 01ddb9c0d61a45a6b0cea659d37e3b7f5a5d607d Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:46:47 -0600 Subject: [PATCH 09/33] fix: strip textarea native appearance, snap to 1-line height on clear --- src/lib/components/CommentInput.svelte | 3 +++ src/lib/components/MentionInput.svelte | 5 +---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 89e19da..fead0d6 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -238,6 +238,9 @@ */ .input-wrapper :global(textarea.overlay-input) { height: calc(1.4em + 12px); + /* Strip macOS/iOS native appearance so our height rule takes full control */ + -webkit-appearance: none; + appearance: none; } /* More right space when GIF pill is also showing */ .input-wrapper.has-gif :global(.overlay-input), diff --git a/src/lib/components/MentionInput.svelte b/src/lib/components/MentionInput.svelte index f8bc5b6..98bf47c 100644 --- a/src/lib/components/MentionInput.svelte +++ b/src/lib/components/MentionInput.svelte @@ -156,10 +156,7 @@ export function clear() { text = ''; onchange?.(''); - if (maxRows > 0) { - const el = inputEl as HTMLTextAreaElement | null; - if (el) el.style.height = 'auto'; - } + if (maxRows > 0) autoResize(); } export function getText(): string { From b4a56e27e719f15e5c653f1cb5c59e3f4ae50320 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:53:39 -0600 Subject: [PATCH 10/33] fix: tighten bottom ui stack and center progress bar between comment and captions --- src/lib/components/ActionSidebar.svelte | 2 +- src/lib/components/CommentPrompt.svelte | 2 +- src/lib/components/MusicDisc.svelte | 2 +- src/lib/components/ProgressBar.svelte | 2 +- src/lib/components/ReelOverlay.svelte | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 9bfe5f0..3fd9c7b 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -165,7 +165,7 @@ .action-sidebar { position: absolute; right: var(--space-lg); - bottom: calc(var(--bottom-nav-height, 64px) + 92px); + bottom: calc(var(--bottom-nav-height, 64px) + 84px); display: flex; flex-direction: column; align-items: center; diff --git a/src/lib/components/CommentPrompt.svelte b/src/lib/components/CommentPrompt.svelte index 80b9c8f..f78a4fc 100644 --- a/src/lib/components/CommentPrompt.svelte +++ b/src/lib/components/CommentPrompt.svelte @@ -26,7 +26,7 @@ diff --git a/src/lib/icons.ts b/src/lib/icons.ts index a35437d..ff9d13a 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -1,30 +1,8 @@ /** - * Shared icon configuration for reaction emoji → Phosphor component mappings. - * Used by ActionSidebar and ReactionPicker to avoid duplicating icon definitions. + * Reaction emoji list and lookup set. + * Used by ReactionPicker and ActionSidebar. */ -import type { Component } from 'svelte'; -import HeartIcon from 'phosphor-svelte/lib/HeartIcon'; -import ThumbsUpIcon from 'phosphor-svelte/lib/ThumbsUpIcon'; -import ThumbsDownIcon from 'phosphor-svelte/lib/ThumbsDownIcon'; -import SmileyXEyesIcon from 'phosphor-svelte/lib/SmileyXEyesIcon'; -import DoubleExclamationIcon from '$lib/components/DoubleExclamationIcon.svelte'; -import QuestionIcon from 'phosphor-svelte/lib/QuestionIcon'; +export const REACTIONS: string[] = ['❤️', '👍', '👎', '😂', '‼️', '❓']; -export interface ReactionDef { - emoji: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: Component; - weight: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; -} - -export const REACTIONS: ReactionDef[] = [ - { emoji: '❤️', component: HeartIcon, weight: 'fill' }, - { emoji: '👍', component: ThumbsUpIcon, weight: 'regular' }, - { emoji: '👎', component: ThumbsDownIcon, weight: 'regular' }, - { emoji: '😂', component: SmileyXEyesIcon, weight: 'regular' }, - { emoji: '‼️', component: DoubleExclamationIcon, weight: 'bold' }, - { emoji: '❓', component: QuestionIcon, weight: 'regular' } -]; - -/** Map from emoji to its Phosphor component + weight for quick lookup in ActionSidebar */ -export const REACTION_MAP = new Map(REACTIONS.map((r) => [r.emoji, r])); +/** Quick membership check — same API (.has) as before */ +export const REACTION_MAP = new Set(REACTIONS); From d30d79957bc02b457551a90cffd51f19098b18d5 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:00:29 -0600 Subject: [PATCH 13/33] feat: add reel-bg tokens and feed-context class for iOS status bar Adds --reel-bg / --reel-bg-elevated tokens (always dark regardless of theme) so empty/end states in the feed stay immersive. Applies html.feed-context on the feed route (both at hydration and via inline script at cold start) so the iOS black-translucent status bar shows the correct dark background color. --- src/app.html | 11 +++++++++++ src/routes/+layout.svelte | 26 ++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/app.html b/src/app.html index 59afad6..6d55ec1 100644 --- a/src/app.html +++ b/src/app.html @@ -38,6 +38,17 @@ } } catch(e) { /* ignore malformed cookie */ } } + // Feed route: set dark background immediately (before JS hydrates) so the iOS + // black-translucent status bar sees the correct reel-bg color on cold start. + if (window.location.pathname === '/') { + document.documentElement.classList.add('feed-context'); + var pref = m && m[1] !== 'system' ? m[1] : 'system'; + var isDark = pref === 'dark' || (pref !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); + var reelBg = isDark ? '#0d0d0d' : '#1c1c1c'; + document.querySelectorAll('meta[name="theme-color"]').forEach(function(el) { + el.setAttribute('content', reelBg); + }); + } })(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b7787f3..3df9c08 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -36,6 +36,9 @@ From e81d90f5b0c78e21a24826f05a55e3b092c76201 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:01:25 -0600 Subject: [PATCH 15/33] feat: remove favorites filter from feed, add push nudge on end-slide Favorites moved to its own dedicated route (/favorites). Removes the 'favorites' option from the feed filter bar and updates labels to New/Seen. Adds a push notification enable button on the feed end-slide for users who haven't enabled notifications yet. --- src/lib/components/FilterBar.svelte | 10 ++-- src/routes/(app)/+page.svelte | 91 ++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/lib/components/FilterBar.svelte b/src/lib/components/FilterBar.svelte index 365545b..1e8fd1d 100644 --- a/src/lib/components/FilterBar.svelte +++ b/src/lib/components/FilterBar.svelte @@ -19,8 +19,8 @@ pullOffset?: number; } = $props(); - const filters: FeedFilter[] = ['unwatched', 'watched', 'favorites']; - const labels = ['New', 'Watched', 'Faves']; + const filters: FeedFilter[] = ['unwatched', 'watched']; + const labels = ['New', 'Seen']; const activeIndex = $derived(filters.indexOf(filter)); let containerEl: HTMLDivElement | undefined = $state(); @@ -41,6 +41,8 @@ $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; @@ -128,7 +130,7 @@ position: relative; padding: 8px var(--space-md); background: none; - color: rgba(255, 255, 255, 0.5); + color: var(--reel-text-subtle); border: none; border-radius: 0; font-family: var(--font-display); @@ -164,7 +166,7 @@ position: absolute; bottom: 0; height: 3px; - background: var(--text-primary); + background: var(--reel-text); border-radius: var(--radius-full); transition: left 0.25s cubic-bezier(0.32, 0.72, 0, 1), diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 2d455ed..61a042b 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -11,6 +11,13 @@ import { addVideoModalOpen } from '$lib/stores/addVideoModal'; import ClipOverlay from '$lib/components/ClipOverlay.svelte'; import { addToast, toast, clipReadySignal, clipOverlaySignal } from '$lib/stores/toasts'; + import { + isStandalone, + showInstallBanner, + showIosInstallBanner, + triggerInstall + } from '$lib/stores/pwa'; + import { isPushSupported, getExistingSubscription, subscribeToPush } from '$lib/push'; import { homeTapSignal } from '$lib/stores/homeTap'; import { unwatchedCount, fetchUnwatchedCount } from '$lib/stores/notifications'; import { feedUiHidden } from '$lib/stores/uiHidden'; @@ -77,7 +84,7 @@ let dragCounter = 0; // Horizontal swipe state - const FILTERS: FeedFilter[] = ['unwatched', 'watched', 'favorites']; + const FILTERS: FeedFilter[] = ['unwatched', 'watched']; let swipeX = $state(0); let swipeAnimating = $state(false); let isHorizontalSwiping = $state(false); @@ -95,6 +102,11 @@ const currentUserId = $derived(page.data.user?.id ?? ''); const autoScroll = $derived(page.data.user?.autoScroll ?? false); const gifEnabled = $derived(!!page.data.gifEnabled); + const vapidPublicKey = $derived(page.data.vapidPublicKey as string); + + let pushSupported = $state(false); + let pushEnabled = $state(false); + let pushEnabling = $state(false); async function loadInitialClips() { loading = true; @@ -601,6 +613,18 @@ } } + async function enablePush() { + if (pushEnabling || !vapidPublicKey) return; + pushEnabling = true; + try { + const sub = await subscribeToPush(vapidPublicKey); + pushEnabled = !!sub; + if (sub) addToast({ type: 'success', message: 'Notifications enabled', autoDismiss: 3000 }); + } finally { + pushEnabling = false; + } + } + onMount(() => { isDesktopFeed = matchMedia('(pointer: fine)').matches; const shareUrl = extractShareTargetUrl(); @@ -640,10 +664,25 @@ } loadInitialClips(); + + // Check push notification state for end-slide banner + pushSupported = isPushSupported(); + if (pushSupported) { + getExistingSubscription().then((sub) => { + pushEnabled = !!sub; + }); + } + + // Mark so the body background matches the reel context — this makes the iOS + // black-translucent status bar show a dark background rather than the light-mode white. + document.documentElement.classList.add('feed-context'); }); onDestroy(() => { feedUiHidden.set(false); + if (typeof document !== 'undefined') { + document.documentElement.classList.remove('feed-context'); + } }); @@ -783,6 +822,20 @@ Your favorite clips are above {/if}

+ {#if $isStandalone && pushSupported && !pushEnabled} + + {:else if !$isStandalone && ($showInstallBanner || $showIosInstallBanner)} + + {/if}
{/if} @@ -881,7 +934,7 @@ align-items: center; justify-content: center; gap: var(--space-sm); - background: var(--bg-primary); + background: var(--reel-bg); animation: empty-in 400ms cubic-bezier(0.32, 0.72, 0, 1); } @keyframes empty-in { @@ -895,7 +948,7 @@ } } .empty-icon { - color: var(--text-muted); + color: var(--reel-text-subtle); opacity: 0.5; margin-bottom: var(--space-xs); } @@ -903,11 +956,11 @@ font-family: var(--font-display); font-size: 1.25rem; font-weight: 700; - color: var(--text-primary); + color: var(--reel-text); margin: 0; } .empty-sub { - color: var(--text-muted); + color: var(--reel-text-subtle); font-size: 0.875rem; margin: 0 0 var(--space-md); } @@ -983,7 +1036,7 @@ margin: 0; } .end-slide { - background: var(--bg-primary); + background: var(--reel-bg); } .end-slide-content { height: 100%; @@ -1003,14 +1056,36 @@ font-family: var(--font-display); font-size: 1.25rem; font-weight: 700; - color: var(--text-primary); + color: var(--reel-text); margin: 0; } .end-slide-sub { - color: var(--text-muted); + color: var(--reel-text-subtle); font-size: 0.875rem; margin: 0; } + .end-slide-banner { + margin-top: var(--space-lg); + padding: var(--space-sm) var(--space-xl); + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-full); + color: var(--reel-text); + font-family: var(--font-display); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; + } + .end-slide-banner:active { + transform: scale(0.97); + background: rgba(255, 255, 255, 0.16); + } + .end-slide-banner:disabled { + opacity: 0.6; + cursor: default; + } @keyframes fade-in { from { opacity: 0; From 2f4bac93cfd45ed484bfb461aa6bbb4da51f4dea Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:01:35 -0600 Subject: [PATCH 16/33] fix: various server and service worker improvements --- src/lib/server/api-utils.ts | 9 ++++++++- src/lib/server/mentions.ts | 9 ++++++++- src/lib/server/push.ts | 9 +++++++-- src/routes/api/clips/+server.ts | 2 +- src/routes/api/clips/[id]/comments/+server.ts | 5 ++++- src/routes/api/clips/[id]/reactions/+server.ts | 1 + src/routes/api/clips/[id]/watched/+server.ts | 4 ++-- src/service-worker.ts | 3 +-- vite.config.ts | 3 +++ 9 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index b9c06d3..e91810b 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -3,6 +3,7 @@ import { db } from '$lib/server/db'; import { clips, users, notificationPreferences, notifications } from '$lib/server/db/schema'; import { eq, and, inArray, type InferSelectModel } from 'drizzle-orm'; import { sendNotification } from '$lib/server/push'; +import { env } from '$env/dynamic/private'; import { v4 as uuid } from 'uuid'; import { createLogger } from '$lib/server/logger'; @@ -196,6 +197,7 @@ export async function notifyClipOwner(opts: { recipientId: string; actorId: string; actorUsername: string; + actorAvatarPath?: string | null; clipId: string; type: 'reaction' | 'comment' | 'reply'; preferenceKey: 'reactions' | 'comments'; @@ -215,11 +217,16 @@ export async function notifyClipOwner(opts: { opts.type === 'comment' || opts.type === 'reply' ? `/?clip=${opts.clipId}&comments=true` : `/?clip=${opts.clipId}`; + const image = + opts.actorAvatarPath && env.ORIGIN + ? `${env.ORIGIN}/api/profile/avatar/${opts.actorAvatarPath}` + : undefined; sendNotification(opts.recipientId, { title: opts.pushTitle, body: opts.pushBody, url, - tag: opts.pushTag + tag: opts.pushTag, + ...(image ? { image } : {}) }).catch((err) => log.error({ err }, 'push notification failed')); } diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts index 9c7b3aa..2d579eb 100644 --- a/src/lib/server/mentions.ts +++ b/src/lib/server/mentions.ts @@ -2,6 +2,7 @@ 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 { env } from '$env/dynamic/private'; import { v4 as uuid } from 'uuid'; import { createLogger } from '$lib/server/logger'; @@ -29,6 +30,7 @@ export async function notifyMentions(opts: { mentionedUsernames: string[]; actorId: string; actorUsername: string; + actorAvatarPath?: string | null; clipId: string; groupId: string; commentPreview: string; @@ -58,11 +60,16 @@ export async function notifyMentions(opts: { // Send push if preference allows (default true if no prefs row) if (!prefs || prefs.mentions) { + const image = + opts.actorAvatarPath && env.ORIGIN + ? `${env.ORIGIN}/api/profile/avatar/${opts.actorAvatarPath}` + : undefined; sendNotification(recipient.id, { title: `${opts.actorUsername} mentioned you`, body: opts.commentPreview, url: `/?clip=${opts.clipId}&comments=true`, - tag: `mention-${opts.clipId}` + tag: `mention-${opts.clipId}`, + ...(image ? { image } : {}) }).catch((err) => log.error({ err }, 'mention push notification failed')); } diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts index f59aa19..6ba30b0 100644 --- a/src/lib/server/push.ts +++ b/src/lib/server/push.ts @@ -16,9 +16,9 @@ const log = createLogger('push'); type NotificationPayload = { title: string; body: string; - icon?: string; url?: string; tag?: string; + image?: string; }; /** Get the number of unwatched ready clips for a user in their group. */ @@ -115,6 +115,10 @@ export async function notifyNewClip(clipId: string): Promise { if (!uploader) return; const label = clip.contentType === 'music' ? 'song' : 'video'; + const image = + clip.thumbnailPath && env.ORIGIN + ? `${env.ORIGIN}/api/thumbnails/${clip.thumbnailPath}` + : undefined; await sendGroupNotification( clip.groupId, @@ -122,7 +126,8 @@ export async function notifyNewClip(clipId: string): Promise { title: `${uploader.username} added a ${label}`, body: clip.title || 'Tap to watch', url: `/?clip=${clipId}`, - tag: 'new-clip' + tag: 'new-clip', + ...(image ? { image } : {}) }, 'newAdds', uploader.id diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index d78427c..6e7b6d1 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -122,7 +122,7 @@ function applySortOrder( watchedRows: { clipId: string; watchedAt: Date }[] ): ClipRow[] { if (filter === 'watched') { - // Watched tab: always sort by most-recently-watched + // Watched tab: sort by first-watched (watchedAt is never updated after initial watch) const watchedAtMap = new Map(watchedRows.map((w) => [w.clipId, w.watchedAt.getTime()])); return [...clipList].sort( (a, b) => (watchedAtMap.get(b.id) ?? 0) - (watchedAtMap.get(a.id) ?? 0) diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts index bc0f142..b10dd5b 100644 --- a/src/routes/api/clips/[id]/comments/+server.ts +++ b/src/routes/api/clips/[id]/comments/+server.ts @@ -127,7 +127,7 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => async function dispatchCommentNotification( clipId: string, parentId: string | null, - actor: { id: string; username: string }, + actor: { id: string; username: string; avatarPath: string | null }, preview: string ): Promise { if (parentId) { @@ -139,6 +139,7 @@ async function dispatchCommentNotification( recipientId: parentComment.userId, actorId: actor.id, actorUsername: actor.username, + actorAvatarPath: actor.avatarPath, clipId, type: 'reply', preferenceKey: 'comments', @@ -156,6 +157,7 @@ async function dispatchCommentNotification( recipientId: clip.addedBy, actorId: actor.id, actorUsername: actor.username, + actorAvatarPath: actor.avatarPath, clipId, type: 'comment', preferenceKey: 'comments', @@ -242,6 +244,7 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u mentionedUsernames, actorId: user.id, actorUsername: user.username, + actorAvatarPath: user.avatarPath, clipId, groupId: user.groupId, commentPreview: preview, diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index f64910a..1c13d7d 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -76,6 +76,7 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u recipientId: clip.addedBy, actorId: userId, actorUsername: user.username, + actorAvatarPath: user.avatarPath, clipId, type: 'reaction', preferenceKey: 'reactions', diff --git a/src/routes/api/clips/[id]/watched/+server.ts b/src/routes/api/clips/[id]/watched/+server.ts index c1e039b..92d4151 100644 --- a/src/routes/api/clips/[id]/watched/+server.ts +++ b/src/routes/api/clips/[id]/watched/+server.ts @@ -31,8 +31,8 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u watchPercent: watchPercent === null ? watched.watchPercent - : sql`MAX(COALESCE(${watched.watchPercent}, 0), ${watchPercent})`, - watchedAt: new Date() + : sql`MAX(COALESCE(${watched.watchPercent}, 0), ${watchPercent})` + // watchedAt intentionally not updated — keep the first-watched timestamp for stable sort order } }); diff --git a/src/service-worker.ts b/src/service-worker.ts index 572eec2..b7b732e 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -84,11 +84,10 @@ sw.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); - const { title, body, icon, url, tag, image, badgeCount } = data; + const { title, body, url, tag, image, badgeCount } = data; const notificationOptions: NotificationOptions & { image?: string } = { body: body || '', - icon: icon || '/icon/icon-192.svg', badge: '/icon/badge-72.svg', tag: tag || undefined, data: { url: url || '/' } diff --git a/vite.config.ts b/vite.config.ts index 89c8568..ec8d19b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,9 @@ const version = process.env.APP_VERSION || pkg.version; export default defineConfig({ plugins: [sveltekit()], + server: { + host: '0.0.0.0' + }, define: { __APP_VERSION__: JSON.stringify(version) }, From 1dd86fe8ea7974811d2c4d2e5a18b3081dff26f3 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:01:45 -0600 Subject: [PATCH 17/33] fix: remove faded opacity on comment reaction pills --- src/lib/components/CommentsSheet.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 0032b04..534064f 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -406,7 +406,6 @@ align-items: center; gap: var(--space-sm); padding: var(--space-xs) 0; - opacity: 0.55; } .reaction-emoji { font-size: 0.875rem; From a1b8481bd1b4a3951179a6e909c373ccf566c606 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:06:20 -0600 Subject: [PATCH 18/33] fix: align input actions with first text line using fixed top offset Replace top: 50% / transform approach with a calculated top: 5px that aligns the icon center with the text baseline regardless of UA-assigned container height. Icons switch to bottom-anchored when input grows past single-line. --- src/lib/components/CommentInput.svelte | 14 ++++++-------- src/lib/components/CommentsSheet.svelte | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index fead0d6..ccea2b8 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -248,25 +248,23 @@ padding-right: 70px; } - /* Actions row: centered vertically on single-line, bottom-anchored on multi-line */ + /* + * Actions row: pin to the first text line on single-line, bottom-anchored on multi-line. + * top: 5px = border(1px) + padding-top(6px) - (icon_height(26) - line_height(22.4))/2 + * This aligns the icon center with the text center regardless of container height. + */ .input-actions { position: absolute; - top: 50%; - transform: translateY(-50%); + top: 5px; right: 8px; display: flex; align-items: center; gap: 4px; z-index: 10; - transition: - top 0.12s ease, - bottom 0.12s ease, - transform 0.12s ease; } .input-wrapper.multi-line .input-actions { top: auto; bottom: 6px; - transform: none; } .gif-pill { diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 534064f..86f34f6 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -412,11 +412,11 @@ } .reaction-text { font-size: 0.75rem; - color: var(--text-muted); + color: var(--text-secondary); } .reaction-time { font-size: 0.6875rem; - color: var(--text-muted); + color: var(--text-secondary); margin-left: auto; } .replies { From b4bc2e3097d02f761158384ca8ef275a6f637dca Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:07:37 -0600 Subject: [PATCH 19/33] fix: equalize input bar top/bottom padding to 12px --- src/lib/components/CommentInput.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index ccea2b8..e133ca5 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -206,10 +206,10 @@ .input-bar { display: flex; - padding: var(--space-sm) var(--space-lg); + padding: var(--space-md) var(--space-lg); border-top: 1px solid var(--border); background: var(--bg-surface); - padding-bottom: max(12px, env(safe-area-inset-bottom)); + padding-bottom: max(var(--space-md), env(safe-area-inset-bottom)); } .input-wrapper { From a97bba95d756a72c20a397b594ffb9bb6bfddbb0 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:29:15 -0600 Subject: [PATCH 20/33] fix: remove user-scalable=no from viewport meta Disabling user scaling is a WCAG 2.1 SC 1.4.4 violation. The existing touch-action: manipulation already prevents double-tap zoom, making user-scalable=no redundant and harmful for accessibility. --- src/app.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.html b/src/app.html index 6d55ec1..0136c35 100644 --- a/src/app.html +++ b/src/app.html @@ -2,7 +2,7 @@ - + From a30d119c002d3388148d2a082e4b223957b52bd1 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:29:21 -0600 Subject: [PATCH 21/33] fix: migrate ProgressBar to unified pointer events with setPointerCapture Replace split mouse + touch event handlers with a single pointer event model. setPointerCapture ensures move/up events reach the element even when the pointer leaves its bounds during a scrub, eliminating the need for document-level mousemove/mouseup listeners. Add touch-action: none so the browser yields all gestures on the scrubber to JS. --- src/lib/components/ProgressBar.svelte | 60 +++++++++------------------ 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/src/lib/components/ProgressBar.svelte b/src/lib/components/ProgressBar.svelte index 7cbd6eb..7145f54 100644 --- a/src/lib/components/ProgressBar.svelte +++ b/src/lib/components/ProgressBar.svelte @@ -26,53 +26,31 @@ return ratio * duration; } - function handleMouseDown(e: MouseEvent) { + function handlePointerDown(e: PointerEvent) { e.preventDefault(); e.stopPropagation(); scrubbing = true; + barEl?.setPointerCapture(e.pointerId); onseek(getTimeFromX(e.clientX)); - - function handleMouseMove(ev: MouseEvent) { - onseek(getTimeFromX(ev.clientX)); - } - - function handleMouseUp() { - scrubbing = false; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - } - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } - - function handleHover(e: MouseEvent) { - if (scrubbing) return; - hoverProgress = getTimeFromX(e.clientX); } - function handleMouseLeave() { - if (!scrubbing) { - hoverProgress = null; + function handlePointerMove(e: PointerEvent) { + if (scrubbing) { + onseek(getTimeFromX(e.clientX)); + } else if (e.pointerType === 'mouse') { + hoverProgress = getTimeFromX(e.clientX); } } - function handleTouchStart(e: TouchEvent) { - e.stopPropagation(); - scrubbing = true; - const touch = e.touches[0]; - onseek(getTimeFromX(touch.clientX)); - } - - function handleTouchMove(e: TouchEvent) { + function handlePointerUp(e: PointerEvent) { if (!scrubbing) return; - e.stopPropagation(); - const touch = e.touches[0]; - onseek(getTimeFromX(touch.clientX)); + scrubbing = false; + barEl?.releasePointerCapture(e.pointerId); + if (e.pointerType !== 'mouse') hoverProgress = null; } - function handleTouchEnd() { - scrubbing = false; + function handlePointerLeave() { + if (!scrubbing) hoverProgress = null; } @@ -82,12 +60,11 @@ class:scrubbing class:ui-hidden={uiHidden} bind:this={barEl} - onmousedown={handleMouseDown} - onmousemove={handleHover} - onmouseleave={handleMouseLeave} - ontouchstart={handleTouchStart} - ontouchmove={handleTouchMove} - ontouchend={handleTouchEnd} + onpointerdown={handlePointerDown} + onpointermove={handlePointerMove} + onpointerup={handlePointerUp} + onpointercancel={handlePointerUp} + onpointerleave={handlePointerLeave} tabindex="0" role="slider" aria-label="Video progress" @@ -117,6 +94,7 @@ align-items: center; cursor: pointer; padding: 0; + touch-action: none; transition: opacity 0.3s ease; } From 56e1a4a47447a1b0046a370acb299b390c87139e Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:29:27 -0600 Subject: [PATCH 22/33] fix: migrate CommentRow long-press to pointer events Replace ontouchstart/ontouchend/ontouchcancel with the pointer events equivalent. Add onpointerleave to cancel the long-press popover when the finger slides off the element, matching the touch cancel behavior. --- src/lib/components/CommentRow.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/components/CommentRow.svelte b/src/lib/components/CommentRow.svelte index a7d2b2a..54fa25d 100644 --- a/src/lib/components/CommentRow.svelte +++ b/src/lib/components/CommentRow.svelte @@ -93,9 +93,10 @@