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/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/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;