From 5b93ecb85907b38bfd9b84e2e198d7e6c7b4cbfb Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:06:13 -0600 Subject: [PATCH 1/4] feat: comment editing, heart popover, reaction events, and interaction guards - Add PATCH endpoint to edit comments (only if unseen by others) - Guard comment/reply deletion so it's blocked after others have viewed - Prevent self-hearting on own comments - Show who hearted a comment via long-press popover - Interleave reaction events into the comments feed stream - Add canEdit flag to comment responses for client-side UI gating - Extract CommentRow component from CommentsSheet to reduce file size --- src/lib/commentsApi.ts | 36 +- src/lib/components/CommentRow.svelte | 389 ++++++++++++++++++ src/lib/components/CommentsSheet.svelte | 387 +++++++---------- src/routes/api/clips/[id]/comments/+server.ts | 108 ++++- .../comments/[commentId]/heart/+server.ts | 4 + 5 files changed, 659 insertions(+), 265 deletions(-) create mode 100644 src/lib/components/CommentRow.svelte diff --git a/src/lib/commentsApi.ts b/src/lib/commentsApi.ts index 4a9d452..0a446cb 100644 --- a/src/lib/commentsApi.ts +++ b/src/lib/commentsApi.ts @@ -10,18 +10,28 @@ export interface Comment { parentId: string | null; heartCount: number; hearted: boolean; + heartUsers: string[]; + canEdit: boolean; createdAt: string; replyCount?: number; replies?: Comment[]; } -export async function fetchComments(clipId: string): Promise { +export interface ReactionEvent { + emoji: string; + username: string; + createdAt: string; +} + +export async function fetchComments( + clipId: string +): Promise<{ comments: Comment[]; reactionEvents: ReactionEvent[] }> { const res = await fetch(`/api/clips/${clipId}/comments`); if (res.ok) { const data = await res.json(); - return data.comments; + return { comments: data.comments ?? [], reactionEvents: data.reactionEvents ?? [] }; } - return []; + return { comments: [], reactionEvents: [] }; } export async function postComment( @@ -45,6 +55,26 @@ export async function postComment( return data.comment; } +export async function editComment( + clipId: string, + commentId: string, + text: string, + gifUrl?: string +): Promise<{ text: string; gifUrl: string | null }> { + const body: { commentId: string; text: string; gifUrl?: string } = { commentId, text }; + if (gifUrl) body.gifUrl = gifUrl; + + const res = await fetch(`/api/clips/${clipId}/comments`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) throw new Error('Failed to edit comment'); + const data = await res.json(); + return data.comment; +} + export async function deleteComment(clipId: string, commentId: string): Promise { const res = await fetch(`/api/clips/${clipId}/comments`, { method: 'DELETE', diff --git a/src/lib/components/CommentRow.svelte b/src/lib/components/CommentRow.svelte new file mode 100644 index 0000000..a7d2b2a --- /dev/null +++ b/src/lib/components/CommentRow.svelte @@ -0,0 +1,389 @@ + + +
+
+ {comment.username.charAt(0).toUpperCase()} +
+
+
+ {comment.username} + {relativeTime(comment.createdAt)} +
+ {#if isEditing} +
+ +
+ + +
+
+ {:else} + {#if comment.text} +

+ +

+ {/if} + {#if comment.gifUrl} + GIF + {/if} + {/if} +
+ {#if !isReply} + + {/if} + {#if comment.userId !== currentUserId} + + + + {#if heartPopoverVisible && comment.heartUsers.length > 0} + +
e.stopPropagation()}> + {#each comment.heartUsers as name, i (i)} + {name}
+ {/each} +
+ {/if} +
+ {:else if comment.heartCount > 0} + + + + + {comment.heartCount} + + {#if heartPopoverVisible && comment.heartUsers.length > 0} + +
e.stopPropagation()}> + {#each comment.heartUsers as name, i (i)} + {name}
+ {/each} +
+ {/if} +
+ {/if} + {#if comment.canEdit} + + + {/if} +
+
+
+ + diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 7c39a4d..42cfb04 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -3,15 +3,16 @@ import { toast } from '$lib/stores/toasts'; import { onDestroy } from 'svelte'; import CommentInput from './CommentInput.svelte'; + import CommentRow from './CommentRow.svelte'; import GifPicker from './GifPicker.svelte'; import BaseSheet from './BaseSheet.svelte'; - import MentionText from './MentionText.svelte'; - import HeartIcon from 'phosphor-svelte/lib/HeartIcon'; import type { GroupMember } from '$lib/types'; import { type Comment, + type ReactionEvent, fetchComments, postComment, + editComment as apiEditComment, deleteComment as apiDeleteComment, toggleCommentHeart, markCommentsRead @@ -36,6 +37,7 @@ const memberUsernames = $derived(members.map((m) => m.username)); let comments = $state([]); + let reactionEvents = $state([]); let loading = $state(true); let submitting = $state(false); let replyingTo = $state<{ id: string; username: string } | null>(null); @@ -55,6 +57,10 @@ let justHeartedIds = $state(new Set()); let justPostedId = $state(null); let sheetRef = $state | null>(null); + let editingId = $state(null); + let editText = $state(''); + let heartPopoverId = $state(null); + let longPressTimer: ReturnType | null = null; let timers: ReturnType[] = []; @@ -66,14 +72,15 @@ onDestroy(() => timers.forEach(clearTimeout)); - // Load comments $effect(() => { loadComments(); }); async function loadComments() { loading = true; - comments = await fetchComments(clipId); + const result = await fetchComments(clipId); + comments = result.comments; + reactionEvents = result.reactionEvents; loading = false; markCommentsRead(clipId); if (autoFocus) safeTimeout(() => commentInput?.focus(), 350); @@ -123,6 +130,44 @@ } } + function startEdit(comment: Comment) { + editingId = comment.id; + editText = comment.text || ''; + } + + function cancelEdit() { + editingId = null; + editText = ''; + } + + async function handleEdit(commentId: string) { + const trimmed = editText.trim(); + if (!trimmed) return; + try { + const result = await apiEditComment(clipId, commentId, trimmed); + const topComment = comments.find((c) => c.id === commentId); + if (topComment) { + topComment.text = result.text; + topComment.gifUrl = result.gifUrl; + comments = [...comments]; + } else { + for (const c of comments) { + const reply = c.replies?.find((r) => r.id === commentId); + if (reply) { + reply.text = result.text; + reply.gifUrl = result.gifUrl; + comments = [...comments]; + break; + } + } + } + editingId = null; + editText = ''; + } catch { + toast.error('Failed to edit comment'); + } + } + async function toggleHeart(comment: Comment) { const wasHearted = comment.hearted; const prevCount = comment.heartCount; @@ -152,6 +197,34 @@ replyingTo = { id: comment.id, username: comment.username }; requestAnimationFrame(() => commentInput?.focus()); } + + type FeedItem = { type: 'comment'; data: Comment } | { type: 'reaction'; data: ReactionEvent }; + + const feedItems = $derived.by(() => { + const items: FeedItem[] = [ + ...comments.map((c) => ({ type: 'comment' as const, data: c })), + ...reactionEvents.map((r) => ({ type: 'reaction' as const, data: r })) + ]; + items.sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt)); + return items; + }); + + function startHeartLongPress(commentId: string) { + longPressTimer = setTimeout(() => { + heartPopoverId = heartPopoverId === commentId ? null : commentId; + }, 400); + } + + function cancelHeartLongPress() { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + } + + function dismissHeartPopover() { + heartPopoverId = null; + }
@@ -161,98 +234,65 @@ sheetId="comments" {ondismiss} > -
+ +
{#if loading}

Loading...

- {:else if comments.length === 0} + {:else if feedItems.length === 0}

No comments yet

{:else} - {#each comments as comment (comment.id)} -
-
- {comment.username.charAt(0).toUpperCase()} + {#each feedItems as item (item.type === 'comment' ? item.data.id : `reaction-${item.data.emoji}-${item.data.username}-${item.data.createdAt}`)} + {#if item.type === 'reaction'} + {@const r = item.data} +
+ {r.emoji} + {r.username} reacted + {relativeTime(r.createdAt)}
-
-
- {comment.username} - {relativeTime(comment.createdAt)} - {#if comment.userId === currentUserId} - - {/if} -
- {#if comment.text} -

- -

- {/if} - {#if comment.gifUrl} - GIF - {/if} -
- - + {:else} + {@const comment = item.data} + startReply(comment)} + ontoggleheart={() => toggleHeart(comment)} + onstartedit={() => startEdit(comment)} + onsaveedit={() => handleEdit(comment.id)} + oncanceledit={cancelEdit} + ondelete={() => handleDelete(comment.id)} + onstartlongpress={() => startHeartLongPress(comment.id)} + oncancellongpress={cancelHeartLongPress} + /> + {#if comment.replies && comment.replies.length > 0} +
+ {#each comment.replies as reply (reply.id)} + toggleHeart(reply)} + onstartedit={() => startEdit(reply)} + onsaveedit={() => handleEdit(reply.id)} + oncanceledit={cancelEdit} + ondelete={() => handleDelete(reply.id)} + onstartlongpress={() => startHeartLongPress(reply.id)} + oncancellongpress={cancelHeartLongPress} + /> + {/each}
- - {#if comment.replies && comment.replies.length > 0} -
- {#each comment.replies as reply (reply.id)} -
-
- {reply.username.charAt(0).toUpperCase()} -
-
-
- {reply.username} - {relativeTime(reply.createdAt)} - {#if reply.userId === currentUserId} - - {/if} -
- {#if reply.text} -

- -

- {/if} - {#if reply.gifUrl} - GIF - {/if} -
- -
-
-
- {/each} -
- {/if} -
-
+ {/if} + {/if} {/each} {/if}
@@ -311,180 +351,29 @@ padding: var(--space-2xl); margin: 0; } - .comment { - display: flex; - gap: var(--space-sm); - margin-bottom: var(--space-lg); - } - .comment-avatar, - .reply-avatar { - border-radius: var(--radius-full); - background: var(--accent-magenta); - color: white; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - font-family: var(--font-display); - font-weight: 700; - } - .comment-avatar { - width: 28px; - height: 28px; - font-size: 0.75rem; - } - .comment-body, - .reply-body { - flex: 1; - min-width: 0; - } - .comment-header { + .reaction-event { display: flex; align-items: center; gap: var(--space-sm); - margin-bottom: 2px; - } - .comment-username { - font-size: 0.8125rem; - font-weight: 600; - color: var(--text-primary); - } - .comment-time { - font-size: 0.75rem; - color: var(--text-muted); + padding: var(--space-xs) 0; + opacity: 0.55; } - - .delete-btn { - margin-left: auto; - background: none; - border: none; - color: var(--text-muted); - font-size: 1rem; - cursor: pointer; - padding: 0 4px; - line-height: 1; - transition: color 0.2s ease; - } - .delete-btn:hover { - color: var(--error); - } - .comment-text { + .reaction-emoji { font-size: 0.875rem; } - .comment-gif, - .reply-gif { - display: block; - border-radius: var(--radius-md); - margin-top: var(--space-sm); - object-fit: contain; - background: var(--bg-surface); - padding: 2px; - } - .comment-gif { - max-width: 150px; - max-height: 150px; - } - .reply-gif { - max-width: 120px; - max-height: 120px; - } - .comment-actions { - display: flex; - align-items: center; - gap: var(--space-md); - margin-top: 4px; - } - .reply-btn { - background: none; - border: none; - color: var(--text-muted); + .reaction-text { font-size: 0.75rem; - font-weight: 600; - cursor: pointer; - padding: 0; - transition: color 0.2s ease; - } - .reply-btn:active { - transform: scale(0.97); - } - .heart-btn { - background: none; - border: none; - display: flex; - align-items: center; - gap: 3px; - cursor: pointer; - padding: 0; color: var(--text-muted); - transition: - color 0.2s ease, - transform 0.1s ease; - } - .heart-btn:active { - transform: scale(0.9); } - .heart-btn.hearted { - color: var(--accent-magenta); - } - .heart-count { + .reaction-time { font-size: 0.6875rem; + color: var(--text-muted); + margin-left: auto; } - .replies { margin-top: var(--space-sm); padding-left: 4px; border-left: 2px solid var(--border); margin-left: 2px; } - .reply { - display: flex; - gap: 6px; - padding: var(--space-xs) 0 var(--space-xs) var(--space-sm); - } - .reply-avatar { - width: 22px; - height: 22px; - font-size: 0.625rem; - } - .reply-username { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-primary); - } - .comment-text, - .reply-text { - margin: 0; - color: var(--text-secondary); - line-height: 1.4; - } - .reply-text { - font-size: 0.8125rem; - } - .heart-btn.heart-pop :global(svg) { - animation: heart-pop 300ms cubic-bezier(0.34, 1.56, 0.64, 1); - } - @keyframes heart-pop { - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.4); - } - 100% { - transform: scale(1); - } - } - .comment.just-posted { - animation: comment-slide-in 250ms cubic-bezier(0.32, 0.72, 0, 1); - } - @keyframes comment-slide-in { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } - } diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts index 3984114..bc0f142 100644 --- a/src/routes/api/clips/[id]/comments/+server.ts +++ b/src/routes/api/clips/[id]/comments/+server.ts @@ -1,8 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; -import { comments, commentHearts, clips } from '$lib/server/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; +import { comments, commentHearts, commentViews, clips, reactions } from '$lib/server/db/schema'; +import { eq, and, ne, inArray } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { withClipAuth, @@ -20,26 +20,45 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => where: eq(comments.clipId, clipId) }); - if (allComments.length === 0) { - return json({ comments: [] }); - } - - // Look up users (username + avatarPath) - const usersMap = await mapUsersByIds(allComments.map((c) => c.userId)); + // Fetch clip reactions for the reaction events stream + const clipReactions = await db.query.reactions.findMany({ + where: eq(reactions.clipId, clipId) + }); // Fetch all hearts for these comments const commentIds = allComments.map((c) => c.id); - const allHearts = await db.query.commentHearts.findMany({ - where: inArray(commentHearts.commentId, commentIds) - }); + const allHearts = + commentIds.length > 0 + ? await db.query.commentHearts.findMany({ + where: inArray(commentHearts.commentId, commentIds) + }) + : []; + + // Batch user lookup: comment authors + heart users + reaction users + const allUserIds = [ + ...allComments.map((c) => c.userId), + ...allHearts.map((h) => h.userId), + ...clipReactions.map((r) => r.userId) + ]; + const usersMap = await mapUsersByIds(allUserIds); const heartCounts = new Map(); const userHearted = new Set(); + const heartUsersByComment = new Map(); for (const h of allHearts) { heartCounts.set(h.commentId, (heartCounts.get(h.commentId) || 0) + 1); if (h.userId === user.id) userHearted.add(h.commentId); + const names = heartUsersByComment.get(h.commentId) || []; + const u = usersMap.get(h.userId); + if (u) names.push(u.username); + heartUsersByComment.set(h.commentId, names); } + // Fetch other users' comment views for this clip (for canEdit checks) + const otherViews = await db.query.commentViews.findMany({ + where: and(eq(commentViews.clipId, clipId), ne(commentViews.userId, user.id)) + }); + // Separate top-level vs replies const topLevel = allComments.filter((c) => !c.parentId); const repliesByParent = new Map(); @@ -52,6 +71,11 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => // Format a comment for the response function formatComment(c: (typeof allComments)[0]) { const u = usersMap.get(c.userId); + // canEdit: only for own comments, and only if no other user has viewed comments since it was posted + let canEdit = false; + if (c.userId === user.id) { + canEdit = !otherViews.some((v) => v.viewedAt >= c.createdAt); + } return { id: c.id, text: c.text, @@ -62,6 +86,8 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => parentId: c.parentId || null, heartCount: heartCounts.get(c.id) || 0, hearted: userHearted.has(c.id), + heartUsers: heartUsersByComment.get(c.id) || [], + canEdit, createdAt: c.createdAt.toISOString() }; } @@ -85,7 +111,16 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => }; }); - return json({ comments: formatted }); + // Format reaction events (read-only, not stored as comments) + const reactionEvents = clipReactions + .map((r) => ({ + emoji: r.emoji, + username: usersMap.get(r.userId)?.username || 'Unknown', + createdAt: r.createdAt.toISOString() + })) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + + return json({ comments: formatted, reactionEvents }); }); /** Determine the notification recipient and dispatch. Returns recipient ID or null. */ @@ -233,7 +268,49 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u ); }); -export const DELETE: RequestHandler = withClipAuth(async ({ request }, { user }) => { +/** Check if any other user has viewed the clip's comments since the given date. */ +async function hasBeenSeenByOthers(clipId: string, since: Date, excludeUserId: string) { + const views = await db.query.commentViews.findMany({ + where: and(eq(commentViews.clipId, clipId), ne(commentViews.userId, excludeUserId)) + }); + return views.some((v) => v.viewedAt >= since); +} + +export const PATCH: RequestHandler = withClipAuth(async ({ params, request }, { user }) => { + const body = await parseBody<{ commentId?: string; text?: string; gifUrl?: string }>(request); + if (isResponse(body)) return body; + + const { commentId } = body; + if (!commentId) return json({ error: 'Comment ID required' }, { status: 400 }); + + const comment = await db.query.comments.findFirst({ + where: and(eq(comments.id, commentId), eq(comments.userId, user.id)) + }); + if (!comment) return json({ error: 'Comment not found or not yours' }, { status: 404 }); + + if (await hasBeenSeenByOthers(params.id, comment.createdAt, user.id)) { + return json({ error: 'Comment can no longer be edited' }, { status: 403 }); + } + + const validation = validateCommentInput(body); + if ('error' in validation) return json({ error: validation.error }, { status: 400 }); + const { trimmed, validGifUrl } = validation; + + await db + .update(comments) + .set({ text: trimmed, gifUrl: validGifUrl }) + .where(eq(comments.id, commentId)); + + return json({ + comment: { + id: commentId, + text: trimmed, + gifUrl: validGifUrl + } + }); +}); + +export const DELETE: RequestHandler = withClipAuth(async ({ params, request }, { user }) => { const body = await parseBody<{ commentId?: string }>(request); if (isResponse(body)) return body; @@ -252,6 +329,11 @@ export const DELETE: RequestHandler = withClipAuth(async ({ request }, { user }) return json({ error: 'Comment not found or not yours' }, { status: 404 }); } + // Only allow deletion if no one else has seen the comments since this was posted + if (await hasBeenSeenByOthers(params.id, comment.createdAt, user.id)) { + return json({ error: 'Comment can no longer be deleted' }, { status: 403 }); + } + // Find child replies (if this is a top-level comment) const childReplies = await db.query.comments.findMany({ where: eq(comments.parentId, commentId) diff --git a/src/routes/api/clips/[id]/comments/[commentId]/heart/+server.ts b/src/routes/api/clips/[id]/comments/[commentId]/heart/+server.ts index 07d7cc6..52064cb 100644 --- a/src/routes/api/clips/[id]/comments/[commentId]/heart/+server.ts +++ b/src/routes/api/clips/[id]/comments/[commentId]/heart/+server.ts @@ -16,6 +16,10 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => }); if (!comment) return notFound('Comment not found'); + if (comment.userId === userId) { + return json({ error: 'Cannot heart your own comment' }, { status: 403 }); + } + // Toggle: if exists, delete; if not, insert const existing = await db.query.commentHearts.findFirst({ where: and(eq(commentHearts.commentId, commentId), eq(commentHearts.userId, userId)) From 982dcc8fd58693512141bcd3428af8f497216791 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:07:09 -0600 Subject: [PATCH 2/4] refactor: simplify iOS Shortcut to use cookie-based auth - Share API now prefers cookie auth (Safari shares session with Shortcuts) - Fall back to token+phone auth for backwards compatibility - Simplify setup page from 7 steps to 3 with a downloadable template - Remove token/API URL display from ShortcutManager settings - Show "Get Shortcut" link directly in settings when URL is configured - Add "from shortcut" error state to share page for failed auto-shares --- .../settings/ShortcutManager.svelte | 165 +--------------- src/routes/(app)/settings/+page.svelte | 21 +-- src/routes/api/clips/share/+server.ts | 78 +++++--- src/routes/share/+page.server.ts | 5 +- src/routes/share/+page.svelte | 10 +- src/routes/share/setup/+page.server.ts | 4 +- src/routes/share/setup/+page.svelte | 178 ++++-------------- 7 files changed, 113 insertions(+), 348 deletions(-) diff --git a/src/lib/components/settings/ShortcutManager.svelte b/src/lib/components/settings/ShortcutManager.svelte index 65fa21b..ad18073 100644 --- a/src/lib/components/settings/ShortcutManager.svelte +++ b/src/lib/components/settings/ShortcutManager.svelte @@ -1,42 +1,18 @@ @@ -92,128 +75,54 @@
{/if} - {#if shortcutUrl && currentStep === 0 && completedSteps.size === 0} -
-

Share from other apps

-

- Your group host has shared a ready-made shortcut. Install it with one tap, or follow the - manual steps below. -

- - - Get Shortcut - -
- or set it up manually -
-
- {/if} - - - {#if !(shortcutUrl && completedSteps.size === 0)} -

Share from other apps

-

- Set up an iOS Shortcut to share clips directly from TikTok, Instagram, and other apps. -

- {/if} -

- It's pre-installed on all iPhones. If you deleted it, re-download it from the App Store. + +

Set up Share Shortcut

+

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

- Look for the blue and pink icon that looks like two overlapping squares. + Tap the button below to download the pre-built shortcut template. It has everything + configured — you just need to update the URL.

+ + + Get Template Shortcut +
- +

- Tap the + button in the top right corner, then tap - Add Action. -

-
- - -

- Search for "Receive" and select - "Receive input from Share Sheet". -

-

- Then tap on "Any" and change it to accept "URLs" only. This - ensures the shortcut only fires when sharing links. -

-
- - -

- Add another action. Search for "Phone Number" and select - "Phone Number" under Contacts. + 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:

+
+ {appUrl} +

- Set it to get numbers from your contact card (the "Me" card). This is how scrolly - identifies who shared the clip. + 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.

- Your phone number must match the one you signed up with in scrolly. + + 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. +
- -

- Add another action. Search for "Get Contents of URL" and add it. Set the URL - to: -

- {#if apiUrl} -
- {apiUrl} - -
- {:else} -
- Ask your group host to set up the shortcut token in Settings. -
- {/if} -

- Change the method to POST and set the body to JSON. Add - these two keys: -

-
    -
  • - url — set to the - Shortcut Input variable -
  • -
  • - phones — set to the - Phone Numbers from step 4 -
  • -
-
- - +

- Add one more action. Search for "Show Notification" and add it. + Long-press your customized shortcut and choose + "Share", then "Copy iCloud Link".

- Set the title to "Added to scrolly!" and the body to the - Shortcut Input. This gives you a confirmation when a clip is shared. -

-
- - -

- Tap the name at the top of the shortcut, rename it to "scrolly", then tap - Done. -

-

- This makes it easy to find when you use the Share button in other apps. + 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.

@@ -320,9 +229,6 @@ height: 14px; color: var(--bg-primary); } - .shortcut-available { - margin-bottom: var(--space-lg); - } .setup-title { font-family: var(--font-display); font-size: 1.5rem; @@ -360,24 +266,6 @@ width: 22px; height: 22px; } - .divider { - display: flex; - align-items: center; - gap: var(--space-md); - margin: var(--space-xl) 0 0; - } - .divider::before, - .divider::after { - content: ''; - flex: 1; - height: 1px; - background: var(--border); - } - .divider span { - font-size: 0.75rem; - color: var(--text-muted); - white-space: nowrap; - } .step-nav { display: flex; justify-content: space-between; From 58247d8d3a42a8714cae46f2dc17fb1a3b2d097c Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:07:25 -0600 Subject: [PATCH 3/4] fix: gif picker layout alignment and home tap signal reset --- src/lib/components/GifPicker.svelte | 2 ++ src/routes/(app)/+page.svelte | 1 + 2 files changed, 3 insertions(+) diff --git a/src/lib/components/GifPicker.svelte b/src/lib/components/GifPicker.svelte index e44d89a..b74516a 100644 --- a/src/lib/components/GifPicker.svelte +++ b/src/lib/components/GifPicker.svelte @@ -206,6 +206,7 @@ -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain; display: flex; + align-items: flex-start; gap: 3px; padding: 0 var(--space-sm) var(--space-sm); } @@ -240,6 +241,7 @@ padding: 0; transition: transform 0.1s ease; display: block; + flex-shrink: 0; } .gif-item:active { transform: scale(0.97); diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 894ffa4..5098c6b 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -432,6 +432,7 @@ $effect(() => { const tap = $homeTapSignal; if (tap > 0) { + homeTapSignal.set(0); if (filter !== 'unwatched') setFilter('unwatched'); else { activeIndex = 0; From 57c1d81cf9a039da9f9f0bb49ea87e792573f6eb Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:13:56 -0600 Subject: [PATCH 4/4] fix: update heart test for self-heart prevention and add new test - Fix multi-user heart test to not self-heart (author can't heart own comment) - Add explicit test for self-hearting prevention (expects 403) --- src/routes/api/__tests__/comments.test.ts | 41 +++++++++++++++-------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/routes/api/__tests__/comments.test.ts b/src/routes/api/__tests__/comments.test.ts index 7e77c6f..b4eae32 100644 --- a/src/routes/api/__tests__/comments.test.ts +++ b/src/routes/api/__tests__/comments.test.ts @@ -565,25 +565,25 @@ describe('POST /api/clips/[id]/comments/[commentId]/heart', () => { }); it('tracks hearts from multiple users independently', async () => { - // Create a comment + // Member creates a comment (so both host and member can heart it) const postEvent = createMockEvent({ method: 'POST', path: `/api/clips/${data.readyClip.id}/comments`, params: { id: data.readyClip.id }, body: { text: 'comment for multi-heart test' }, - user: data.host, + user: data.member, group: data.group }); const postRes = await commentsMod.POST(postEvent as any); const postBody = await postRes.json(); const commentId = postBody.comment.id; - // Member hearts + // Host hearts (not the author) const heartEvent1 = createMockEvent({ method: 'POST', path: `/api/clips/${data.readyClip.id}/comments/${commentId}/heart`, params: { id: data.readyClip.id, commentId }, - user: data.member, + user: data.host, group: data.group }); const heartRes1 = await heartMod.POST(heartEvent1 as any); @@ -591,7 +591,7 @@ describe('POST /api/clips/[id]/comments/[commentId]/heart', () => { expect(heartBody1.hearted).toBe(true); expect(heartBody1.heartCount).toBe(1); - // Host also hearts + // Host un-hearts — count drops to 0 const heartEvent2 = createMockEvent({ method: 'POST', path: `/api/clips/${data.readyClip.id}/comments/${commentId}/heart`, @@ -601,21 +601,34 @@ describe('POST /api/clips/[id]/comments/[commentId]/heart', () => { }); const heartRes2 = await heartMod.POST(heartEvent2 as any); const heartBody2 = await heartRes2.json(); - expect(heartBody2.hearted).toBe(true); - expect(heartBody2.heartCount).toBe(2); + expect(heartBody2.hearted).toBe(false); + expect(heartBody2.heartCount).toBe(0); + }); + + it('prevents self-hearting own comment', async () => { + // Host creates a comment + const postEvent = createMockEvent({ + method: 'POST', + path: `/api/clips/${data.readyClip.id}/comments`, + params: { id: data.readyClip.id }, + body: { text: 'my own comment' }, + user: data.host, + group: data.group + }); + const postRes = await commentsMod.POST(postEvent as any); + const postBody = await postRes.json(); + const commentId = postBody.comment.id; - // Member un-hearts — count drops to 1 - const heartEvent3 = createMockEvent({ + // Host tries to heart own comment — should be rejected + const heartEvent = createMockEvent({ method: 'POST', path: `/api/clips/${data.readyClip.id}/comments/${commentId}/heart`, params: { id: data.readyClip.id, commentId }, - user: data.member, + user: data.host, group: data.group }); - const heartRes3 = await heartMod.POST(heartEvent3 as any); - const heartBody3 = await heartRes3.json(); - expect(heartBody3.hearted).toBe(false); - expect(heartBody3.heartCount).toBe(1); + const heartRes = await heartMod.POST(heartEvent as any); + expect(heartRes.status).toBe(403); }); it('shows hearted status in GET comments response', async () => {