From 3a146adcbd2835184cbf63b35edb7720d70bfffc Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:55:17 -0600 Subject: [PATCH 01/12] feat: add iOS shortcut validation on save Validate iOS Shortcut configuration when host pastes their iCloud link in the share setup flow. Fetches the shortcut from Apple's API, parses the binary plist, and checks URL, token, import questions, phone number setup, and shortcut name. Shows warnings as a checklist with bold labels and allows skip-and-save if host insists. --- package-lock.json | 22 + package.json | 1 + .../components/settings/SetupStepCard.svelte | 20 +- src/lib/server/shortcut-validator.ts | 561 ++++++++++++++++++ .../api/group/shortcut/validate/+server.ts | 21 + src/routes/share/setup/+page.server.ts | 7 +- src/routes/share/setup/+page.svelte | 529 +++++++++++++++-- 7 files changed, 1105 insertions(+), 56 deletions(-) create mode 100644 src/lib/server/shortcut-validator.ts create mode 100644 src/routes/api/group/shortcut/validate/+server.ts diff --git a/package-lock.json b/package-lock.json index 1c7f033..4ccc2a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "better-sqlite3": "^12.6.2", + "bplist-parser": "^0.3.2", "drizzle-orm": "^0.45.1", "mrmime": "^2.0.1", "pino": "^10.3.1", @@ -3977,6 +3978,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -4013,6 +4023,18 @@ "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", diff --git a/package.json b/package.json index b75550b..0dfe9b8 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "better-sqlite3": "^12.6.2", + "bplist-parser": "^0.3.2", "drizzle-orm": "^0.45.1", "mrmime": "^2.0.1", "pino": "^10.3.1", diff --git a/src/lib/components/settings/SetupStepCard.svelte b/src/lib/components/settings/SetupStepCard.svelte index b7d036c..387de37 100644 --- a/src/lib/components/settings/SetupStepCard.svelte +++ b/src/lib/components/settings/SetupStepCard.svelte @@ -37,7 +37,7 @@ } .step-badge { font-family: var(--font-display); - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; @@ -51,7 +51,7 @@ margin: 0; } .step-card :global(.step-desc) { - font-size: 0.875rem; + font-size: 1rem; color: var(--text-secondary); line-height: 1.6; margin: 0 0 var(--space-md); @@ -67,7 +67,7 @@ margin: 0 0 var(--space-xs); } .step-card :global(.setup-subtitle) { - font-size: 0.875rem; + font-size: 0.9375rem; color: var(--text-muted); margin: 0 0 var(--space-xl); line-height: 1.5; @@ -90,9 +90,9 @@ margin-top: 1px; } .step-card :global(.info-box span) { - font-size: 0.8125rem; + font-size: 0.9375rem; color: var(--text-secondary); - line-height: 1.4; + line-height: 1.5; } .step-card :global(.url-display) { display: flex; @@ -106,7 +106,7 @@ } .step-card :global(.url-display code) { flex: 1; - font-size: 0.6875rem; + font-size: 0.8125rem; color: var(--accent-primary); word-break: break-all; font-family: monospace; @@ -114,7 +114,7 @@ .step-card :global(.url-display code.url-placeholder) { color: var(--text-muted); font-family: var(--font-body); - font-size: 0.75rem; + font-size: 0.8125rem; } .step-card :global(.copy-btn) { flex-shrink: 0; @@ -123,7 +123,7 @@ border: 1px solid var(--border); border-radius: var(--radius-full); color: var(--text-primary); - font-size: 0.6875rem; + font-size: 0.8125rem; font-weight: 600; cursor: pointer; } @@ -136,7 +136,7 @@ gap: var(--space-sm); } .step-card :global(.json-keys li) { - font-size: 0.8125rem; + font-size: 0.9375rem; color: var(--text-secondary); line-height: 1.5; padding-left: var(--space-md); @@ -154,7 +154,7 @@ } .step-card :global(.key-name) { font-family: 'SF Mono', 'Fira Code', monospace; - font-size: 0.75rem; + font-size: 0.8125rem; background: color-mix(in srgb, var(--accent-primary) 12%, transparent); color: var(--accent-primary); padding: 1px 6px; diff --git a/src/lib/server/shortcut-validator.ts b/src/lib/server/shortcut-validator.ts new file mode 100644 index 0000000..7a131a4 --- /dev/null +++ b/src/lib/server/shortcut-validator.ts @@ -0,0 +1,561 @@ +/** + * iOS Shortcut validator — fetches a shared iCloud shortcut, parses the + * binary plist, and checks that the embedded URL / token / import questions + * are configured correctly for this Scrolly instance. + */ + +import bplist from 'bplist-parser'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('shortcut-validator'); + +const ICLOUD_API = 'https://www.icloud.com/shortcuts/api/records'; + +// ── Types ────────────────────────────────────────────────────────────── + +export interface ValidationWarning { + code: string; + message: string; +} + +export interface ValidationResult { + name: string | null; + warnings: ValidationWarning[]; +} + +interface ShortcutAction { + WFWorkflowActionIdentifier: string; + WFWorkflowActionParameters: Record; +} + +interface ImportQuestion { + ActionIndex: number; + ParameterKey: string; + Text?: string; + DefaultValue?: string; + Category?: string; +} + +interface DownloadAnalysis { + urlTemplate: string | null; + variableRefs: Map; + bodyFieldKeys: string[]; + bodyVariableRefs: string[]; +} + +// ── Keyword lists for classifying import questions ───────────────────── + +const PHONE_KEYWORDS = ['phone', 'number', 'phone number']; +const URL_KEYWORDS = ['url', 'instance', 'server', 'address', 'domain']; +const TOKEN_KEYWORDS = ['token', 'key', 'secret', 'group token']; + +function matchesKeywords(text: string, keywords: string[]): boolean { + const lower = text.toLowerCase(); + return keywords.some((kw) => lower.includes(kw)); +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +function extractShortcutId(icloudUrl: string): string | null { + const m = icloudUrl.match(/\/shortcuts\/([a-f0-9]{32})\/?$/i); + return m ? m[1] : null; +} + +function deepFindStrings(obj: unknown, target: string): boolean { + if (typeof obj === 'string') return obj.includes(target); + if (Array.isArray(obj)) return obj.some((v) => deepFindStrings(v, target)); + if (obj && typeof obj === 'object') { + return Object.values(obj).some((v) => deepFindStrings(v, target)); + } + return false; +} + +function getTextValue(params: Record): string | null { + const text = params.WFTextActionText; + if (typeof text === 'string') return text; + if (text && typeof text === 'object') { + const val = (text as Record).Value; + if (val && typeof val === 'object') { + const str = (val as Record).string; + if (typeof str === 'string') return str; + } + } + return null; +} + +function resolveVariables(actions: ShortcutAction[]): Map { + const uuidToText = new Map(); + for (const action of actions) { + if (action.WFWorkflowActionIdentifier === 'is.workflow.actions.gettext') { + const uuid = action.WFWorkflowActionParameters.UUID as string | undefined; + const text = getTextValue(action.WFWorkflowActionParameters); + if (uuid && text !== null) uuidToText.set(uuid, text); + } + } + + const variables = new Map(); + for (const action of actions) { + if (action.WFWorkflowActionIdentifier !== 'is.workflow.actions.setvariable') continue; + const params = action.WFWorkflowActionParameters; + const varName = params.WFVariableName as string | undefined; + const input = params.WFInput as Record | undefined; + if (!varName || !input) continue; + const value = input.Value as Record | undefined; + const sourceUUID = value?.OutputUUID as string | undefined; + if (sourceUUID && uuidToText.has(sourceUUID)) { + variables.set(varName, uuidToText.get(sourceUUID)!); + } + } + return variables; +} + +function extractUrlFromAction(params: Record): { + urlTemplate: string | null; + variableRefs: Map; +} { + const variableRefs = new Map(); + const wfurl = params.WFURL as Record | undefined; + const urlVal = wfurl?.Value as Record | undefined; + if (!urlVal) return { urlTemplate: null, variableRefs }; + + const urlTemplate = (urlVal.string as string) ?? null; + const attachments = urlVal.attachmentsByRange as + | Record> + | undefined; + if (attachments) { + for (const [range, ref] of Object.entries(attachments)) { + if (ref.VariableName) variableRefs.set(range, ref.VariableName); + } + } + return { urlTemplate, variableRefs }; +} + +function extractBodyFields(params: Record): { + bodyFieldKeys: string[]; + bodyVariableRefs: string[]; +} { + const bodyFieldKeys: string[] = []; + const bodyVariableRefs: string[] = []; + const jsonValues = params.WFJSONValues as Record | undefined; + const items = (jsonValues?.Value as Record)?.WFDictionaryFieldValueItems as + | Array> + | undefined; + if (!items) return { bodyFieldKeys, bodyVariableRefs }; + + for (const item of items) { + const keyStr = ((item.WFKey as Record)?.Value as Record) + ?.string as string | undefined; + if (keyStr) bodyFieldKeys.push(keyStr); + const wfValueVal = (item.WFValue as Record)?.Value as + | Record + | undefined; + const attachments = wfValueVal?.attachmentsByRange as + | Record> + | undefined; + if (attachments) { + for (const ref of Object.values(attachments)) { + if (ref.VariableName) bodyVariableRefs.push(ref.VariableName); + } + } + } + return { bodyFieldKeys, bodyVariableRefs }; +} + +function analyzeDownloadAction(action: ShortcutAction): DownloadAnalysis { + const params = action.WFWorkflowActionParameters; + return { + ...extractUrlFromAction(params), + ...extractBodyFields(params) + }; +} + +function resolveUrl( + urlTemplate: string, + variableRefs: Map, + resolvedVars: Map +): string { + const sortedRefs = [...variableRefs.entries()] + .map(([range, varName]) => { + const m = range.match(/\{(\d+),\s*(\d+)\}/); + return m ? { start: parseInt(m[1]), len: parseInt(m[2]), varName } : null; + }) + .filter(Boolean) + .sort((a, b) => b!.start - a!.start) as { start: number; len: number; varName: string }[]; + + let result = urlTemplate; + for (const ref of sortedRefs) { + const value = resolvedVars.get(ref.varName) ?? `{${ref.varName}}`; + result = result.slice(0, ref.start) + value + result.slice(ref.start + ref.len); + } + return result; +} + +function isLocalhostOrigin(origin: string): boolean { + try { + const url = new URL(origin); + const host = url.hostname; + return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'; + } catch { + return false; + } +} + +// ── Fetch + parse shortcut from iCloud ───────────────────────────────── + +async function fetchShortcutPlist( + icloudUrl: string +): Promise< + | { name: string | null; actions: ShortcutAction[]; importQuestions: ImportQuestion[] } + | { error: ValidationResult } +> { + const shortcutId = extractShortcutId(icloudUrl); + if (!shortcutId) { + return { + error: { + name: null, + warnings: [{ code: 'invalid_url', message: 'Not a valid iCloud Shortcuts link.' }] + } + }; + } + + let metadata: Record; + try { + const res = await fetch(`${ICLOUD_API}/${shortcutId}`); + if (!res.ok) { + return { + error: { + name: null, + warnings: [ + { + code: 'fetch_failed', + message: + 'Could not fetch this shortcut from iCloud. The link may be invalid or expired.' + } + ] + } + }; + } + metadata = await res.json(); + } catch (err) { + log.error({ err }, 'Failed to fetch shortcut metadata'); + return { + error: { + name: null, + warnings: [ + { + code: 'fetch_failed', + message: 'Could not reach iCloud to validate the shortcut. Try again later.' + } + ] + } + }; + } + + const fields = metadata.fields as Record> | undefined; + const name = (fields?.name?.value as string) ?? null; + const downloadURL = (fields?.shortcut?.value as Record | undefined) + ?.downloadURL as string | undefined; + + if (!downloadURL) { + return { + error: { + name, + warnings: [ + { + code: 'fetch_failed', + message: 'The shortcut exists on iCloud but has no downloadable file.' + } + ] + } + }; + } + + try { + const plistRes = await fetch(downloadURL); + if (!plistRes.ok) { + return { + error: { + name, + warnings: [ + { code: 'fetch_failed', message: 'Could not download the shortcut file from iCloud.' } + ] + } + }; + } + const buffer = Buffer.from(await plistRes.arrayBuffer()); + const [parsed] = bplist.parseBuffer(buffer); + const plist = parsed as Record; + return { + name, + actions: (plist.WFWorkflowActions ?? []) as ShortcutAction[], + importQuestions: (plist.WFWorkflowImportQuestions ?? []) as ImportQuestion[] + }; + } catch (err) { + log.error({ err }, 'Failed to parse shortcut plist'); + return { + error: { + name, + warnings: [{ code: 'parse_failed', message: 'Could not read the shortcut file.' }] + } + }; + } +} + +// ── Validation checks (each returns warnings) ───────────────────────── + +function checkUrl(resolvedUrl: string, expectedAppUrl: string): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + const normalized = expectedAppUrl.replace(/\/$/, ''); + + if (!resolvedUrl.includes(normalized)) { + const urlMatch = resolvedUrl.match(/^(https?:\/\/[^/]+)/); + const found = urlMatch?.[1]; + if (found && found !== normalized) { + warnings.push({ + code: 'wrong_url', + message: `Wrong instance URL. Points to ${found} — should be ${normalized}.` + }); + } else { + warnings.push({ + code: 'wrong_url', + message: `Instance URL not configured. Should point to ${normalized}.` + }); + } + } + + const originMatch = resolvedUrl.match(/^(https?:\/\/[^/]+)/); + const origin = originMatch?.[1] ?? ''; + if (origin && isLocalhostOrigin(origin)) { + warnings.push({ + code: 'localhost_url', + message: `URL is localhost. Points to ${origin} which only works on your device. Use your public domain.` + }); + } + + if (resolvedUrl.includes('//api/')) { + warnings.push({ + code: 'trailing_slash', + message: 'Trailing slash in URL. Remove it or the API path will break.' + }); + } + + return warnings; +} + +function checkToken(resolvedUrl: string, expectedToken: string): ValidationWarning[] { + if (!expectedToken) return []; + const warnings: ValidationWarning[] = []; + + if (resolvedUrl.includes(`token=${expectedToken}`)) return []; + + const tokenMatch = resolvedUrl.match(/token=([^&\s]+)/); + const found = tokenMatch?.[1]; + + if (found && !found.includes('{') && found !== expectedToken) { + warnings.push({ + code: 'wrong_token', + message: "Token mismatch. The group token in this shortcut doesn't match yours." + }); + } else if (!found || found.includes('{')) { + warnings.push({ + code: 'wrong_token', + message: "Token not configured. The shortcut needs your group's token to authenticate." + }); + } else { + warnings.push({ + code: 'wrong_token', + message: "No token found. The shortcut doesn't send a group token." + }); + } + return warnings; +} + +function checkBodyFields( + bodyFieldKeys: string[], + bodyVariableRefs: string[], + actionParams: Record +): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + if (!bodyFieldKeys.includes('url') && !deepFindStrings(actionParams, 'ExtensionInput')) { + warnings.push({ + code: 'no_url_field', + message: "Missing clip URL. The shortcut doesn't send the shared link." + }); + } + if ( + !bodyFieldKeys.includes('phone') && + !bodyVariableRefs.some((v) => v.toLowerCase().includes('phone')) + ) { + warnings.push({ + code: 'no_phone_field', + message: "Missing phone number. The shortcut doesn't send the user's phone." + }); + } + return warnings; +} + +function isBodyFieldHardcoded(actionParams: Record, fieldName: string): boolean { + const jsonValues = actionParams.WFJSONValues as Record | undefined; + const items = (jsonValues?.Value as Record)?.WFDictionaryFieldValueItems as + | Array> + | undefined; + if (!items) return false; + for (const item of items) { + const keyStr = ((item.WFKey as Record)?.Value as Record) + ?.string as string | undefined; + if (keyStr !== fieldName) continue; + const vVal = (item.WFValue as Record)?.Value as + | Record + | undefined; + const valueStr = vVal?.string as string | undefined; + const attachments = vVal?.attachmentsByRange as Record | undefined; + if (valueStr && (!attachments || Object.keys(attachments).length === 0)) return true; + } + return false; +} + +function checkPhone( + actions: ShortcutAction[], + actionParams: Record, + hasPhoneImportQuestion: boolean +): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + + const phoneAction = actions.find( + (a) => a.WFWorkflowActionIdentifier === 'is.workflow.actions.phonenumber' + ); + const bakedPhone = phoneAction + ? (phoneAction.WFWorkflowActionParameters.WFPhoneNumber as string | undefined) + : undefined; + + if (bakedPhone && !hasPhoneImportQuestion) { + warnings.push({ + code: 'baked_phone', + message: `Phone number hardcoded (${bakedPhone}). Users won't be prompted — all clips will be attributed to this number.` + }); + } + if (isBodyFieldHardcoded(actionParams, 'phone')) { + warnings.push({ + code: 'baked_phone', + message: + 'Phone number hardcoded in request body. All clips will be attributed to the same account.' + }); + } + if (!hasPhoneImportQuestion && !bakedPhone) { + warnings.push({ + code: 'no_phone_prompt', + message: + 'No phone number prompt. Users need to enter their own number for the shortcut to work.' + }); + } + return warnings; +} + +function checkImportQuestions(importQuestions: ImportQuestion[]): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + + const nonPhoneQuestions = importQuestions.filter((q) => { + if (q.ParameterKey === 'WFPhoneNumber') return false; + return !matchesKeywords(q.Text ?? '', PHONE_KEYWORDS); + }); + + if (nonPhoneQuestions.length === 0) return warnings; + + const urlTokenQuestions = nonPhoneQuestions.filter( + (q) => + matchesKeywords(q.Text ?? '', URL_KEYWORDS) || matchesKeywords(q.Text ?? '', TOKEN_KEYWORDS) + ); + const otherQuestions = nonPhoneQuestions.filter( + (q) => + !matchesKeywords(q.Text ?? '', URL_KEYWORDS) && !matchesKeywords(q.Text ?? '', TOKEN_KEYWORDS) + ); + + if (urlTokenQuestions.length > 0) { + const count = urlTokenQuestions.length; + warnings.push({ + code: 'extra_prompts', + message: `${count} extra setup prompt${count > 1 ? 's' : ''} (URL/token). Remove so only the phone number is asked during install.` + }); + } + if (otherQuestions.length > 0) { + warnings.push({ + code: 'extra_prompts', + message: `${otherQuestions.length} extra setup prompt${otherQuestions.length > 1 ? 's' : ''}. Consider removing to simplify install.` + }); + } + return warnings; +} + +// ── Main validator ────────────────────────────────────────────────────── + +export async function validateShortcut( + icloudUrl: string, + expectedAppUrl: string, + expectedToken: string +): Promise { + const fetched = await fetchShortcutPlist(icloudUrl); + if ('error' in fetched) return fetched.error; + + const { name, actions, importQuestions } = fetched; + const warnings: ValidationWarning[] = []; + + // Find the downloadurl action (API call) + const downloadAction = actions.find( + (a) => a.WFWorkflowActionIdentifier === 'is.workflow.actions.downloadurl' + ); + if (!downloadAction) { + return { + name, + warnings: [ + { + code: 'no_api_call', + message: "No API call found. This shortcut doesn't appear to call Scrolly." + } + ] + }; + } + + const resolvedVars = resolveVariables(actions); + const { urlTemplate, variableRefs, bodyFieldKeys, bodyVariableRefs } = + analyzeDownloadAction(downloadAction); + + // Classify import questions + const hasPhoneImportQuestion = importQuestions.some( + (q) => q.ParameterKey === 'WFPhoneNumber' || matchesKeywords(q.Text ?? '', PHONE_KEYWORDS) + ); + + // Check shortcut name + if (name && /needs?\s*setup/i.test(name)) { + warnings.push({ + code: 'bad_name', + message: `Rename the shortcut. "${name}" will confuse members. Use something like "Share to scrolly".` + }); + } + + // Validate URL, token, body, phone, import questions + if (urlTemplate) { + const resolvedUrl = resolveUrl(urlTemplate, variableRefs, resolvedVars); + + if (!resolvedUrl.includes('/api/clips/share')) { + warnings.push({ + code: 'wrong_endpoint', + message: 'Wrong API endpoint. Not targeting /api/clips/share.' + }); + } + + warnings.push(...checkUrl(resolvedUrl, expectedAppUrl)); + warnings.push(...checkToken(resolvedUrl, expectedToken)); + } else { + warnings.push({ code: 'no_api_call', message: "Can't read API URL from the shortcut." }); + } + + warnings.push( + ...checkBodyFields(bodyFieldKeys, bodyVariableRefs, downloadAction.WFWorkflowActionParameters) + ); + warnings.push( + ...checkPhone(actions, downloadAction.WFWorkflowActionParameters, hasPhoneImportQuestion) + ); + warnings.push(...checkImportQuestions(importQuestions)); + + return { name, warnings }; +} diff --git a/src/routes/api/group/shortcut/validate/+server.ts b/src/routes/api/group/shortcut/validate/+server.ts new file mode 100644 index 0000000..39ffbea --- /dev/null +++ b/src/routes/api/group/shortcut/validate/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { withHost, parseBody, isResponse } from '$lib/server/api-utils'; +import { validateShortcut } from '$lib/server/shortcut-validator'; + +export const POST: RequestHandler = withHost(async ({ request, url }, { group }) => { + const body = await parseBody<{ shortcutUrl: string }>(request); + if (isResponse(body)) return body; + + const { shortcutUrl } = body; + if (!shortcutUrl || typeof shortcutUrl !== 'string') { + return json({ error: 'Shortcut URL is required' }, { status: 400 }); + } + + const appUrl = url.origin; + const expectedToken = group.shortcutToken ?? ''; + + const result = await validateShortcut(shortcutUrl.trim(), appUrl, expectedToken); + + return json(result); +}); diff --git a/src/routes/share/setup/+page.server.ts b/src/routes/share/setup/+page.server.ts index 8632a61..2cb3d17 100644 --- a/src/routes/share/setup/+page.server.ts +++ b/src/routes/share/setup/+page.server.ts @@ -1,13 +1,14 @@ import { redirect } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, url }) => { if (!locals.user) { redirect(302, '/join'); } return { - appUrl: env.PUBLIC_APP_URL || 'http://localhost:3000' + appUrl: url.origin, + shortcutToken: locals.group?.shortcutToken ?? null, + hostPhone: locals.user.phone }; }; diff --git a/src/routes/share/setup/+page.svelte b/src/routes/share/setup/+page.svelte index 0910707..494228a 100644 --- a/src/routes/share/setup/+page.svelte +++ b/src/routes/share/setup/+page.svelte @@ -1,23 +1,58 @@ @@ -55,7 +167,16 @@
+ {#if !allDone} +
+ +

+ Setting up the Share Shortcut requires careful attention. If any step is done incorrectly, + your group members will get errors when they try to share clips. Follow each step exactly. +

+
+
{#each Array(totalSteps) as _, i (i)} @@ -76,59 +197,202 @@ {/if} - +

Set up Share Shortcut

+ Difficulty: Intermediate

Create an iOS Shortcut so your group can share clips directly from TikTok, Instagram, and - other apps. + other apps — without opening scrolly.

- Tap the button below to download the pre-built shortcut template. It has everything - configured — you just need to update the URL. + Tap the button below to install the template shortcut. When it opens, you'll be prompted + with setup questions — fill them in using the values from the next step.

- + Get Template Shortcut
- +

- Open the shortcut you just downloaded in the Shortcuts app. You'll see two actions that - reference a URL — update both to your scrolly instance: + When you install the template, iOS will ask you to fill in setup questions. Copy and paste + each value exactly as shown:

-
- {appUrl} + +
+ Instance URL +

Your scrolly server address — no trailing slash.

+
+ {appUrl} + +
+
+ +
+ Group Token +

Unique to your group. Identifies where shared clips go.

+
+ {shortcutToken} + +
+ +
+ Your Phone Number +

+ Enter your number for now. Group members will enter theirs when they install. +

+
+ {hostPhone} + +
+
+ +

+ After filling in all prompts, tap "Add Shortcut" to finish installing. +

+ + + +

+ This is the most important step. Your group members should only be asked for + their phone number when they install — not the URL or token. +

- Look for the "Get Contents of URL" action and the - "Open URLs" action inside the "Otherwise" block. Replace the placeholder URL - in each with your URL above. + In the Shortcuts app, long-press your new shortcut and tap the icon (or + tap "Details"). Scroll down to the "Setup" section.

-
- +

+ Delete every Import Question except the one for + phone number. The URL and token are already baked into the shortcut — only + the phone number needs to be asked on install. +

+
+ - The shortcut uses your browser's login session to identify who shared the clip. Group - members need to be logged into scrolly in Safari for it to work automatically. + If you skip this step, your group members will be asked for the URL and token when they + install — they won't know what to enter and the shortcut will break.
- +

- Long-press your customized shortcut and choose + Long-press your customized shortcut in the Shortcuts app and choose "Share", then "Copy iCloud Link".

- Go back to Settings → iOS Shortcut in scrolly and paste the iCloud link. - Once saved, your group members will see a "Get Shortcut" button in their settings - to install it with one tap. + Paste the iCloud link below. We'll check that it's configured correctly before saving.

+
+ + {#if icloudLinkError} +

{icloudLinkError}

+ {:else if isTemplateLink} +

+ This is the template shortcut link, not your customized one. Share your edited shortcut + from the Shortcuts app to get a new iCloud link. +

+ {/if} +
+ + {#if !validated} + + {/if} + + {#if hasWarnings} +
+ {#if shortcutName} +

Shortcut: {shortcutName}

+ {/if} +

+ Review each issue. Check off items you've addressed or want to skip: +

+ {#each validationWarnings as warning, i (i)} + + {/each} +
+ +
+ + +
+ {/if}
{#if allDone} - + {/if} @@ -142,12 +406,12 @@ {:else}
{/if} - + + {/if}
{/if}
@@ -194,6 +458,27 @@ padding: var(--space-xl) var(--space-lg); padding-bottom: calc(var(--space-3xl) + env(safe-area-inset-bottom, 0px)); } + .warning-card { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-md); + background: color-mix(in srgb, var(--warning) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); + border-radius: var(--radius-md); + margin-bottom: var(--space-xl); + } + .warning-card :global(svg) { + flex-shrink: 0; + color: var(--warning); + margin-top: 1px; + } + .warning-card p { + font-size: 0.9375rem; + color: var(--text-secondary); + line-height: 1.5; + margin: 0; + } .progress-dots { display: flex; gap: var(--space-sm); @@ -229,18 +514,16 @@ height: 14px; color: var(--bg-primary); } - .setup-title { - font-family: var(--font-display); - font-size: 1.5rem; - font-weight: 800; - color: var(--text-primary); - margin: 0 0 var(--space-xs); - } - .setup-subtitle { - font-size: 0.875rem; - color: var(--text-muted); - margin: 0 0 var(--space-xl); - line-height: 1.5; + .difficulty-badge { + display: inline-flex; + align-self: flex-start; + padding: var(--space-xs) var(--space-md); + background: color-mix(in srgb, var(--warning) 12%, transparent); + color: var(--warning); + font-size: 0.75rem; + font-weight: 700; + border-radius: var(--radius-full); + margin-bottom: var(--space-md); } .icloud-btn { display: flex; @@ -302,4 +585,164 @@ font-weight: 700; margin-left: auto; } + .icloud-input-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + .icloud-input { + width: 100%; + padding: var(--space-sm) var(--space-md); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9375rem; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; + } + .icloud-input::placeholder { + color: var(--text-muted); + } + .icloud-input:focus { + border-color: var(--accent-primary); + } + .icloud-input.invalid:not(:focus) { + border-color: var(--error); + } + .icloud-input:disabled { + opacity: 0.5; + } + .input-error { + font-size: 0.8125rem; + color: var(--error); + margin: 0; + } + .save-link-btn { + width: 100%; + margin-top: var(--space-md); + padding: var(--space-md) var(--space-xl); + background: var(--accent-primary); + color: var(--bg-primary); + border: none; + border-radius: var(--radius-full); + font-size: 1rem; + font-weight: 700; + font-family: var(--font-display); + cursor: pointer; + transition: transform 0.1s ease; + } + .save-link-btn:active:not(:disabled) { + transform: scale(0.97); + } + .save-link-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .setup-field { + margin-bottom: var(--space-lg); + } + .setup-field:last-of-type { + margin-bottom: var(--space-md); + } + .field-label { + font-family: var(--font-display); + font-size: 0.9375rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--space-xs); + } + .field-hint { + font-size: 0.8125rem; + color: var(--text-muted); + margin: 0 0 var(--space-sm); + line-height: 1.4; + } + .validation-results { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-lg); + } + .shortcut-name { + font-size: 0.8125rem; + color: var(--text-secondary); + margin: 0; + } + .shortcut-name strong { + color: var(--text-primary); + } + .checklist-hint { + font-size: 0.8125rem; + color: var(--text-muted); + margin: 0; + } + .validation-warning-check { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: color-mix(in srgb, var(--warning) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); + border-radius: var(--radius-sm); + cursor: pointer; + transition: opacity 0.2s ease; + } + .validation-warning-check input[type='checkbox'] { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-top: 2px; + accent-color: var(--accent-primary); + cursor: pointer; + } + .validation-warning-check :global(svg) { + flex-shrink: 0; + color: var(--warning); + margin-top: 2px; + transition: opacity 0.2s ease; + } + .validation-warning-check span { + font-size: 0.9375rem; + color: var(--text-secondary); + line-height: 1.5; + transition: all 0.2s ease; + } + .validation-warning-check span :global(b) { + color: var(--text-primary); + font-weight: 600; + } + .validation-warning-check.dismissed { + opacity: 0.5; + } + .validation-warning-check.dismissed span { + text-decoration: line-through; + color: var(--text-muted); + } + .validation-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-md); + } + .validation-actions .save-link-btn { + flex: 1; + margin-top: 0; + } + .revalidate-btn { + background: var(--accent-primary) !important; + color: var(--bg-primary) !important; + } + .skip-btn { + background: var(--bg-surface) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border) !important; + } + :global(.info-box.warn) { + background: color-mix(in srgb, var(--warning) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); + } + :global(.info-box.warn svg) { + color: var(--warning); + } From 473c1d67f5f0e1595ca2c06747521977dede2d7a Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:08:21 -0600 Subject: [PATCH 02/12] fix: token comparison uses exact match, add security warning and re-share tip - Fix token validation using substring includes() which missed near-matches (e.g. token with extra char appended). Now extracts exact token and compares with strict equality. - Add security warning on done state: only share shortcut with trusted people. - Add tip below checklist: re-share the shortcut link after making changes. --- .../components/settings/SetupDoneState.svelte | 255 +++++++++++++++++- src/lib/server/shortcut-validator.ts | 116 +++----- src/routes/share/setup/+page.svelte | 14 + 3 files changed, 296 insertions(+), 89 deletions(-) diff --git a/src/lib/components/settings/SetupDoneState.svelte b/src/lib/components/settings/SetupDoneState.svelte index 5e646c9..7737ffe 100644 --- a/src/lib/components/settings/SetupDoneState.svelte +++ b/src/lib/components/settings/SetupDoneState.svelte @@ -1,7 +1,30 @@
@@ -13,6 +36,56 @@ Now when you tap Share in any app and choose your shortcut, the clip will be added to scrolly in the background.

+ + +
+

What your members will see

+
+
+ +
+ Share from other apps + Install the iOS Shortcut to share clips directly +
+ Get +
+
+
    +
  • + + iOS members will see this card with a Get button to install the shortcut +
  • +
  • + + Android members see a note that sharing works automatically via the installed app +
  • +
+
+ + + + Back to Settings
@@ -44,12 +117,186 @@ margin: 0 0 var(--space-sm); } .done-desc { - font-size: 0.875rem; + font-size: 1rem; color: var(--text-secondary); line-height: 1.5; - margin: 0 0 var(--space-xl); - max-width: 320px; + margin: 0 0 var(--space-2xl); + max-width: 340px; + } + + /* --- Section headings --- */ + .section-heading { + font-family: var(--font-display); + font-size: 0.8125rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin: 0 0 var(--space-md); + } + + /* --- Preview section --- */ + .preview-section { + width: 100%; + margin-bottom: var(--space-2xl); + } + + .preview-frame { + background: var(--bg-surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + pointer-events: none; + margin-bottom: var(--space-md); + } + + .preview-cta { + display: flex; + align-items: center; + gap: var(--space-md); + background: var(--bg-elevated); + border-radius: var(--radius-lg); + padding: var(--space-lg); + } + + .preview-cta :global(svg:first-child) { + flex-shrink: 0; + color: var(--accent-primary); + } + + .preview-cta-content { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; + text-align: left; + } + + .preview-cta-title { + font-family: var(--font-display); + font-size: 0.9375rem; + font-weight: 700; + color: var(--text-primary); + } + + .preview-cta-desc { + font-size: 0.8125rem; + color: var(--text-secondary); + } + + .preview-cta-btn { + flex-shrink: 0; + background: var(--accent-primary); + color: var(--bg-primary); + font-size: 0.8125rem; + font-weight: 700; + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-full); } + + .preview-notes { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); + text-align: left; + } + + .preview-notes li { + font-size: 0.9375rem; + color: var(--text-secondary); + line-height: 1.5; + padding-left: var(--space-xl); + position: relative; + } + + .preview-notes li :global(.platform-icon) { + position: absolute; + left: 0; + top: 0.3em; + color: var(--text-muted); + } + + .preview-notes li strong { + color: var(--text-primary); + } + + /* --- Share section --- */ + .share-section { + width: 100%; + margin-bottom: var(--space-2xl); + } + + .share-desc { + font-size: 0.9375rem; + color: var(--text-secondary); + margin: 0 0 var(--space-md); + line-height: 1.5; + } + + .security-note { + font-size: 0.8125rem; + color: var(--text-secondary); + line-height: 1.5; + padding: var(--space-sm) var(--space-md); + background: color-mix(in srgb, var(--warning) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 15%, transparent); + border-radius: var(--radius-sm); + margin-bottom: var(--space-md); + } + + .security-note strong { + color: var(--warning); + } + + .link-display { + display: flex; + align-items: center; + gap: var(--space-sm); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-sm) var(--space-md); + } + + .link-display code { + flex: 1; + font-size: 0.8125rem; + color: var(--accent-primary); + word-break: break-all; + font-family: monospace; + text-align: left; + } + + .copy-btn { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-full); + color: var(--text-primary); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.1s ease; + } + + .copy-btn:active { + transform: scale(0.97); + } + + .copy-btn :global(svg) { + width: 14px; + height: 14px; + } + + /* --- Back button --- */ .done-btn { display: inline-flex; padding: var(--space-sm) var(--space-xl); diff --git a/src/lib/server/shortcut-validator.ts b/src/lib/server/shortcut-validator.ts index 7a131a4..4951c3c 100644 --- a/src/lib/server/shortcut-validator.ts +++ b/src/lib/server/shortcut-validator.ts @@ -11,8 +11,6 @@ const log = createLogger('shortcut-validator'); const ICLOUD_API = 'https://www.icloud.com/shortcuts/api/records'; -// ── Types ────────────────────────────────────────────────────────────── - export interface ValidationWarning { code: string; message: string; @@ -43,8 +41,6 @@ interface DownloadAnalysis { bodyVariableRefs: string[]; } -// ── Keyword lists for classifying import questions ───────────────────── - const PHONE_KEYWORDS = ['phone', 'number', 'phone number']; const URL_KEYWORDS = ['url', 'instance', 'server', 'address', 'domain']; const TOKEN_KEYWORDS = ['token', 'key', 'secret', 'group token']; @@ -54,8 +50,6 @@ function matchesKeywords(text: string, keywords: string[]): boolean { return keywords.some((kw) => lower.includes(kw)); } -// ── Helpers ───────────────────────────────────────────────────────────── - function extractShortcutId(icloudUrl: string): string | null { const m = icloudUrl.match(/\/shortcuts\/([a-f0-9]{32})\/?$/i); return m ? m[1] : null; @@ -200,55 +194,36 @@ function isLocalhostOrigin(origin: string): boolean { } } -// ── Fetch + parse shortcut from iCloud ───────────────────────────────── - -async function fetchShortcutPlist( - icloudUrl: string -): Promise< +type FetchResult = | { name: string | null; actions: ShortcutAction[]; importQuestions: ImportQuestion[] } - | { error: ValidationResult } -> { + | { error: ValidationResult }; + +function fail(name: string | null, code: string, message: string): { error: ValidationResult } { + return { error: { name, warnings: [{ code, message }] } }; +} + +async function fetchShortcutPlist(icloudUrl: string): Promise { const shortcutId = extractShortcutId(icloudUrl); - if (!shortcutId) { - return { - error: { - name: null, - warnings: [{ code: 'invalid_url', message: 'Not a valid iCloud Shortcuts link.' }] - } - }; - } + if (!shortcutId) return fail(null, 'invalid_url', 'Not a valid iCloud Shortcuts link.'); let metadata: Record; try { const res = await fetch(`${ICLOUD_API}/${shortcutId}`); if (!res.ok) { - return { - error: { - name: null, - warnings: [ - { - code: 'fetch_failed', - message: - 'Could not fetch this shortcut from iCloud. The link may be invalid or expired.' - } - ] - } - }; + return fail( + null, + 'fetch_failed', + 'Could not fetch this shortcut from iCloud. The link may be invalid or expired.' + ); } metadata = await res.json(); } catch (err) { log.error({ err }, 'Failed to fetch shortcut metadata'); - return { - error: { - name: null, - warnings: [ - { - code: 'fetch_failed', - message: 'Could not reach iCloud to validate the shortcut. Try again later.' - } - ] - } - }; + return fail( + null, + 'fetch_failed', + 'Could not reach iCloud to validate the shortcut. Try again later.' + ); } const fields = metadata.fields as Record> | undefined; @@ -256,32 +231,17 @@ async function fetchShortcutPlist( const downloadURL = (fields?.shortcut?.value as Record | undefined) ?.downloadURL as string | undefined; - if (!downloadURL) { - return { - error: { - name, - warnings: [ - { - code: 'fetch_failed', - message: 'The shortcut exists on iCloud but has no downloadable file.' - } - ] - } - }; - } + if (!downloadURL) + return fail( + name, + 'fetch_failed', + 'The shortcut exists on iCloud but has no downloadable file.' + ); try { const plistRes = await fetch(downloadURL); - if (!plistRes.ok) { - return { - error: { - name, - warnings: [ - { code: 'fetch_failed', message: 'Could not download the shortcut file from iCloud.' } - ] - } - }; - } + if (!plistRes.ok) + return fail(name, 'fetch_failed', 'Could not download the shortcut file from iCloud.'); const buffer = Buffer.from(await plistRes.arrayBuffer()); const [parsed] = bplist.parseBuffer(buffer); const plist = parsed as Record; @@ -292,17 +252,10 @@ async function fetchShortcutPlist( }; } catch (err) { log.error({ err }, 'Failed to parse shortcut plist'); - return { - error: { - name, - warnings: [{ code: 'parse_failed', message: 'Could not read the shortcut file.' }] - } - }; + return fail(name, 'parse_failed', 'Could not read the shortcut file.'); } } -// ── Validation checks (each returns warnings) ───────────────────────── - function checkUrl(resolvedUrl: string, expectedAppUrl: string): ValidationWarning[] { const warnings: ValidationWarning[] = []; const normalized = expectedAppUrl.replace(/\/$/, ''); @@ -346,12 +299,12 @@ function checkToken(resolvedUrl: string, expectedToken: string): ValidationWarni if (!expectedToken) return []; const warnings: ValidationWarning[] = []; - if (resolvedUrl.includes(`token=${expectedToken}`)) return []; - const tokenMatch = resolvedUrl.match(/token=([^&\s]+)/); const found = tokenMatch?.[1]; - if (found && !found.includes('{') && found !== expectedToken) { + if (found === expectedToken) return []; + + if (found && !found.includes('{')) { warnings.push({ code: 'wrong_token', message: "Token mismatch. The group token in this shortcut doesn't match yours." @@ -361,11 +314,6 @@ function checkToken(resolvedUrl: string, expectedToken: string): ValidationWarni code: 'wrong_token', message: "Token not configured. The shortcut needs your group's token to authenticate." }); - } else { - warnings.push({ - code: 'wrong_token', - message: "No token found. The shortcut doesn't send a group token." - }); } return warnings; } @@ -486,8 +434,6 @@ function checkImportQuestions(importQuestions: ImportQuestion[]): ValidationWarn return warnings; } -// ── Main validator ────────────────────────────────────────────────────── - export async function validateShortcut( icloudUrl: string, expectedAppUrl: string, diff --git a/src/routes/share/setup/+page.svelte b/src/routes/share/setup/+page.svelte index 494228a..4e4a80e 100644 --- a/src/routes/share/setup/+page.svelte +++ b/src/routes/share/setup/+page.svelte @@ -373,6 +373,10 @@ {@html warning.message} {/each} +

+ After making changes, close the shortcut editor and use + Share → Copy iCloud Link again to get an updated link. +

@@ -678,6 +682,16 @@ color: var(--text-muted); margin: 0; } + .checklist-tip { + font-size: 0.8125rem; + color: var(--text-muted); + margin: 0; + font-style: italic; + } + .checklist-tip strong { + color: var(--text-secondary); + font-style: normal; + } .validation-warning-check { display: flex; align-items: flex-start; From 5f909fc56ec94df886ccd091ca7a59293f0242d5 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:05:34 -0600 Subject: [PATCH 03/12] refactor: improve mobile input handling and code entry UX - Refactor SMS code input from 6 individual inputs to single hidden input with visual slots (fixes Android IME paste truncation) - Add inputmode and autocomplete attributes to URL, mention, and caption inputs - Update viewport meta for better PWA behavior (interactive-widget, safe-area) - Increase root font-size to 18px for mobile legibility --- src/app.html | 4 +- src/lib/components/AddVideo.svelte | 3 +- src/lib/components/MentionInput.svelte | 2 + src/lib/components/ReelOverlayActions.svelte | 1 + src/routes/+layout.svelte | 1 + src/routes/join/+page.svelte | 121 +++++++------------ 6 files changed, 52 insertions(+), 80 deletions(-) diff --git a/src/app.html b/src/app.html index e74f1f3..59afad6 100644 --- a/src/app.html +++ b/src/app.html @@ -2,7 +2,7 @@ - + @@ -15,7 +15,7 @@ - + diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index 8acb401..1156022 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -187,6 +187,8 @@ (['', '', '', '', '', '']); - const codeInputs = $state([]); + // Single hidden input + visual slots (avoids Android IME maxlength paste truncation) + let codeInputEl = $state(null); - function handleCodeInput(index: number, e: Event) { + function handleCodeInput(e: Event) { 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 - if (index < 5) { - codeInputs[index + 1]?.focus(); - } - } else { - codeDigits[index] = ''; - } - code = codeDigits.join(''); - } - - function handleCodeKeydown(index: number, e: KeyboardEvent) { - if (e.key === 'Backspace') { - if (codeDigits[index] === '' && index > 0) { - codeInputs[index - 1]?.focus(); - codeDigits[index - 1] = ''; - code = codeDigits.join(''); - } else { - codeDigits[index] = ''; - code = codeDigits.join(''); - } - } - } - - function handleCodePaste(e: ClipboardEvent) { - e.preventDefault(); - const pasted = (e.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6); - for (let i = 0; i < 6; i++) { - codeDigits[i] = pasted[i] || ''; - } - code = codeDigits.join(''); - // Focus the next empty input or the last one - const nextEmpty = codeDigits.findIndex((d) => d === ''); - codeInputs[nextEmpty === -1 ? 5 : nextEmpty]?.focus(); + code = input.value.replace(/\D/g, '').slice(0, 6); + input.value = code; } @@ -269,22 +222,26 @@ 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 !== ''} - bind:this={codeInputs[i]} - autocomplete={i === 0 ? 'one-time-code' : 'off'} - /> + +
codeInputEl?.focus()}> + + {#each Array(6) as _, i (i)} +
+ {code[i] || ''} +
{/each}
@@ -313,7 +270,6 @@ view = 'login'; error = ''; code = ''; - codeDigits = ['', '', '', '', '', '']; }} > Change number @@ -495,18 +451,30 @@ outline: none; } - /* --- Code inputs --- */ - .code-inputs { + /* --- Code slots (single hidden input + visual slots) --- */ + .code-slots { + position: relative; display: flex; gap: var(--space-sm); justify-content: center; margin-bottom: var(--space-sm); + cursor: text; + } + + .code-hidden-input { + position: absolute; + inset: 0; + opacity: 0; + font-size: 1rem; + caret-color: transparent; } - .code-box { + .code-slot { width: 46px; height: 56px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; font-family: var(--font-display); font-size: 1.5rem; font-weight: 700; @@ -517,16 +485,15 @@ transition: border-color 0.2s ease, background 0.2s ease; - padding: 0; + pointer-events: none; } - .code-box:focus { - outline: none; + .code-slot.active { border-color: var(--accent-primary); background: var(--bg-subtle); } - .code-box.filled { + .code-slot.filled { border-color: var(--accent-primary); } From a77790e979812b611ebcf054ece0a517d6625220 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:05:44 -0600 Subject: [PATCH 04/12] style: redesign toast notifications with larger icons and prominent styling - Enlarge toast icons with circular colored backgrounds - Add processing state with accent-primary tint - Redesign action button with primary background and pill shape - Update backgrounds to use color-mix for themed tints --- src/lib/components/ToastStack.svelte | 70 +++++++++++++++++----------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/src/lib/components/ToastStack.svelte b/src/lib/components/ToastStack.svelte index 5e17194..03db870 100644 --- a/src/lib/components/ToastStack.svelte +++ b/src/lib/components/ToastStack.svelte @@ -176,37 +176,42 @@ .toast { display: flex; align-items: center; - gap: var(--space-sm); - padding: var(--space-md) var(--space-md); + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); color: var(--text-primary); font-family: var(--font-body); - font-size: 0.8125rem; + font-size: 0.875rem; + font-weight: 500; pointer-events: auto; - animation: toast-in 0.3s cubic-bezier(0.32, 0.72, 0, 1); + animation: toast-in 0.35s cubic-bezier(0.32, 0.72, 0, 1); position: relative; overflow: hidden; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); } .toast-success { - border-color: color-mix(in srgb, var(--success) 40%, transparent); + background: color-mix(in srgb, var(--success) 10%, var(--bg-elevated)); } .toast-error { - border-color: color-mix(in srgb, var(--error) 40%, transparent); + background: color-mix(in srgb, var(--error) 10%, var(--bg-elevated)); } .toast-info { - border-color: color-mix(in srgb, var(--accent-blue) 40%, transparent); + background: color-mix(in srgb, var(--accent-blue) 10%, var(--bg-elevated)); + } + + .toast-processing { + background: color-mix(in srgb, var(--accent-primary) 8%, var(--bg-elevated)); } .toast-icon { flex-shrink: 0; - width: 18px; - height: 18px; + width: 28px; + height: 28px; + border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center; @@ -218,17 +223,24 @@ } .toast-success .toast-icon { + background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); } .toast-error .toast-icon { + background: color-mix(in srgb, var(--error) 20%, transparent); color: var(--error); } .toast-info .toast-icon { + background: color-mix(in srgb, var(--accent-blue) 20%, transparent); color: var(--accent-blue); } + .toast-processing .toast-icon { + background: color-mix(in srgb, var(--accent-primary) 15%, transparent); + } + .toast-message { flex: 1; min-width: 0; @@ -237,19 +249,20 @@ .toast-view { flex-shrink: 0; - background: none; + background: var(--accent-primary); border: none; - color: var(--accent-primary); + color: var(--bg-primary); cursor: pointer; - padding: 2px 4px; - font-family: var(--font-body); - font-size: 0.8125rem; - font-weight: 600; - transition: opacity 0.15s ease; + padding: var(--space-xs) var(--space-md); + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 700; + border-radius: var(--radius-full); + transition: transform 0.15s ease; } .toast-view:active { - opacity: 0.7; + transform: scale(0.95); } .toast-dismiss { @@ -258,15 +271,16 @@ border: none; color: var(--text-muted); cursor: pointer; - padding: 2px; + padding: var(--space-xs); display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-full); transition: color 0.15s ease; } .toast-dismiss:active { - color: var(--text-primary); + color: var(--text-secondary); } .toast-dismiss :global(svg) { @@ -279,22 +293,22 @@ bottom: 0; left: 0; right: 0; - height: 2px; - background: var(--bg-subtle); + height: 3px; + background: color-mix(in srgb, var(--accent-primary) 20%, transparent); } .progress-bar { height: 100%; width: 40%; background: var(--accent-primary); - border-radius: 1px; + border-radius: 2px; animation: indeterminate 1.5s ease-in-out infinite; } .spinner-ring { width: 16px; height: 16px; - border: 2px solid var(--bg-subtle); + border: 2px solid color-mix(in srgb, var(--accent-primary) 25%, transparent); border-top-color: var(--accent-primary); border-radius: var(--radius-full); animation: spin 0.8s linear infinite; @@ -321,7 +335,7 @@ @keyframes toast-in { from { opacity: 0; - transform: translateY(12px) scale(0.97); + transform: translateY(16px) scale(0.95); } to { opacity: 1; @@ -336,7 +350,7 @@ @keyframes toast-out { to { opacity: 0; - transform: translateY(12px) scale(0.97); + transform: translateY(16px) scale(0.95); } } From ea2e02569044164afaecd9e96f09e00bded88f85 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:05:53 -0600 Subject: [PATCH 05/12] feat: improve iOS Shortcut share security and error messages - Use exact token comparison instead of substring match - Refactor share API to accept single phone string - Provide user-friendly shortcut-optimized error messages - Add iCloud shortcut URL validation regex - Increase session cookie max age to 10 years - Resolve default group for unauthenticated request branding - Fix lint errors in shortcut-validator.ts (reduce line count) --- src/hooks.server.ts | 8 +- src/lib/server/auth.ts | 7 +- src/lib/server/shortcut-validator.ts | 9 +- src/routes/api/clips/share/+server.ts | 116 ++++++++++++++++------- src/routes/api/group/shortcut/+server.ts | 9 +- 5 files changed, 105 insertions(+), 44 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index eb0085e..c46ca98 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,5 @@ import type { Handle, RequestEvent } from '@sveltejs/kit'; -import { getUserIdFromCookies, getUserWithGroup } from '$lib/server/auth'; +import { getUserIdFromCookies, getUserWithGroup, getDefaultGroup } from '$lib/server/auth'; import { getAccentColor } from '$lib/colors'; import { startScheduler } from '$lib/server/scheduler'; import { createLogger } from '$lib/server/logger'; @@ -103,6 +103,12 @@ export const handle: Handle = async ({ event, resolve }) => { } } + // For unauthenticated requests, still resolve the group so accent color, + // dynamic icons, and PWA branding work on /join, /onboard, etc. + if (!event.locals.group) { + event.locals.group = await getDefaultGroup(); + } + const response = await resolve(event, { transformPageChunk: ({ html }) => { const theme = event.locals.user?.themePreference; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 8de2484..13cbe04 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -5,7 +5,7 @@ import crypto from 'crypto'; import { env } from '$env/dynamic/private'; const COOKIE_NAME = 'scrolly_session'; -const MAX_AGE = 60 * 60 * 24 * 365; // 1 year +const MAX_AGE = 60 * 60 * 24 * 365 * 10; // 10 years function getSecret(): string { const secret = env.SESSION_SECRET; @@ -71,3 +71,8 @@ export async function validateInviteCode(code: string) { }); return group ?? null; } + +export async function getDefaultGroup() { + const group = await db.query.groups.findFirst(); + return group ?? null; +} diff --git a/src/lib/server/shortcut-validator.ts b/src/lib/server/shortcut-validator.ts index 4951c3c..d0494f6 100644 --- a/src/lib/server/shortcut-validator.ts +++ b/src/lib/server/shortcut-validator.ts @@ -495,12 +495,9 @@ export async function validateShortcut( warnings.push({ code: 'no_api_call', message: "Can't read API URL from the shortcut." }); } - warnings.push( - ...checkBodyFields(bodyFieldKeys, bodyVariableRefs, downloadAction.WFWorkflowActionParameters) - ); - warnings.push( - ...checkPhone(actions, downloadAction.WFWorkflowActionParameters, hasPhoneImportQuestion) - ); + const dlParams = downloadAction.WFWorkflowActionParameters; + warnings.push(...checkBodyFields(bodyFieldKeys, bodyVariableRefs, dlParams)); + warnings.push(...checkPhone(actions, dlParams, hasPhoneImportQuestion)); warnings.push(...checkImportQuestions(importQuestions)); return { name, warnings }; diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index d7bf499..5494d4b 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -4,7 +4,7 @@ import { db } from '$lib/server/db'; import { groups, users, clips, watched } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; -import { normalizePhones } from '$lib/server/phone'; +import { normalizePhone } from '$lib/server/phone'; import { isSupportedUrl, detectPlatform, @@ -16,68 +16,116 @@ import { downloadVideo } from '$lib/server/video/download'; import { downloadMusic } from '$lib/server/music/download'; import { normalizeUrl } from '$lib/server/download-lock'; import { getActiveProvider } from '$lib/server/providers/registry'; -import { parseBody, isResponse } from '$lib/server/api-utils'; import { createLogger } from '$lib/server/logger'; const log = createLogger('share'); +/** Shortcut-friendly JSON response. Every response includes `success` (1|0) and `message`. */ +function shareResponse( + success: boolean, + message: string, + status: number, + extra?: Record +) { + return json({ success: success ? 1 : 0, message, ...extra }, { status }); +} + type AuthUser = { id: string; groupId: string | null; phone: string }; type AuthResult = { user: AuthUser; group: typeof groups.$inferSelect } | { error: Response }; -/** Authenticate via token + phone numbers (backwards-compat path). */ +/** Authenticate via shortcut token + single phone number. */ async function authenticateWithToken( tokenParam: string | null, - phones: string[] | undefined + phone: string | undefined ): Promise { + // Missing or invalid token = host misconfiguration if (!tokenParam) { - return { error: json({ error: 'Not logged in' }, { status: 401 }) }; + return { + error: shareResponse( + false, + "❌ This shortcut isn't set up correctly. Ask your group host to re-share it.", + 401 + ) + }; } const group = await db.query.groups.findFirst({ where: eq(groups.shortcutToken, tokenParam) }); if (!group) { - return { error: json({ error: 'Invalid token' }, { status: 401 }) }; + return { + error: shareResponse( + false, + "❌ This shortcut isn't set up correctly. Ask your group host to re-share it.", + 401 + ) + }; } - if (!phones || !Array.isArray(phones) || phones.length === 0) { - return { error: json({ error: 'Phone numbers required for token auth' }, { status: 400 }) }; + // Missing phone = user misconfiguration (Import Question not set) + if (!phone || typeof phone !== 'string' || !phone.trim()) { + return { + error: shareResponse( + false, + '❌ This shortcut is missing your phone number. Delete it and install it again from your group.', + 400 + ) + }; } - const normalizedPhones = normalizePhones(phones as string[]); - if (normalizedPhones.length === 0) { - return { error: json({ error: 'No valid phone numbers provided' }, { status: 400 }) }; + // Phone can't be normalized = user entered something unrecognizable + const normalized = normalizePhone(phone); + if (!normalized) { + return { + error: shareResponse( + false, + "❌ Your phone number couldn't be recognized. Delete the shortcut and install it again — enter your number with area code.", + 400 + ) + }; } const groupMembers = await db.query.users.findMany({ where: and(eq(users.groupId, group.id), isNull(users.removedAt)) }); - const matchedUser = groupMembers.find((u) => normalizedPhones.includes(u.phone)); + const matchedUser = groupMembers.find((u) => u.phone === normalized); if (!matchedUser) { - return { error: json({ error: 'No matching user found in this group' }, { status: 403 }) }; + return { + error: shareResponse( + false, + '❌ No account matches this phone number. Delete the shortcut and install it again with the phone number you signed up with.', + 403 + ) + }; } return { user: matchedUser, group }; } export const POST: RequestHandler = async ({ request, url, locals }) => { - // 1. Parse body upfront (before auth, since token path needs phones from body) - const body = await parseBody<{ url?: string; phones?: string[] }>(request); - if (isResponse(body)) return body; + // 1. Parse body + let body: { url?: string; phone?: string; phones?: string[] }; + try { + body = await request.json(); + } catch { + return shareResponse(false, '❌ Something went wrong. Try sharing again.', 400); + } - const { url: videoUrl, phones } = body; + const videoUrl = body.url; + // Support `phone` (string, new) or first element of `phones` (array, legacy fallback) + const phone = body.phone || (Array.isArray(body.phones) ? body.phones[0] : undefined); if (!videoUrl || typeof videoUrl !== 'string') { - return json({ error: 'URL required' }, { status: 400 }); + return shareResponse(false, '❌ No link found. Try sharing again from the app.', 400); } - // 2. Authenticate — cookie first, then token+phone fallback + // 2. Authenticate — cookie first, then token+phone let authResult: AuthResult; if (locals.user && locals.group) { authResult = { user: locals.user, group: locals.group }; } else { - authResult = await authenticateWithToken(url.searchParams.get('token'), phones); + authResult = await authenticateWithToken(url.searchParams.get('token'), phone); } if ('error' in authResult) return authResult.error; @@ -86,20 +134,19 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { // 3. Check download provider const provider = await getActiveProvider(group.id); if (!provider) { - return json( - { error: 'No download provider configured. Ask your group host to set one up in Settings.' }, - { status: 400 } + return shareResponse( + false, + "❌ Downloads aren't set up yet. Ask your group host to configure one in scrolly settings.", + 400 ); } // 4. Validate URL if (!isSupportedUrl(videoUrl)) { - return json( - { - error: - 'Unsupported URL. Try a link from TikTok, YouTube, Instagram, X, Reddit, Spotify, or other supported platforms.' - }, - { status: 400 } + return shareResponse( + false, + "❌ This link isn't supported. Try sharing from TikTok, Instagram, YouTube, or other supported apps.", + 400 ); } @@ -107,9 +154,10 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { const platform = detectPlatform(videoUrl)!; const filterList = group.platformFilterList ? JSON.parse(group.platformFilterList) : null; if (!isPlatformAllowed(platform, group.platformFilterMode, filterList)) { - return json( - { error: `${platformLabel(videoUrl) || platform} links are not allowed in this group` }, - { status: 400 } + return shareResponse( + false, + `❌ ${platformLabel(videoUrl) || platform} links aren't allowed in this group.`, + 400 ); } @@ -122,7 +170,7 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { where: and(eq(clips.groupId, group.id), eq(clips.originalUrl, normalizedVideoUrl)) }); if (existing) { - return json({ error: 'This link has already been added to the feed.' }, { status: 409 }); + return shareResponse(false, '❌ This clip has already been shared!', 409); } // 8. Create clip @@ -164,5 +212,5 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { // Push notification is sent after download succeeds (see video/download.ts, music/download.ts) - return json({ ok: true, clipId, status: 'downloading' }, { status: 201 }); + return shareResponse(true, '✅ Clip shared!', 201, { clipId }); }; diff --git a/src/routes/api/group/shortcut/+server.ts b/src/routes/api/group/shortcut/+server.ts index 4bf751a..b40a86a 100644 --- a/src/routes/api/group/shortcut/+server.ts +++ b/src/routes/api/group/shortcut/+server.ts @@ -5,6 +5,8 @@ import { groups } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { withHost, parseBody, isResponse } from '$lib/server/api-utils'; +const ICLOUD_SHORTCUT_RE = /^https:\/\/www\.icloud\.com\/shortcuts\/[a-f0-9]{32}\/?$/; + export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { const body = await parseBody<{ shortcutUrl?: string | null }>(request); if (isResponse(body)) return body; @@ -17,8 +19,11 @@ export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => const trimmed = typeof shortcutUrl === 'string' ? shortcutUrl.trim() : ''; - if (trimmed && !trimmed.startsWith('https://www.icloud.com/shortcuts/')) { - return json({ error: 'Must be an iCloud shortcut link' }, { status: 400 }); + if (trimmed && !ICLOUD_SHORTCUT_RE.test(trimmed)) { + return json( + { error: 'Must be a valid iCloud shortcut link (https://www.icloud.com/shortcuts/...)' }, + { status: 400 } + ); } await db From b0eb169766dd927511cd96c60d81dba0afedfc31 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:06:02 -0600 Subject: [PATCH 06/12] feat: include unwatched clip count in push notifications and app badge - Add getUnwatchedCount() to calculate per-user unwatched clips - Include badgeCount in all push notification payloads - Service worker uses payload badgeCount for app badge - Export updateAppBadge() from notifications store --- src/lib/server/push.ts | 45 ++++++++++++++++++++++++--- src/lib/stores/notifications.ts | 2 +- src/service-worker.ts | 55 +++++++++++++++++++++++---------- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts index bb24870..f59aa19 100644 --- a/src/lib/server/push.ts +++ b/src/lib/server/push.ts @@ -1,8 +1,14 @@ import webpush from 'web-push'; import { env } from '$env/dynamic/private'; import { db } from '$lib/server/db'; -import { clips, pushSubscriptions, notificationPreferences, users } from '$lib/server/db/schema'; -import { eq, inArray } from 'drizzle-orm'; +import { + clips, + pushSubscriptions, + notificationPreferences, + users, + watched +} from '$lib/server/db/schema'; +import { eq, and, inArray, sql } from 'drizzle-orm'; import { createLogger } from '$lib/server/logger'; const log = createLogger('push'); @@ -15,6 +21,21 @@ type NotificationPayload = { tag?: string; }; +/** Get the number of unwatched ready clips for a user in their group. */ +async function getUnwatchedCount(userId: string, groupId: string): Promise { + const [result] = await db + .select({ count: sql`count(*)` }) + .from(clips) + .where( + and( + eq(clips.groupId, groupId), + eq(clips.status, 'ready'), + sql`${clips.id} NOT IN (SELECT ${watched.clipId} FROM ${watched} WHERE ${watched.userId} = ${userId})` + ) + ); + return result.count; +} + let initialized = false; function ensureInitialized() { @@ -28,7 +49,7 @@ function ensureInitialized() { export async function sendNotification( userId: string, - payload: NotificationPayload + payload: NotificationPayload & { badgeCount?: number } ): Promise { ensureInitialized(); @@ -38,7 +59,20 @@ export async function sendNotification( if (subs.length === 0) return; - const payloadStr = JSON.stringify(payload); + // Auto-compute badge count if not provided by caller + let finalPayload = payload; + if (payload.badgeCount === undefined) { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { groupId: true } + }); + if (user) { + const badgeCount = await getUnwatchedCount(userId, user.groupId); + finalPayload = { ...payload, badgeCount }; + } + } + + const payloadStr = JSON.stringify(finalPayload); await Promise.allSettled( subs.map(async (sub) => { @@ -121,7 +155,8 @@ export async function sendGroupNotification( targets.map(async (user) => { const prefs = prefsMap.get(user.id); if (prefs && !prefs[preferenceKey]) return; - await sendNotification(user.id, payload); + const badgeCount = await getUnwatchedCount(user.id, groupId); + await sendNotification(user.id, { ...payload, badgeCount }); }) ); } diff --git a/src/lib/stores/notifications.ts b/src/lib/stores/notifications.ts index 6d15484..a9dfd90 100644 --- a/src/lib/stores/notifications.ts +++ b/src/lib/stores/notifications.ts @@ -30,7 +30,7 @@ export async function fetchUnwatchedCount(): Promise { } } -function updateAppBadge(count: number): void { +export function updateAppBadge(count: number): void { if (!('setAppBadge' in navigator)) return; if (count > 0) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Badge API not in lib.dom.d.ts diff --git a/src/service-worker.ts b/src/service-worker.ts index a5f539c..ace8ab8 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -84,7 +84,7 @@ sw.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); - const { title, body, icon, url, tag, image } = data; + const { title, body, icon, url, tag, image, badgeCount } = data; const notificationOptions: NotificationOptions & { image?: string } = { body: body || '', @@ -98,9 +98,14 @@ sw.addEventListener('push', (event) => { event.waitUntil( Promise.all([ sw.registration.showNotification(title || 'scrolly', notificationOptions), - // Set app badge indicator on push (Badging API not in standard TS types) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (sw.navigator as any).setAppBadge?.()?.catch?.(() => {}) + // Set app badge with unwatched count from server payload + typeof badgeCount === 'number' + ? (sw.navigator as unknown as { setAppBadge?: (n: number) => Promise }) + .setAppBadge?.(badgeCount) + ?.catch?.(() => {}) + : (sw.navigator as unknown as { setAppBadge?: () => Promise }) + .setAppBadge?.() + ?.catch?.(() => {}) ]) ); }); @@ -108,23 +113,39 @@ sw.addEventListener('push', (event) => { sw.addEventListener('notificationclick', (event) => { event.notification.close(); - // Clear app badge when user taps a notification (Badging API not in standard TS types) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (sw.navigator as any).clearAppBadge?.()?.catch?.(() => {}); - const url = event.notification.data?.url || '/'; event.waitUntil( - sw.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { - for (const client of clients) { - if (client.url.includes(sw.location.origin) && 'focus' in client) { - client.focus(); - client.navigate(url); - return; + Promise.all([ + // Refresh badge with actual unwatched count instead of blindly clearing + fetch('/api/clips/unwatched-count') + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nav = sw.navigator as any; + if (data && data.count > 0) { + nav.setAppBadge?.(data.count)?.catch?.(() => {}); + } else { + nav.clearAppBadge?.()?.catch?.(() => {}); + } + }) + .catch(() => { + // Offline or fetch failed — clear badge as fallback + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (sw.navigator as any).clearAppBadge?.()?.catch?.(() => {}); + }), + // Focus or open the app + sw.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if (client.url.includes(sw.location.origin) && 'focus' in client) { + client.focus(); + client.navigate(url); + return; + } } - } - return sw.clients.openWindow(url); - }) + return sw.clients.openWindow(url); + }) + ]) ); }); From 718631169b5af41d70bb0f01e639a35fca2d172f Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:06:41 -0600 Subject: [PATCH 07/12] feat: add desktop pull-to-refresh and improve feed refresh UX - Add wheel/trackpad-based pull-to-refresh for desktop - Improve touch pull-to-refresh with visual snapping and feedback - Add unwatched count badge to filter bar - Show contextual toast messages on refresh - Increase progress bar touch target to 48px - Refetch unwatched count after marking clips watched --- src/lib/components/FilterBar.svelte | 44 ++++++++- src/lib/components/ProgressBar.svelte | 2 +- src/lib/feed.ts | 2 + src/routes/(app)/+page.svelte | 124 ++++++++++++++++++++------ 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/src/lib/components/FilterBar.svelte b/src/lib/components/FilterBar.svelte index 99de8e5..365545b 100644 --- a/src/lib/components/FilterBar.svelte +++ b/src/lib/components/FilterBar.svelte @@ -6,13 +6,17 @@ onfilter, swipeProgress = 0, swiping = false, - hidden = false + hidden = false, + unwatchedCount = 0, + pullOffset = 0 }: { filter: FeedFilter; onfilter: (f: FeedFilter) => void; swipeProgress?: number; swiping?: boolean; hidden?: boolean; + unwatchedCount?: number; + pullOffset?: number; } = $props(); const filters: FeedFilter[] = ['unwatched', 'watched', 'favorites']; @@ -57,7 +61,12 @@ }); -
+
0 ? `translateY(${pullOffset}px)` : undefined} +>
{#each filters as f, i (f)} {/each}
@@ -97,6 +111,12 @@ opacity: 0; } + .filter-bar.pull-snapping { + transition: + opacity 0.3s ease, + transform 0.25s ease; + } + .filter-tabs { position: relative; display: flex; @@ -122,6 +142,24 @@ color: var(--reel-text); } + .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: #fff; + font-family: var(--font-body); + font-size: 0.6875rem; + font-weight: 700; + line-height: 1; + border-radius: var(--radius-full); + vertical-align: middle; + } + .tab-indicator { position: absolute; bottom: 0; diff --git a/src/lib/components/ProgressBar.svelte b/src/lib/components/ProgressBar.svelte index 97905ee..54298e9 100644 --- a/src/lib/components/ProgressBar.svelte +++ b/src/lib/components/ProgressBar.svelte @@ -112,7 +112,7 @@ left: 0; right: 0; z-index: 6; - height: 24px; + height: 48px; display: flex; align-items: center; cursor: pointer; diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 1f72c93..92d39f6 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -1,4 +1,5 @@ import type { FeedClip } from '$lib/types'; +import { fetchUnwatchedCount } from '$lib/stores/notifications'; export type FeedFilter = 'all' | 'unwatched' | 'watched' | 'favorites'; @@ -47,6 +48,7 @@ export async function fetchMoreClips( export async function markClipWatched(clipId: string): Promise { await fetch(`/api/clips/${clipId}/watched`, { method: 'POST' }); + fetchUnwatchedCount(); } export async function toggleClipFavorite(clipId: string): Promise<{ favorited: boolean } | null> { diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 5098c6b..6cd0c5f 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -16,6 +16,7 @@ openCommentsSignal } from '$lib/stores/toasts'; import { homeTapSignal } from '$lib/stores/homeTap'; + import { unwatchedCount, fetchUnwatchedCount } from '$lib/stores/notifications'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { onMount, onDestroy } from 'svelte'; import { page } from '$app/state'; @@ -51,10 +52,23 @@ let pullDistance = $state(0); let isRefreshing = $state(false); + let pullSnapping = $state(false); let touchStartY = 0; let isPullingActive = false; const PULL_THRESHOLD = 80; + function endPull(triggerRefresh: boolean) { + if (triggerRefresh) { + refresh(); + } else { + pullSnapping = true; + pullDistance = 0; + setTimeout(() => { + pullSnapping = false; + }, 250); + } + } + let isDragging = $state(false); let dragCounter = 0; @@ -69,6 +83,11 @@ swipeX !== 0 && typeof window !== 'undefined' ? -swipeX / window.innerWidth : 0 ); + function computePullY(dist: number, refreshing: boolean): number { + if (dist > 0) return dist; + return refreshing ? 48 : 0; + } + const pullY = $derived(computePullY(pullDistance, isRefreshing)); const currentUserId = $derived(page.data.user?.id ?? ''); const autoScroll = $derived(page.data.user?.autoScroll ?? false); const gifEnabled = $derived(!!page.data.gifEnabled); @@ -103,12 +122,14 @@ } async function markWatched(clipId: string) { + const wasUnwatched = clips.find((c) => c.id === clipId && !c.watched); await markClipWatched(clipId); clips = clips.map((c) => c.id === clipId ? { ...c, watched: true, viewCount: c.watched ? c.viewCount : c.viewCount + 1 } : c ); + if (wasUnwatched) fetchUnwatchedCount(); } async function toggleFavorite(clipId: string) { @@ -178,12 +199,15 @@ }, 250); } + // Pull-to-refresh: attach to feedWrapper so it works on empty state too. + // Uses scrollContainer for scroll position when it exists, otherwise treats as top. $effect(() => { - if (!scrollContainer) return; - const sc = scrollContainer; + if (!feedWrapper) return; + const el = feedWrapper; + const getScrollTop = () => scrollContainer?.scrollTop ?? 0; function handleTouchStart(e: TouchEvent) { - if (sc.scrollTop <= 0 && !isRefreshing) { + if (getScrollTop() <= 0 && !isRefreshing) { touchStartY = e.touches[0].clientY; isPullingActive = true; } @@ -191,7 +215,7 @@ function handleTouchMove(e: TouchEvent) { if (!isPullingActive || isRefreshing || isHorizontalSwiping) return; - if (sc.scrollTop > 0) { + if (getScrollTop() > 0) { isPullingActive = false; pullDistance = 0; return; @@ -206,18 +230,52 @@ } function handleTouchEnd() { - if (pullDistance >= PULL_THRESHOLD && !isRefreshing) refresh(); - else pullDistance = 0; + if (pullDistance > 0) endPull(pullDistance >= PULL_THRESHOLD && !isRefreshing); isPullingActive = false; } - sc.addEventListener('touchstart', handleTouchStart, { passive: true }); - sc.addEventListener('touchmove', handleTouchMove, { passive: false }); - sc.addEventListener('touchend', handleTouchEnd, { passive: true }); + // Wheel-based pull-to-refresh for desktop (trackpad / mouse wheel) + let wheelAccum = 0; + let wheelTimer: ReturnType | null = null; + + function handleWheel(e: WheelEvent) { + if (isRefreshing) return; + if (getScrollTop() > 0) { + wheelAccum = 0; + pullDistance = 0; + return; + } + + if (e.deltaY < 0) { + // Scrolling up — accumulate pull distance + wheelAccum += Math.abs(e.deltaY); + pullDistance = Math.min(wheelAccum * 0.3, 120); + if (pullDistance > 10) e.preventDefault(); + } else if (e.deltaY > 0 && wheelAccum > 0) { + // Scrolling back down — allow cancelling + wheelAccum = Math.max(0, wheelAccum - Math.abs(e.deltaY)); + pullDistance = Math.min(wheelAccum * 0.3, 120); + if (pullDistance > 0) e.preventDefault(); + } + + // Trigger or cancel after user stops scrolling + if (wheelTimer) clearTimeout(wheelTimer); + wheelTimer = setTimeout(() => { + if (pullDistance > 0) endPull(pullDistance >= PULL_THRESHOLD && !isRefreshing); + wheelAccum = 0; + }, 150); + } + + el.addEventListener('touchstart', handleTouchStart, { passive: true }); + el.addEventListener('touchmove', handleTouchMove, { passive: false }); + el.addEventListener('touchend', handleTouchEnd, { passive: true }); + el.addEventListener('wheel', handleWheel, { passive: false }); return () => { - sc.removeEventListener('touchstart', handleTouchStart); - sc.removeEventListener('touchmove', handleTouchMove); - sc.removeEventListener('touchend', handleTouchEnd); + el.removeEventListener('touchstart', handleTouchStart); + el.removeEventListener('touchmove', handleTouchMove); + el.removeEventListener('touchend', handleTouchEnd); + el.removeEventListener('wheel', handleWheel); + if (wheelTimer) clearTimeout(wheelTimer); }; }); @@ -313,6 +371,7 @@ async function refresh() { isRefreshing = true; + const previousIds = new Set(clips.map((c) => c.id)); const data = await fetchClips(filter, PAGE_SIZE); if (data) { clips = data.clips; @@ -320,11 +379,22 @@ currentOffset = data.clips.length; activeIndex = 0; if (scrollContainer) scrollContainer.scrollTop = 0; + const hasNew = data.clips.some((c) => !previousIds.has(c.id)); + if (!hasNew && data.clips.length > 0) { + toast.info('All caught up'); + } else if (data.clips.length === 0) { + toast.info('Nothing new'); + } } else { toast.error('Failed to refresh feed'); } isRefreshing = false; + pullSnapping = true; pullDistance = 0; + setTimeout(() => { + pullSnapping = false; + }, 250); + fetchUnwatchedCount(); } $effect(() => { @@ -572,19 +642,21 @@ {swipeProgress} swiping={isHorizontalSwiping} hidden={$feedUiHidden} + unwatchedCount={$unwatchedCount} + pullOffset={pullY} /> {#if pullDistance > 0 || isRefreshing}
= PULL_THRESHOLD ? 1 : Math.min(pullDistance / PULL_THRESHOLD, 1)}" > - {#if isRefreshing} + {#if isRefreshing || pullDistance >= PULL_THRESHOLD} {:else} - = PULL_THRESHOLD}> + {/if} @@ -594,7 +666,13 @@
{ + const parts: string[] = []; + if (swipeX !== 0) parts.push(`translateX(${swipeX}px)`); + if (pullY > 0 || pullSnapping) parts.push(`translateY(${pullY}px)`); + return parts.length > 0 ? parts.join(' ') : undefined; + })()} bind:this={feedWrapper} > {#if loading} @@ -671,10 +749,11 @@ top: 0; left: 0; right: 0; - z-index: 25; + z-index: 19; display: flex; align-items: center; justify-content: center; + padding-top: max(var(--space-sm), env(safe-area-inset-top)); height: 48px; pointer-events: none; transition: opacity 0.15s ease; @@ -682,14 +761,6 @@ .pull-arrow { display: inline-flex; color: var(--reel-text-dim); - transform: rotate(180deg); - transition: - transform 0.2s ease, - color 0.2s ease; - } - .pull-arrow.ready { - transform: rotate(0deg); - color: var(--accent-primary); } .pull-spinner { display: inline-block; @@ -806,6 +877,9 @@ .feed-slide.animating { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); } + .feed-slide.pull-snapping { + transition: transform 0.25s ease; + } .drop-target { height: 100dvh; position: relative; From f0b0807468dff48fdcaca312bfcea7ac614b077e Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:07:05 -0600 Subject: [PATCH 08/12] feat: expand shortcut support to macOS and improve setup flow - Expand iOS shortcut nudge to include macOS detection - Create ShortcutGuideSheet with 3-step interactive walkthrough - Distinguish iOS vs macOS setup instructions - Improve shortcut URL validation with soft/hard blocker distinction - Redesign ShortcutManager with inline URL editing and token display --- src/lib/components/ShortcutGuideSheet.svelte | 434 ++++++++++++++++++ .../settings/ShortcutManager.svelte | 416 ++++++++++++++--- src/lib/stores/shortcutNudge.ts | 8 +- src/routes/share/setup/+page.svelte | 231 ++++++---- 4 files changed, 950 insertions(+), 139 deletions(-) create mode 100644 src/lib/components/ShortcutGuideSheet.svelte diff --git a/src/lib/components/ShortcutGuideSheet.svelte b/src/lib/components/ShortcutGuideSheet.svelte new file mode 100644 index 0000000..f7c2dd4 --- /dev/null +++ b/src/lib/components/ShortcutGuideSheet.svelte @@ -0,0 +1,434 @@ + + + + {#snippet header()} +
+ Share Shortcut Setup +
+ {#each Array(totalSteps) as _, i (i)} + + {/each} +
+
+ {/snippet} + +
+ {#if currentStep === 0} +
+
+ +
+

Install the Shortcut

+

+ This shortcut lets you share clips to scrolly directly from {isMac + ? "Safari's share menu" + : "any app's share menu"} + — no need to open scrolly first. +

+ +

+ {#if isMac} + Click Add Shortcut when prompted in the Shortcuts app + {:else} + Tap Add Shortcut when prompted in the Shortcuts app + {/if} +

+
+ {:else if currentStep === 1} +
+
+ +
+

{isMac ? 'Use It in Safari' : 'Find It in Your Share Sheet'}

+

After installing, here's where to find it:

+
+ {#if isMac} +
+ 1 + Open Safari and browse to a video on TikTok, + Instagram, YouTube, or any site +
+
+ 2 + + Click the + + Share button in the Safari toolbar + +
+
+ 3 + Your shortcut will appear in the share menu +
+
+ 4 + If you don't see it, scroll down and click More... to enable it +
+ {:else} +
+ 1 + Open TikTok, Instagram, YouTube, + or any app +
+
+ 2 + + Tap the + + Share button on a video + +
+
+ 3 + Scroll down past contacts and apps to the actions list +
+
+ 4 + Your shortcut will be near the bottom — you may need to tap More... to see it +
+ {/if} +
+
+ {:else} +
+
+ +
+

{isMac ? 'Pin for Quick Access' : 'Move It to the Top'}

+

+ {isMac + ? 'Make the shortcut easy to reach whenever you need it:' + : "Pin the shortcut to your favourites so it's always easy to reach:"} +

+
+ {#if isMac} +
+ 1 + Open the Shortcuts app +
+
+ 2 + Right-click your scrolly shortcut +
+
+ 3 + Choose Add to Dock or enable + Pin in Menu Bar +
+ {:else} +
+ 1 + At the very bottom of the share sheet actions, tap Edit Actions... +
+
+ 2 + Find your scrolly shortcut under Other Actions +
+
+ 3 + Tap the green + button to add it to + Favourites +
+ {/if} +
+
+ {#if isMac} + With Menu Bar enabled, you can run the shortcut from anywhere without opening Safari. + {:else} + Once it's in Favourites, it'll appear right at the top of your share sheet for quick + access. + {/if} +
+
+ {/if} +
+ +
+ {#if currentStep > 0} + + {:else} +
+ {/if} + {#if currentStep < totalSteps - 1} + + {:else} + + {/if} +
+
+ + diff --git a/src/lib/components/settings/ShortcutManager.svelte b/src/lib/components/settings/ShortcutManager.svelte index ad18073..0f6b884 100644 --- a/src/lib/components/settings/ShortcutManager.svelte +++ b/src/lib/components/settings/ShortcutManager.svelte @@ -1,29 +1,84 @@
- - - -
- - + {#if isUnreachable} +
+ +
+

Validation server could not be reached

+

+ Apple's iCloud servers are unavailable right now. You can save without validation, + but the shortcut won't be checked for configuration errors. +

+
+
+
+ + +
+ {:else if hasBlockers} + {#if hasExtraPrompts} +

+ This must be fixed before saving. Remove the extra setup prompts in the Shortcuts + app, then share an updated iCloud link. + +

+ {:else if hasTrailingSlash} +

+ This must be fixed before saving. Remove the trailing slash from the URL in the + Shortcuts app, then share an updated iCloud link. +

+ {:else} +

+ {displayedBlockers.length === 1 ? 'This issue' : 'These issues'} must be fixed before + saving. Update the shortcut, then re-validate with a new iCloud link. +

+ {/if} + {#each displayedBlockers as warning (warning.code + warning.message)} +
+ + + {@html warning.message} +
+ {/each} +

+ After making changes, close the shortcut editor and use + Share → Copy iCloud Link again to get an updated link. +

+
+ +
+ {:else} + + {/if}
{/if} @@ -677,62 +731,85 @@ .shortcut-name strong { color: var(--text-primary); } - .checklist-hint { - font-size: 0.8125rem; - color: var(--text-muted); - margin: 0; + .unreachable-notice { + display: flex; + align-items: flex-start; + gap: var(--space-md); + padding: var(--space-md); + background: color-mix(in srgb, var(--warning) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent); + border-radius: var(--radius-sm); } - .checklist-tip { + .unreachable-notice :global(svg) { + flex-shrink: 0; + color: var(--warning); + margin-top: 2px; + } + .unreachable-title { + font-size: 0.875rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 var(--space-xs); + } + .unreachable-desc { font-size: 0.8125rem; - color: var(--text-muted); + color: var(--text-secondary); + line-height: 1.5; margin: 0; - font-style: italic; } - .checklist-tip strong { + .blocker-hint { + font-size: 0.875rem; color: var(--text-secondary); - font-style: normal; + line-height: 1.5; + margin: 0; } - .validation-warning-check { + .validation-blocker { display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); - background: color-mix(in srgb, var(--warning) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); + background: color-mix(in srgb, var(--error) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--error) 20%, transparent); border-radius: var(--radius-sm); - cursor: pointer; - transition: opacity 0.2s ease; - } - .validation-warning-check input[type='checkbox'] { - flex-shrink: 0; - width: 16px; - height: 16px; - margin-top: 2px; - accent-color: var(--accent-primary); - cursor: pointer; } - .validation-warning-check :global(svg) { + .validation-blocker :global(svg) { flex-shrink: 0; - color: var(--warning); + color: var(--error); margin-top: 2px; - transition: opacity 0.2s ease; } - .validation-warning-check span { + .validation-blocker span { font-size: 0.9375rem; color: var(--text-secondary); line-height: 1.5; - transition: all 0.2s ease; } - .validation-warning-check span :global(b) { + .validation-blocker span :global(b) { color: var(--text-primary); font-weight: 600; } - .validation-warning-check.dismissed { - opacity: 0.5; + .blocker-link { + display: inline; + background: none; + border: none; + padding: 0; + color: var(--accent-primary); + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + } + .blocker-link:active { + opacity: 0.7; } - .validation-warning-check.dismissed span { - text-decoration: line-through; + .checklist-tip { + font-size: 0.8125rem; color: var(--text-muted); + margin: 0; + font-style: italic; + } + .checklist-tip strong { + color: var(--text-secondary); + font-style: normal; } .validation-actions { display: flex; From f745014e0a523e936003d1cb31de7df1bd4fe430 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:07:15 -0600 Subject: [PATCH 09/12] feat: add getting started checklist and settings improvements - New GettingStartedChecklist with auto-completion of 5 setup items - New SetupDoneState component for shortcut configuration confirmation - MemberList shows Android share info for non-Apple users - NotificationSettings adds daily reminder toggle description - Settings page integrates checklist, shortcut guide, and platform detection --- .../settings/GettingStartedChecklist.svelte | 353 ++++++++++++++++++ src/lib/components/settings/MemberList.svelte | 15 + .../settings/NotificationSettings.svelte | 4 + .../components/settings/SetupDoneState.svelte | 39 ++ src/routes/(app)/settings/+page.svelte | 88 ++++- 5 files changed, 483 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/settings/GettingStartedChecklist.svelte diff --git a/src/lib/components/settings/GettingStartedChecklist.svelte b/src/lib/components/settings/GettingStartedChecklist.svelte new file mode 100644 index 0000000..ed1d26b --- /dev/null +++ b/src/lib/components/settings/GettingStartedChecklist.svelte @@ -0,0 +1,353 @@ + + +{#if !dismissed && loaded} +
+
+
+

Getting started

+ +
+
+
+
+
+ {completedCount}/{items.length} +
+
+ +
+ {#each itemsWithStatus as item (item.key)} +
+ + {#if item.sectionId} + + + {:else} +
+ {item.label} + {item.description} +
+ {/if} +
+ {/each} +
+ + {#if allComplete} + + {/if} +
+{/if} + + diff --git a/src/lib/components/settings/MemberList.svelte b/src/lib/components/settings/MemberList.svelte index 7734035..8db0afb 100644 --- a/src/lib/components/settings/MemberList.svelte +++ b/src/lib/components/settings/MemberList.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import { confirm } from '$lib/stores/confirm'; import { toast } from '$lib/stores/toasts'; + import { groupMembers } from '$lib/stores/members'; import XIcon from 'phosphor-svelte/lib/XIcon'; import PlusIcon from 'phosphor-svelte/lib/PlusIcon'; @@ -85,6 +86,14 @@ return; } members = [...members, data.member]; + groupMembers.update((ms) => [ + ...ms, + { + id: data.member.id, + username: data.member.username, + avatarPath: data.member.avatarPath + } + ]); newUsername = ''; newPhone = ''; showAddForm = false; @@ -126,6 +135,7 @@ const res = await fetch(`/api/group/members/${member.id}`, { method: 'DELETE' }); if (res.ok) { members = members.filter((m) => m.id !== member.id); + groupMembers.update((ms) => ms.filter((m) => m.id !== member.id)); toast.success(`${member.username} removed`); } else { toast.error('Failed to remove member'); @@ -245,8 +255,13 @@ border-bottom: 1px solid var(--bg-surface); } + .member-row:first-child { + padding-top: 0; + } + .member-row:last-child { border-bottom: none; + padding-bottom: 0; } .member-avatar { diff --git a/src/lib/components/settings/NotificationSettings.svelte b/src/lib/components/settings/NotificationSettings.svelte index 40cf817..9dfd5e1 100644 --- a/src/lib/components/settings/NotificationSettings.svelte +++ b/src/lib/components/settings/NotificationSettings.svelte @@ -143,9 +143,13 @@ padding: var(--space-sm) 0; border-bottom: 1px solid var(--bg-surface); } + .setting-row:first-child { + padding-top: 0; + } .setting-row.last, .setting-row:last-child { border-bottom: none; + padding-bottom: 0; } .setting-label { diff --git a/src/lib/components/settings/SetupDoneState.svelte b/src/lib/components/settings/SetupDoneState.svelte index 7737ffe..826c4eb 100644 --- a/src/lib/components/settings/SetupDoneState.svelte +++ b/src/lib/components/settings/SetupDoneState.svelte @@ -37,6 +37,14 @@ to scrolly in the background.

+
+ +

+ This shortcut syncs via iCloud to all your Apple devices. On Mac, share to + the Shortcuts app from Safari's share menu, then choose your scrolly shortcut. +

+
+

What your members will see

@@ -124,6 +132,37 @@ max-width: 340px; } + /* --- iCloud tip --- */ + .icloud-tip { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + text-align: left; + padding: var(--space-md); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: var(--space-2xl); + width: 100%; + } + + .icloud-tip :global(svg) { + flex-shrink: 0; + color: var(--text-muted); + margin-top: 2px; + } + + .icloud-tip p { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; + margin: 0; + } + + .icloud-tip p strong { + color: var(--text-primary); + } + /* --- Section headings --- */ .section-heading { font-family: var(--font-display); diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 3c4f52c..5234390 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -11,7 +11,7 @@ import { type AccentColorKey } from '$lib/colors'; import { globalMuted } from '$lib/stores/mute'; import CameraIcon from 'phosphor-svelte/lib/CameraIcon'; - import ExportIcon from 'phosphor-svelte/lib/ExportIcon'; + import { applyTheme, saveThemePreference, @@ -34,8 +34,14 @@ import DownloadProviderManager from '$lib/components/settings/DownloadProviderManager.svelte'; import PlatformFilter from '$lib/components/settings/PlatformFilter.svelte'; import ShortcutManager from '$lib/components/settings/ShortcutManager.svelte'; + import GettingStartedChecklist from '$lib/components/settings/GettingStartedChecklist.svelte'; import UsernameEdit from '$lib/components/settings/UsernameEdit.svelte'; import AvatarCropModal from '$lib/components/AvatarCropModal.svelte'; + import ShortcutGuideSheet from '$lib/components/ShortcutGuideSheet.svelte'; + import AppleLogoIcon from 'phosphor-svelte/lib/AppleLogoIcon'; + import AndroidLogoIcon from 'phosphor-svelte/lib/AndroidLogoIcon'; + import { isStandalone } from '$lib/stores/pwa'; + import { groupMembers } from '$lib/stores/members'; const vapidPublicKey = $derived(page.data.vapidPublicKey as string); const user = $derived(page.data.user); @@ -43,6 +49,7 @@ const isHost = $derived(group?.createdBy === user?.id); let activeTab = $state<'me' | 'group'>('me'); + let showShortcutGuide = $state(false); let avatarCropImage = $state(null); let avatarOverride = $state(undefined); let avatarCacheBust = $state(0); @@ -95,7 +102,7 @@ return 2; }); - let showShareCta = $state(false); + let platform = $state<'ios' | 'macos' | 'android' | 'other'>('other'); let pushSupported = $state(false); let pushEnabled = $state(false); let pushLoading = $state(false); @@ -109,8 +116,11 @@ }); onMount(async () => { - const isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent); - showShareCta = isIos; + const ua = navigator.userAgent; + if (/iPhone|iPad|iPod/i.test(ua)) platform = 'ios'; + else if (/Android/i.test(ua)) platform = 'android'; + else if (/Macintosh/i.test(ua)) platform = 'macos'; + else platform = 'other'; pushSupported = isPushSupported(); if (pushSupported) { @@ -165,6 +175,15 @@ prefs = { ...prefs, [key]: value }; updateNotificationPref(key, value); } + + const checklistMemberCount = $derived($groupMembers.length); + + function scrollToSection(sectionId: string) { + const el = document.getElementById(sectionId); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } @@ -216,15 +235,27 @@ {/if}
- {#if showShareCta && group?.shortcutUrl} - + {:else if platform === 'android'} + {/if}
@@ -304,18 +335,25 @@ {#if activeTab === 'group' && isHost}
-
+ + +

Group Name

-
+

Accent Color

-
+

Members

@@ -341,10 +379,10 @@ />
-
-

iOS Shortcut

+
+

Share from Apps

- +
@@ -378,6 +416,13 @@ /> {/if} + {#if showShortcutGuide && group?.shortcutUrl} + (showShortcutGuide = false)} + /> + {/if} +