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/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/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) { 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} 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; 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' } ]; 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) {