From be9ff06b62393999e372dc94cc30c007286f477d Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:21:51 -0600 Subject: [PATCH 1/5] feat: extract phone utilities, improve onboarding phone input and auth flow - Extract rawDigits, formatPhone, toE164 into shared $lib/phone module with tests - Onboard page now uses formatted (XXX) XXX-XXXX phone input matching join page - Add returnTo URL preservation through join/onboard auth flow - Add rate limiting on join action (5 req/15 min per IP) - Fix OTP paste handling for Android IME multi-digit input - Lazy-load display fonts on join page instead of globally --- src/lib/__tests__/phone.test.ts | 67 ++++++++++++++++ src/lib/phone.ts | 18 +++++ src/routes/(app)/+layout.server.ts | 7 +- src/routes/api/auth/+server.ts | 13 +++- src/routes/join/+page.svelte | 44 ++++++----- src/routes/onboard/+page.svelte | 121 +++++++++++++++++++++++++---- 6 files changed, 227 insertions(+), 43 deletions(-) create mode 100644 src/lib/__tests__/phone.test.ts create mode 100644 src/lib/phone.ts diff --git a/src/lib/__tests__/phone.test.ts b/src/lib/__tests__/phone.test.ts new file mode 100644 index 0000000..76ad329 --- /dev/null +++ b/src/lib/__tests__/phone.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { rawDigits, formatPhone, toE164 } from '../phone'; + +describe('rawDigits', () => { + it('strips non-digit characters', () => { + expect(rawDigits('(555) 123-4567')).toBe('5551234567'); + }); + + it('returns empty string for empty input', () => { + expect(rawDigits('')).toBe(''); + }); + + it('returns digits only from mixed input', () => { + expect(rawDigits('+1 (555) 123-4567')).toBe('15551234567'); + }); + + it('passes through pure digit strings unchanged', () => { + expect(rawDigits('1234567890')).toBe('1234567890'); + }); +}); + +describe('formatPhone', () => { + it('returns empty string for no digits', () => { + expect(formatPhone('')).toBe(''); + }); + + it('formats 1-3 digits with opening paren', () => { + expect(formatPhone('5')).toBe('(5'); + expect(formatPhone('55')).toBe('(55'); + expect(formatPhone('555')).toBe('(555'); + }); + + it('formats 4-6 digits with area code and space', () => { + expect(formatPhone('5551')).toBe('(555) 1'); + expect(formatPhone('55512')).toBe('(555) 12'); + expect(formatPhone('555123')).toBe('(555) 123'); + }); + + it('formats 7-10 digits with full formatting', () => { + expect(formatPhone('5551234')).toBe('(555) 123-4'); + expect(formatPhone('55512345')).toBe('(555) 123-45'); + expect(formatPhone('555123456')).toBe('(555) 123-456'); + expect(formatPhone('5551234567')).toBe('(555) 123-4567'); + }); + + it('truncates digits beyond 10', () => { + expect(formatPhone('55512345678')).toBe('(555) 123-4567'); + }); +}); + +describe('toE164', () => { + it('converts formatted phone to E.164', () => { + expect(toE164('(555) 123-4567')).toBe('+15551234567'); + }); + + it('converts raw digits to E.164', () => { + expect(toE164('5551234567')).toBe('+15551234567'); + }); + + it('handles empty string', () => { + expect(toE164('')).toBe('+1'); + }); + + it('handles partial input', () => { + expect(toE164('(555')).toBe('+1555'); + }); +}); diff --git a/src/lib/phone.ts b/src/lib/phone.ts new file mode 100644 index 0000000..2ccb1a6 --- /dev/null +++ b/src/lib/phone.ts @@ -0,0 +1,18 @@ +/** Extract raw digits from a formatted phone display string. */ +export function rawDigits(formatted: string): string { + return formatted.replace(/\D/g, ''); +} + +/** Format digits as (XXX) XXX-XXXX for display. */ +export function formatPhone(digits: string): string { + if (digits.length === 0) return ''; + if (digits.length <= 3) return `(${digits}`; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; +} + +/** Convert a formatted display string to E.164 format (+1XXXXXXXXXX). */ +export function toE164(formatted: string): string { + const digits = rawDigits(formatted); + return `+1${digits}`; +} diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index 4eea6e8..e614671 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -2,12 +2,13 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; import { env } from '$env/dynamic/private'; -export const load: LayoutServerLoad = async ({ locals }) => { +export const load: LayoutServerLoad = async ({ locals, url }) => { + const returnTo = url.search ? url.pathname + url.search : ''; if (!locals.user) { - redirect(302, '/join'); + redirect(302, returnTo ? `/join?returnTo=${encodeURIComponent(returnTo)}` : '/join'); } if (!locals.user.username) { - redirect(302, '/onboard'); + redirect(302, returnTo ? `/onboard?returnTo=${encodeURIComponent(returnTo)}` : '/onboard'); } return { user: locals.user, diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index 6da0b0c..9be5936 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -13,6 +13,7 @@ import { eq, and, desc } from 'drizzle-orm'; import { sendVerification, checkVerification } from '$lib/server/sms/verify'; import { dev } from '$app/environment'; import { createLogger } from '$lib/server/logger'; +import { checkRateLimit, rateLimitResponse } from '$lib/server/rate-limit'; const log = createLogger('auth'); @@ -224,12 +225,20 @@ async function handleLoginVerifyCode(body: Record) { } // POST /api/auth - Login, join, send/verify phone code, or onboard -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, getClientAddress }) => { const body = await request.json(); const { action } = body; // Unauthenticated actions - if (action === 'join') return handleJoin(body); + if (action === 'join') { + const ip = getClientAddress(); + const result = checkRateLimit(`join:${ip}`, { windowMs: 15 * 60 * 1000, maxRequests: 5 }); + if (!result.allowed) { + log.warn({ ip }, 'join rate limit exceeded'); + return rateLimitResponse(result.resetAt); + } + return handleJoin(body); + } if (action === 'login-send-code') return handleLoginSendCode(body); if (action === 'login-verify-code') return handleLoginVerifyCode(body); diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte index 1d96c6f..c0a211b 100644 --- a/src/routes/join/+page.svelte +++ b/src/routes/join/+page.svelte @@ -4,6 +4,7 @@ import iconUrl from '$lib/assets/icon.svg?url'; import InlineError from '$lib/components/InlineError.svelte'; import ArrowRightIcon from 'phosphor-svelte/lib/ArrowRightIcon'; + import { rawDigits, formatPhone, toE164 } from '$lib/phone'; let view = $state<'login' | 'verify'>('login'); let phoneDisplay = $state(''); @@ -21,25 +22,6 @@ }; }); - // Extract raw digits from formatted display - function rawDigits(formatted: string): string { - return formatted.replace(/\D/g, ''); - } - - // Format digits as (XXX) XXX-XXXX for display - function formatPhone(digits: string): string { - if (digits.length === 0) return ''; - if (digits.length <= 3) return `(${digits}`; - if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; - } - - // Convert display format to E.164 for Twilio - function toE164(formatted: string): string { - const digits = rawDigits(formatted); - return `+1${digits}`; - } - function handlePhoneInput(e: Event) { const input = e.target as HTMLInputElement; const digits = rawDigits(input.value).slice(0, 10); @@ -113,7 +95,8 @@ } const params = new URLSearchParams(window.location.search); const returnTo = params.get('returnTo'); - window.location.href = returnTo && returnTo.startsWith('/') ? returnTo : '/'; + window.location.href = + returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//') ? returnTo : '/'; } catch { error = 'Something went wrong'; } finally { @@ -152,6 +135,17 @@ const input = e.target as HTMLInputElement; const value = input.value.replace(/\D/g, ''); + if (value.length > 1) { + // Multi-digit paste via input event (common on Android IME) + for (let i = 0; i < 6; i++) { + codeDigits[i] = value[i] || ''; + } + code = codeDigits.join(''); + const nextEmpty = codeDigits.findIndex((d) => d === ''); + codeInputs[nextEmpty === -1 ? 5 : nextEmpty]?.focus(); + return; + } + if (value.length > 0) { codeDigits[index] = value[0]; // Auto-advance to next input @@ -192,6 +186,12 @@ scrolly — Sign In + + +
@@ -269,7 +269,7 @@ handleVerifyLogin(); }} > -
+
{#each codeDigits as digit, i (i)} handleCodeInput(i, e)} onkeydown={(e) => handleCodeKeydown(i, e)} + onpaste={handleCodePaste} disabled={loading} class="code-box" class:filled={digit !== ''} @@ -337,6 +338,7 @@ justify-content: center; min-height: 100dvh; padding: var(--space-xl); + padding-bottom: calc(var(--space-xl) + 72px); background: var(--bg-primary); overflow: hidden; } diff --git a/src/routes/onboard/+page.svelte b/src/routes/onboard/+page.svelte index 3def2c1..89b5d1a 100644 --- a/src/routes/onboard/+page.svelte +++ b/src/routes/onboard/+page.svelte @@ -1,16 +1,48 @@ - - - - {@render children()} diff --git a/src/routes/icon/[name]/+server.ts b/src/routes/icon/[name]/+server.ts new file mode 100644 index 0000000..135628a --- /dev/null +++ b/src/routes/icon/[name]/+server.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from './$types'; +import { generateIconSvg, resolveAccentHex } from '$lib/iconSvg'; + +const ICON_CONFIG: Record = { + 'favicon.svg': {}, + 'icon-192.svg': {}, + 'icon-512.svg': {}, + 'icon-maskable-192.svg': { maskable: true }, + 'icon-maskable-512.svg': { maskable: true }, + 'apple-touch-icon.svg': {}, + 'badge-72.svg': { monochrome: true } +}; + +export const GET: RequestHandler = ({ params, locals }) => { + const config = ICON_CONFIG[params.name]; + if (!config) { + return new Response('Not found', { status: 404 }); + } + + const accentHex = resolveAccentHex(locals.group?.accentColor); + const svg = generateIconSvg(accentHex, config); + + return new Response(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'private, no-cache', + Vary: 'Cookie' + } + }); +}; diff --git a/src/routes/manifest.json/+server.ts b/src/routes/manifest.json/+server.ts new file mode 100644 index 0000000..1d3e3a3 --- /dev/null +++ b/src/routes/manifest.json/+server.ts @@ -0,0 +1,93 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = () => { + const manifest = { + name: 'scrolly', + short_name: 'scrolly', + description: 'Private video sharing for your friend group', + id: '/', + start_url: '/', + scope: '/', + display: 'standalone', + orientation: 'portrait', + lang: 'en', + categories: ['social', 'entertainment'], + background_color: '#000000', + theme_color: '#000000', + icons: [ + { + src: '/icon/icon-192.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any' + }, + { + src: '/icon/icon-512.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any' + }, + { + src: '/icon/icon-maskable-192.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'maskable' + }, + { + src: '/icon/icon-maskable-512.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'maskable' + }, + // PNG fallbacks for platforms that don't support SVG manifest icons + { + src: '/icons/icon-192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any' + }, + { + src: '/icons/icon-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any' + }, + { + src: '/icons/icon-maskable-192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'maskable' + }, + { + src: '/icons/icon-maskable-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable' + }, + { + src: '/icon/badge-72.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'monochrome' + } + ], + share_target: { + action: '/share', + method: 'GET', + enctype: 'application/x-www-form-urlencoded', + params: { + url: 'url', + text: 'text', + title: 'title' + } + } + }; + + return new Response(JSON.stringify(manifest, null, '\t'), { + headers: { + 'Content-Type': 'application/manifest+json', + 'Cache-Control': 'private, no-cache', + Vary: 'Cookie' + } + }); +}; diff --git a/static/manifest.json b/static/manifest.json deleted file mode 100644 index 4e3b6a6..0000000 --- a/static/manifest.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "scrolly", - "short_name": "scrolly", - "description": "Private video sharing for your friend group", - "id": "/", - "start_url": "/", - "scope": "/", - "display": "standalone", - "orientation": "portrait", - "lang": "en", - "categories": ["social", "entertainment"], - "background_color": "#000000", - "theme_color": "#000000", - "icons": [ - { - "src": "/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/icons/icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/icons/icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/icons/badge-72.png", - "sizes": "72x72", - "type": "image/png", - "purpose": "monochrome" - } - ], - "share_target": { - "action": "/share", - "method": "GET", - "enctype": "application/x-www-form-urlencoded", - "params": { - "url": "url", - "text": "text", - "title": "title" - } - } -} From af075f4316bb1cdc6bc6ce678090413cbb910e4e Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:22:51 -0600 Subject: [PATCH 3/5] feat: improve push notifications and add deep-linking to clips - Improve push notification copy and include clip titles - Deep-link push notifications to specific clips with ?clip=ID - Auto-open comments sheet for comment/reply notifications - Send push notifications even when clip download fails - Add Odesli fetch timeout (15s) for music clip resolution - Navigate to clip from activity sheet notification taps - Add openCommentsSignal store for cross-component signaling - Migrate ReelItem to $derived for muted/speed store subscriptions --- src/lib/components/ActivitySheet.svelte | 47 ++++++++++++------- src/lib/components/ReelItem.svelte | 25 +++++----- src/lib/server/api-utils.ts | 6 ++- src/lib/server/mentions.ts | 2 +- src/lib/server/music/download.ts | 14 +++++- src/lib/server/push.ts | 15 +++--- src/lib/server/video/download.ts | 16 +++++++ src/lib/stores/toasts.ts | 1 + src/routes/(app)/+page.svelte | 39 +++++++++++---- src/routes/api/clips/[id]/comments/+server.ts | 8 ++-- .../api/clips/[id]/reactions/+server.ts | 4 +- 11 files changed, 124 insertions(+), 53 deletions(-) diff --git a/src/lib/components/ActivitySheet.svelte b/src/lib/components/ActivitySheet.svelte index c72a5e5..3106380 100644 --- a/src/lib/components/ActivitySheet.svelte +++ b/src/lib/components/ActivitySheet.svelte @@ -1,8 +1,9 @@ @@ -170,7 +181,7 @@ class="notification-item" class:unread={!n.read} style="animation-delay: {Math.min(i, 15) * 30}ms" - onclick={(e) => handleNotificationClick(e, n.clipId)} + onclick={(e) => handleNotificationClick(e, n)} >
{#if n.actorAvatar} diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 2f3074f..769034c 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -1,8 +1,8 @@ + +
+ + {#if saved} + + + + {/if} +
+ + diff --git a/src/lib/settingsApi.ts b/src/lib/settingsApi.ts index bfa2aef..bcc57ce 100644 --- a/src/lib/settingsApi.ts +++ b/src/lib/settingsApi.ts @@ -1,4 +1,5 @@ import { ACCENT_COLORS, type AccentColorKey } from '$lib/colors'; +import { updateFavicon } from '$lib/iconSvg'; export type NotificationPrefs = { newAdds: boolean; @@ -8,6 +9,20 @@ export type NotificationPrefs = { dailyReminder: boolean; }; +// --- Helpers --- + +async function savePreference(data: Record): Promise { + try { + await fetch('/api/profile/preferences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + } catch (err) { + console.warn('failed to save preference', err); + } +} + // --- Theme --- export function applyTheme(value: 'system' | 'light' | 'dark'): void { @@ -20,29 +35,33 @@ export function applyTheme(value: 'system' | 'light' | 'dark'): void { } export async function saveThemePreference(value: 'system' | 'light' | 'dark'): Promise { - await fetch('/api/profile/preferences', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ themePreference: value }) - }); + await savePreference({ themePreference: value }); +} + +// --- Username --- + +export async function saveUsername(username: string): Promise<{ username: string } | null> { + try { + const res = await fetch('/api/profile/preferences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }) + }); + if (res.ok) return res.json(); + } catch (err) { + console.warn('failed to save username', err); + } + return null; } // --- Playback --- export async function saveAutoScroll(value: boolean): Promise { - await fetch('/api/profile/preferences', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ autoScroll: value }) - }); + await savePreference({ autoScroll: value }); } export async function saveMutedByDefault(value: boolean): Promise { - await fetch('/api/profile/preferences', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mutedByDefault: value }) - }); + await savePreference({ mutedByDefault: value }); } // --- Accent color --- @@ -52,28 +71,44 @@ export function applyAccentColor(key: AccentColorKey): void { document.documentElement.style.setProperty('--accent-primary', color.hex); document.documentElement.style.setProperty('--accent-primary-dark', color.dark); document.cookie = `scrolly_accent=${encodeURIComponent(JSON.stringify({ hex: color.hex, dark: color.dark }))};path=/;max-age=31536000;SameSite=Lax`; + updateFavicon(color.hex); } export async function saveAccentColor(key: AccentColorKey): Promise { - await fetch('/api/group/accent', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ accentColor: key }) - }); + try { + await fetch('/api/group/accent', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accentColor: key }) + }); + } catch (err) { + console.warn('failed to save accent color', err); + } } // --- Notification preferences --- export async function fetchNotificationPrefs(): Promise { - const res = await fetch('/api/notifications/preferences'); - if (res.ok) return res.json(); + try { + const res = await fetch('/api/notifications/preferences'); + if (res.ok) return res.json(); + } catch (err) { + console.warn('failed to fetch notification prefs', err); + } return { newAdds: true, reactions: true, comments: true, mentions: true, dailyReminder: false }; } -export async function updateNotificationPref(key: string, value: boolean): Promise { - await fetch('/api/notifications/preferences', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [key]: value }) - }); +export async function updateNotificationPref( + key: keyof NotificationPrefs, + value: boolean +): Promise { + try { + await fetch('/api/notifications/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: value }) + }); + } catch (err) { + console.warn('failed to update notification pref', err); + } } diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index c19f815..b46321d 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index 49ba784..cbd824e 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -18,6 +18,13 @@ let lastBackupDate: string | null = null; const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour const REMINDER_HOUR = 9; // 9 AM server time const BACKUP_HOUR = 2; // 2 AM server time +const REMINDER_BODIES = [ + 'Your group has been sharing — catch up!', + 'New stuff from your crew awaits.', + 'See what your friends have been posting.', + "You're falling behind — time to scroll.", + 'Fresh clips in the feed. Take a look!' +]; export function startScheduler(): void { checkAndSendReminders(); @@ -96,7 +103,7 @@ async function sendDailyReminders(): Promise { and( eq(clips.groupId, user.groupId), eq(clips.status, 'ready'), - sql`${clips.id} NOT IN (SELECT ${watched.clipId} FROM ${watched} WHERE ${watched.userId} = ${user.id})` + sql`NOT EXISTS (SELECT 1 FROM ${watched} WHERE ${watched.clipId} = ${clips.id} AND ${watched.userId} = ${user.id})` ) ); @@ -105,7 +112,7 @@ async function sendDailyReminders(): Promise { await sendNotification(user.id, { title: `${unwatchedCount} unwatched ${unwatchedCount === 1 ? 'clip' : 'clips'}`, - body: 'Check out what your group has been sharing!', + body: REMINDER_BODIES[Math.floor(Math.random() * REMINDER_BODIES.length)], url: '/', tag: 'daily-reminder' }); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 89bf61a..72d05d9 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -28,6 +28,9 @@ onMount(() => { startPolling(); fetchGroupMembers(); + + // Tell fixed-position components (e.g. InstallBanner) about the bottom nav height + document.documentElement.style.setProperty('--bottom-nav-height', '80px'); // Initialize mute state from user preference const user = page.data?.user; if (user) { @@ -56,6 +59,7 @@ stopPolling(); themeObserver.disconnect(); darkMq.removeEventListener('change', syncThemeColor); + document.documentElement.style.removeProperty('--bottom-nav-height'); }; }); @@ -306,7 +310,7 @@ } .overlay-mode .tab { - color: rgba(255, 255, 255, 0.5); + color: var(--reel-text-subtle); } .tab.active {