From 87c972885c431798ae433f43cc1455308a6b220d Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:53:07 -0600 Subject: [PATCH 1/5] feat: replace smile with laughing icon and warning with double exclamation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap SmileyIcon for SmileyXEyesIcon on the 😂 reaction - Create custom DoubleExclamationIcon SVG to replace WarningIcon for ‼️ --- .../components/DoubleExclamationIcon.svelte | 38 +++++++++++++++++++ src/lib/icons.ts | 8 ++-- 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/DoubleExclamationIcon.svelte diff --git a/src/lib/components/DoubleExclamationIcon.svelte b/src/lib/components/DoubleExclamationIcon.svelte new file mode 100644 index 0000000..6a058b5 --- /dev/null +++ b/src/lib/components/DoubleExclamationIcon.svelte @@ -0,0 +1,38 @@ + + + + + {#if weight === 'bold'} + + + + + {:else if weight === 'fill'} + + + + + {:else} + + + + + {/if} + diff --git a/src/lib/icons.ts b/src/lib/icons.ts index d9cf2a3..a35437d 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -6,8 +6,8 @@ import type { Component } from 'svelte'; import HeartIcon from 'phosphor-svelte/lib/HeartIcon'; import ThumbsUpIcon from 'phosphor-svelte/lib/ThumbsUpIcon'; import ThumbsDownIcon from 'phosphor-svelte/lib/ThumbsDownIcon'; -import SmileyIcon from 'phosphor-svelte/lib/SmileyIcon'; -import WarningIcon from 'phosphor-svelte/lib/WarningIcon'; +import SmileyXEyesIcon from 'phosphor-svelte/lib/SmileyXEyesIcon'; +import DoubleExclamationIcon from '$lib/components/DoubleExclamationIcon.svelte'; import QuestionIcon from 'phosphor-svelte/lib/QuestionIcon'; export interface ReactionDef { @@ -21,8 +21,8 @@ export const REACTIONS: ReactionDef[] = [ { emoji: '❤️', component: HeartIcon, weight: 'fill' }, { emoji: '👍', component: ThumbsUpIcon, weight: 'regular' }, { emoji: '👎', component: ThumbsDownIcon, weight: 'regular' }, - { emoji: '😂', component: SmileyIcon, weight: 'regular' }, - { emoji: '‼️', component: WarningIcon, weight: 'bold' }, + { emoji: '😂', component: SmileyXEyesIcon, weight: 'regular' }, + { emoji: '‼️', component: DoubleExclamationIcon, weight: 'bold' }, { emoji: '❓', component: QuestionIcon, weight: 'regular' } ]; From afa141bbac517b44af9cd8cecaf63fe041a6a60d Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:53:16 -0600 Subject: [PATCH 2/5] fix: close reaction wheel on heart tap, skip auto-fave for negative reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dismiss picker when main heart button is tapped - Don't auto-favorite clips when reacting with 👎 or ❓ --- src/lib/components/ReelItem.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 5880050..70a7053 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -280,6 +280,8 @@ ); }); + const NEGATIVE_EMOJIS = new Set(['👎', '❓']); + function handlePickEmoji(emoji: string) { showPicker = false; if (isOwn) return; @@ -288,7 +290,7 @@ showerY = pickerY; showShower = true; if (!clip.reactions[emoji]?.reacted) onreaction(clip.id, emoji); - if (!clip.favorited) onfavorited(clip.id); + if (!clip.favorited && !NEGATIVE_EMOJIS.has(emoji)) onfavorited(clip.id); } function triggerReactionPickerHold(bx: number, by: number) { if (isOwn) return; @@ -369,7 +371,10 @@ {muted} {uiHidden} {isOwn} - onsave={() => onfavorited(clip.id)} + onsave={() => { + showPicker = false; + onfavorited(clip.id); + }} oncomment={() => { commentsAutoFocus = false; showComments = true; From 32afefaa9b4867199ab8cb6db0b6ff08fd8899cb Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:53:23 -0600 Subject: [PATCH 3/5] fix: clean up favorites created by negative-only reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time startup migration removes favorites where the user's only reactions on the clip are 👎 or ❓ (negative emojis). --- src/lib/server/db/index.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 8eaf6be..356183e 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -134,6 +134,27 @@ for (const g of groupsWithoutToken) { db.update(schema.groups).set({ shortcutToken: uuid() }).where(eq(schema.groups.id, g.id)).run(); } +// Remove favorites that were auto-created by negative-only reactions (👎, ❓) +// A favorite is removed if the user has reactions on that clip but ALL are negative +const NEGATIVE_EMOJIS = ['👎', '❓']; +const negativeFavCleanup = sqlite // eslint-disable-line sonarjs/sql-queries -- static placeholders only + .prepare( + `DELETE FROM favorites WHERE rowid IN ( + SELECT f.rowid FROM favorites f + INNER JOIN reactions r ON r.clip_id = f.clip_id AND r.user_id = f.user_id + WHERE NOT EXISTS ( + SELECT 1 FROM reactions r2 + WHERE r2.clip_id = f.clip_id AND r2.user_id = f.user_id + AND r2.emoji NOT IN (${NEGATIVE_EMOJIS.map(() => '?').join(',')}) + ) + GROUP BY f.clip_id, f.user_id + )` + ) + .run(...NEGATIVE_EMOJIS); +if (negativeFavCleanup.changes > 0) { + log.info({ removed: negativeFavCleanup.changes }, 'Cleaned up negative-reaction-only favorites'); +} + // Auto-create the group on first run if the database is empty const groupCount = db.select({ id: schema.groups.id }).from(schema.groups).limit(1).all(); if (groupCount.length === 0) { From 511d0e7d71270931a9825a9243d88905a3f4b2b9 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:53:36 -0600 Subject: [PATCH 4/5] fix: gif search previews not animating, defocus on enter - Re-run IntersectionObserver when gifs state changes so new results get observed for auto-play - Blur search input on Enter key press to dismiss keyboard --- src/lib/components/GifPicker.svelte | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/lib/components/GifPicker.svelte b/src/lib/components/GifPicker.svelte index 4ffca56..6e05097 100644 --- a/src/lib/components/GifPicker.svelte +++ b/src/lib/components/GifPicker.svelte @@ -26,6 +26,9 @@ e.preventDefault(); searchInputEl?.blur(); onclose?.(); + } else if (e.key === 'Enter') { + e.preventDefault(); + searchInputEl?.blur(); } } @@ -88,6 +91,10 @@ // IntersectionObserver: auto-play visible GIFs, show stills for off-screen $effect(() => { if (!gridEl) return; + // Track gifs so this effect re-runs when results change + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- reactive dependency + gifs.length; + const io = new IntersectionObserver( (entries) => { for (const entry of entries) { @@ -101,10 +108,16 @@ { root: gridEl, rootMargin: '100px 0px' } ); - const imgs = gridEl.querySelectorAll('img[data-animated]'); - imgs.forEach((img) => io.observe(img)); + // Delay observation until after Svelte renders new images + const raf = requestAnimationFrame(() => { + const imgs = gridEl!.querySelectorAll('img[data-animated]'); + imgs.forEach((img) => io.observe(img)); + }); - return () => io.disconnect(); + return () => { + cancelAnimationFrame(raf); + io.disconnect(); + }; }); async function loadGifs(q?: string) { From f0be7d7ad30a57b3820844c61b25070f4782d427 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:53:44 -0600 Subject: [PATCH 5/5] feat: expandable comment box up to 3 lines, strip empty lines - Switch comment input from single-line input to auto-expanding textarea - Grows from 1 to 3 lines as content wraps or newlines are added - Enter submits, Shift+Enter adds newline - Empty lines stripped from comments before submission --- src/lib/components/CommentInput.svelte | 17 ++++++++++++----- src/lib/components/MentionInput.svelte | 21 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 36ddb8f..3675467 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -46,10 +46,17 @@ return text.trim().length === 0; } + function stripEmptyLines(s: string): string { + return s + .split('\n') + .filter((line) => line.trim().length > 0) + .join('\n'); + } + function handleSubmit(e: SubmitEvent) { e.preventDefault(); if (!canSubmit || submitting) return; - onsubmit(text.trim(), attachedGif?.shareUrl || attachedGif?.url); + onsubmit(stripEmptyLines(text.trim()), attachedGif?.shareUrl || attachedGif?.url); } function getGifBtnLabel() { @@ -102,14 +109,14 @@ maxlength={500} disabled={submitting} {members} - singleLine + maxRows={3} onchange={(t) => { text = t; }} onfocus={handleInputFocus} onsubmit={() => { if (canSubmit && !submitting) - onsubmit(text.trim(), attachedGif?.shareUrl || attachedGif?.url); + onsubmit(stripEmptyLines(text.trim()), attachedGif?.shareUrl || attachedGif?.url); }} /> @@ -183,7 +190,7 @@ .input-bar { display: flex; - align-items: center; + align-items: flex-end; gap: var(--space-sm); padding: var(--space-md) var(--space-lg); border-top: 1px solid var(--border); @@ -195,7 +202,7 @@ min-width: 0; } .input-bar :global(.mention-input-wrap .input-container) { - border-radius: var(--radius-full); + border-radius: var(--radius-md); } .input-bar :global(.mention-input-wrap .overlay-input), .input-bar :global(.mention-input-wrap .highlight-mirror) { diff --git a/src/lib/components/MentionInput.svelte b/src/lib/components/MentionInput.svelte index ee4e2fc..f8bc5b6 100644 --- a/src/lib/components/MentionInput.svelte +++ b/src/lib/components/MentionInput.svelte @@ -7,6 +7,7 @@ disabled = false, members = [], singleLine = false, + maxRows = 0, onchange, onfocus, onsubmit @@ -16,6 +17,7 @@ disabled?: boolean; members?: GroupMember[]; singleLine?: boolean; + maxRows?: number; onchange?: (text: string) => void; onfocus?: () => void; onsubmit?: () => void; @@ -64,6 +66,16 @@ onchange?.(text); checkForMention(); syncScroll(); + if (maxRows > 0) autoResize(); + } + + function autoResize() { + const el = inputEl as HTMLTextAreaElement | null; + if (!el) return; + el.style.height = 'auto'; + const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 22; + const maxHeight = lineHeight * maxRows; + el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`; } function syncScroll() { @@ -125,7 +137,8 @@ } } - if (singleLine && e.key === 'Enter' && !showDropdown) { + if (e.key === 'Enter' && !showDropdown && (singleLine || maxRows > 0)) { + if (e.shiftKey && maxRows > 0) return; // Shift+Enter inserts newline in multi-line mode e.preventDefault(); onsubmit?.(); } @@ -143,6 +156,10 @@ export function clear() { text = ''; onchange?.(''); + if (maxRows > 0) { + const el = inputEl as HTMLTextAreaElement | null; + if (el) el.style.height = 'auto'; + } } export function getText(): string { @@ -216,7 +233,7 @@ {placeholder} {maxlength} {disabled} - rows="2" + rows={maxRows > 0 ? 1 : 2} oninput={handleInput} onkeydown={handleKeydown} onclick={handleClick}