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 @@
+
+
+
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}