atagen

malice

public
Star0
Watch0

Commit 5059ed753f8b

tarball

atagen <boss@atagen.co>2026-10-27 07:28 UTC

14 files changed+620-79
web: reorder quick reactions by dragging and group them with separators

Quick reactions could only be appended in pick order, so a long set read as one undifferentiated grid. Chips now drag to a new slot with mouse or touch (and move with alt or shift plus arrows), and separators start a new toolbar row. Separators are stored as { separator: true } in the same dev.malice.quick_reactions list, which older builds skip because it has no key.
Modified file: web/src/App.svelte+4 additions−3 deletions
@@ -99,6 +99,7 @@
9999 import { mentionsFromHtml, resolveMentions } from './lib/mentions';
100100 import type { Mention } from './lib/mentions';
101101 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
102+ import { normaliseQuick } from './lib/quick-reactions-model';
102103 import { loadKeywords } from './lib/keywords';
103104 import { startNotifications } from './lib/notifications';
104105 import { disablePush } from './lib/push';
@@ -132,7 +133,7 @@
132133 import SettingsPanel from './lib/SettingsPanel.svelte';
133134 import { finePointer, longpress, type PressEvent } from './lib/longpress';
134135 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
135- import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, TimelineEvent, TimelineItem } from './lib/types';
136+ import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickEntry, ReplyReference, RoomSummary, TimelineEvent, TimelineItem } from './lib/types';
136137
137138 let roomsLoading = false;
138139 let booted = false;
@@ -214,7 +215,7 @@
214215 $: recentState = $selectedRoomId ? $recentHistory[$selectedRoomId] : undefined;
215216 $: roomPacks = enabledPacks(packsForRoom($imagePacks, $selectedRoomId, $rooms), $disabledPackImages);
216217 $: roomEmotes = customEmotes(roomPacks);
217- $: usableQuickReactions = enabledQuickReactions($quickReactions, allPacks($imagePacks), $disabledPackImages);
218+ $: usableQuickReactions = normaliseQuick(enabledQuickReactions($quickReactions, allPacks($imagePacks), $disabledPackImages));
218219 $: previewsAllowed = selectedRoom !== null && (!selectedRoom.encrypted || $urlPreviewSettings.encrypted_rooms);
219220 $: loadPreview = previewsAllowed && $selectedRoomId ? previewLoader($selectedRoomId) : null;
220221 $: spaceRooms = $rooms.filter((r) => r.space);
@@ -1396,7 +1397,7 @@
13961397 }
13971398 }
13981399
1399- async function changeQuickReactions(list: QuickReaction[]): Promise<void> {
1400+ async function changeQuickReactions(list: QuickEntry[]): Promise<void> {
14001401 try {
14011402 await saveQuickReactions(list);
14021403 } catch (e) {
Modified file: web/src/lib/MessageRow.svelte+25 additions−19 deletions
@@ -15,8 +15,9 @@
1515 import { selectedRow } from './selected-row';
1616 import { mediaUrl, messageMediaUrl } from './api';
1717 import { keywordMatcher } from './keywords';
18- import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
18+ import type { ImagePack, LinkPreview, Person, QuickEntry, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
1919 import { QUICK_REACTIONS_PER_ROW, senderLabel } from './types';
20+ import { quickColumns, quickGroups } from './quick-reactions-model';
2021 import { formatClock, formatDay, initialsOf, senderHue } from './format';
2122 import { findUrls } from './urls';
2223 import { mentionsFromHtml, resolveMentions, stripMarkdownPills } from './mentions';
@@ -27,9 +28,9 @@
2728 export let reacting: boolean;
2829 export let onReact: (eventId: string, key: string, shortcode?: string) => void;
2930 export let senderAvatar: string | null = null;
30- export let quickReactions: QuickReaction[] = [];
31+ export let quickReactions: QuickEntry[] = [];
3132 export let packs: ImagePack[] = [];
32- export let onQuickChange: (list: QuickReaction[]) => void = () => {};
33+ export let onQuickChange: (list: QuickEntry[]) => void = () => {};
3334 export let linkPreviews: Record<string, LinkPreview | null> = {};
3435 export let loadPreview: ((url: string) => void) | null = null;
3536 export let people: Record<string, Person> = {};
@@ -392,23 +393,27 @@
392393 on:focusout={onFocusOut}
393394 >
394395 {#if showActions && event.event_id}
396+ {@const quickRows = quickGroups(quickReactions)}
395397 <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
396- <div class="quick" style={`--quick-cols:${Math.min(quickReactions.length, QUICK_REACTIONS_PER_ROW)}`}>
397- {#each quickReactions as r (r.key)}
398- {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
399- <button
400- type="button"
401- title={`React ${label}`}
402- aria-label={`React ${label} to message from ${displayName}`}
403- disabled={reacting}
404- on:click={() => react(r.key, r.shortcode)}
405- >
406- {#if r.key.startsWith('mxc://')}
407- <img class="emote" src={mediaUrl(r.key, 64) ?? ''} alt="" />
408- {:else}
409- <Emoji emoji={r.key} size={18} />
410- {/if}
411- </button>
398+ <div class="quick" style={`--quick-cols:${quickColumns(quickRows, QUICK_REACTIONS_PER_ROW)}`}>
399+ {#each quickRows as group, g}
400+ {#each group as r, i (r.key)}
401+ {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
402+ <button
403+ type="button"
404+ class:group-start={g > 0 && i === 0}
405+ title={`React ${label}`}
406+ aria-label={`React ${label} to message from ${displayName}`}
407+ disabled={reacting}
408+ on:click={() => react(r.key, r.shortcode)}
409+ >
410+ {#if r.key.startsWith('mxc://')}
411+ <img class="emote" src={mediaUrl(r.key, 64) ?? ''} alt="" />
412+ {:else}
413+ <Emoji emoji={r.key} size={18} />
414+ {/if}
415+ </button>
416+ {/each}
412417 {/each}
413418 </div>
414419 <button
@@ -762,6 +767,7 @@
762767 }
763768 .quick { display: grid; grid-template-columns: repeat(var(--quick-cols, 8), auto); gap: 2px; }
764769 .quick:empty { display: none; }
770+ .quick .group-start { grid-column-start: 1; }
765771 .actions button {
766772 background: transparent;
767773 border: 1px solid transparent;
Modified file: web/src/lib/ReactionPicker.svelte+105 additions−22 deletions
@@ -1,26 +1,33 @@
11 <script lang="ts">
22 import EmojiText from './EmojiText.svelte';
3- import { onMount } from 'svelte';
3+ import { onMount, tick } from 'svelte';
44 import { mediaUrl, messageMediaUrl } from './api';
55 import Emoji from './Emoji.svelte';
66 import { loadEmoji, searchEmoji } from './emoji';
77 import type { EmojiGroup } from './emoji';
8- import type { ImagePack, PackImage, QuickReaction } from './types';
8+ import type { ImagePack, PackImage, QuickEntry, QuickSeparator } from './types';
99 import { hasUsage } from './packs';
10+ import { isSeparator, normaliseQuick, sameQuick } from './quick-reactions-model';
11+ import { flipAfter, reorder } from './reorder';
12+ import { moveBy, moveToSlot } from './reorder-model';
1013
1114 export let packs: ImagePack[];
12- export let quick: QuickReaction[];
15+ export let quick: QuickEntry[];
1316 export let onPick: (key: string, shortcode: string | null) => void;
14- export let onQuickChange: (list: QuickReaction[]) => void;
17+ export let onQuickChange: (list: QuickEntry[]) => void;
1518 export let onClose: () => void;
1619 export let embedded = false;
1720
1821 let root: HTMLDivElement;
1922 let input: HTMLInputElement;
23+ let row: HTMLDivElement;
2024 let query = '';
2125 let customising = embedded;
2226 let groups: EmojiGroup[] | null = null;
2327 let loadFailed = false;
28+ let draftSeparator: QuickSeparator | null = null;
29+ const separatorIds = new WeakMap<QuickSeparator, number>();
30+ let separatorCount = 0;
2431
2532 onMount(() => {
2633 if (!embedded) input.focus();
@@ -35,6 +42,8 @@
3542 .map((pack) => ({ pack, images: pack.images.filter((i) => imageMatches(i, q)) }))
3643 .filter((p) => p.images.length > 0);
3744 $: filteredGroups = searchEmoji(groups ?? [], q);
45+ $: working = draftSeparator ? [...quick, draftSeparator] : quick;
46+ $: canSeparate = working.length > 0 && !isSeparator(working[working.length - 1]);
3847 $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;
3948
4049 function imageMatches(image: PackImage, needle: string): boolean {
@@ -48,7 +57,13 @@
4857 }
4958
5059 function isQuick(key: string): boolean {
51- return quick.some((r) => r.key === key);
60+ return quick.some((r) => !isSeparator(r) && r.key === key);
61+ }
62+
63+ function commit(list: QuickEntry[]): void {
64+ const next = normaliseQuick(list);
65+ draftSeparator = null;
66+ if (!sameQuick(next, quick)) onQuickChange(next);
5267 }
5368
5469 function choose(key: string, shortcode: string | null): void {
@@ -56,12 +71,41 @@
5671 onPick(key, shortcode);
5772 return;
5873 }
59- if (isQuick(key)) onQuickChange(quick.filter((r) => r.key !== key));
60- else onQuickChange([...quick, { key, shortcode }]);
74+ if (isQuick(key)) commit(working.filter((r) => isSeparator(r) || r.key !== key));
75+ else commit([...working, { key, shortcode }]);
76+ }
77+
78+ function entryKey(entry: QuickEntry): string {
79+ if (!isSeparator(entry)) return entry.key;
80+ let id = separatorIds.get(entry);
81+ if (id === undefined) separatorIds.set(entry, (id = separatorCount++));
82+ return `separator:${id}`;
83+ }
84+
85+ function entryLabel(entry: QuickEntry): string {
86+ if (isSeparator(entry)) return 'separator';
87+ return entry.shortcode ? `:${entry.shortcode}:` : entry.key;
88+ }
89+
90+ function remove(index: number): void {
91+ commit(working.filter((_, i) => i !== index));
92+ }
93+
94+ function moveItem(from: number, slot: number): void {
95+ const next = moveToSlot(working, from, slot);
96+ if (next !== working) commit(next);
6197 }
6298
63- function quickLabel(r: QuickReaction): string {
64- return r.shortcode ? `:${r.shortcode}:` : r.key;
99+ async function onChipKeydown(e: KeyboardEvent, index: number, entry: QuickEntry): Promise<void> {
100+ if (!(e.altKey || e.shiftKey) || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
101+ e.preventDefault();
102+ const next = moveBy(working, index, e.key === 'ArrowLeft' ? -1 : 1);
103+ if (next === working) return;
104+ const id = entryKey(entry);
105+ void flipAfter(row, () => commit(next));
106+ await tick();
107+ const grabs = row.querySelectorAll<HTMLElement>('.grab');
108+ (row.querySelector<HTMLElement>(`[data-reorder="${CSS.escape(id)}"] .grab`) ?? grabs[Math.min(index, grabs.length - 1)])?.focus();
65109 }
66110
67111 function onKeydown(e: KeyboardEvent): void {
@@ -100,20 +144,33 @@
100144 <div class="quick" role="group" aria-label="Quick reactions">
101145 <div class="quick-head">
102146 <span>Quick reactions</span>
147+ <button type="button" class="add-sep" disabled={!canSeparate} on:click={() => (draftSeparator = { separator: true })}>
148+ Add separator
149+ </button>
103150 </div>
104- <div class="quick-row">
105- {#each quick as r (r.key)}
106- <span class="quick-item">
107- {#if r.key.startsWith('mxc://')}
108- <img src={mediaUrl(r.key, 64) ?? ''} alt={quickLabel(r)} />
109- {:else}
110- <Emoji emoji={r.key} size={18} />
111- {/if}
151+ <div class="quick-row" bind:this={row} use:reorder={{ onMove: moveItem }}>
152+ {#each working as entry, i (entryKey(entry))}
153+ <span class="quick-item" class:separator={isSeparator(entry)} data-reorder={entryKey(entry)}>
154+ <button
155+ type="button"
156+ class="grab"
157+ data-reorder-handle
158+ aria-label={`Move ${entryLabel(entry)}, ${i + 1} of ${working.length}`}
159+ on:keydown={(e) => onChipKeydown(e, i, entry)}
160+ >
161+ {#if isSeparator(entry)}
162+ <span class="bar"></span>
163+ {:else if entry.key.startsWith('mxc://')}
164+ <img src={mediaUrl(entry.key, 64) ?? ''} alt="" draggable="false" />
165+ {:else}
166+ <Emoji emoji={entry.key} size={18} />
167+ {/if}
168+ </button>
112169 <button
113170 type="button"
114171 class="remove"
115- aria-label={`Remove ${quickLabel(r)} from quick reactions`}
116- on:click={() => onQuickChange(quick.filter((x) => x.key !== r.key))}
172+ aria-label={`Remove ${entryLabel(entry)} from quick reactions`}
173+ on:click={() => remove(i)}
117174 >
118175 ×
119176 </button>
@@ -210,15 +267,41 @@
210267 .search:focus-visible { outline: none; border-color: var(--accent); }
211268 .muted { color: var(--muted); font-size: 12px; }
212269 .quick { border: 1px solid var(--border); border-radius: var(--r-md); padding: 6px; background: var(--bg-soft); }
213- .quick-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
214- .quick-row { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
270+ .quick-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
271+ .add-sep {
272+ background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
273+ color: var(--text); font-size: 11px; font-weight: 400; padding: 1px 6px; cursor: pointer;
274+ transition: border-color var(--pop);
275+ }
276+ .add-sep:hover:not(:disabled) { border-color: var(--accent); }
277+ .add-sep:disabled { opacity: 0.4; cursor: default; }
278+ .quick-row { position: relative; display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
215279 .quick-item {
216280 display: inline-flex; align-items: center; gap: 2px;
217- padding: 1px 2px 1px 6px;
281+ padding: 1px 2px 1px 0;
218282 border: 1px solid var(--border); border-radius: var(--r-sm);
219283 font-size: 16px;
284+ user-select: none;
285+ }
286+ .quick-item.separator { border-style: dashed; }
287+ .quick-item:has(.grab:focus-visible) { border-color: var(--accent); }
288+ .quick-item:global([data-dragging]) {
289+ z-index: 2; background: var(--bg-soft); border-color: var(--accent);
290+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
220291 }
221292 .quick-item img { width: 18px; height: 18px; object-fit: contain; }
293+ .grab {
294+ display: inline-flex; align-items: center; height: 20px; padding: 0 2px 0 6px;
295+ background: transparent; border: none; color: inherit; font: inherit;
296+ cursor: grab; touch-action: none; -webkit-touch-callout: none; user-select: none;
297+ }
298+ .grab:focus-visible { outline: none; }
299+ .quick-item:global([data-dragging]) .grab { cursor: grabbing; }
300+ .separator .grab { padding: 0 6px; }
301+ .bar { width: 1px; height: 14px; background: var(--muted); }
302+ @media (pointer: coarse) {
303+ .grab { height: 30px; }
304+ }
222305 .remove {
223306 background: transparent; border: none; color: var(--muted);
224307 cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: var(--r-sm);
Modified file: web/src/lib/RoomTimelineRows.svelte+3 additions−3 deletions
@@ -5,7 +5,7 @@
55 import { expand, motionActive, rowIn, rowOut, setRowMotion } from './motion';
66 import type { PendingMessage } from './pending';
77 import { buildGroups, type Group, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';
8- import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';
8+ import type { ImagePack, LinkPreview, Person, QuickEntry, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';
99
1010 export let items: TimelineItem[];
1111 export let pending: PendingMessage[] = [];
@@ -15,7 +15,7 @@
1515 export let people: Record<string, Person> = {};
1616 export let senderAvatarFor: (userId: string) => string | null;
1717 export let reacting: boolean;
18- export let quickReactions: QuickReaction[] = [];
18+ export let quickReactions: QuickEntry[] = [];
1919 export let packs: ImagePack[] = [];
2020 export let linkPreviews: Record<string, LinkPreview | null> = {};
2121 export let loadPreview: ((url: string) => void) | null = null;
@@ -26,7 +26,7 @@
2626 export let fillingGap: string | null = null;
2727 export let following = true;
2828 export let onReact: (eventId: string, key: string, shortcode?: string) => void;
29- export let onQuickChange: (list: QuickReaction[]) => void;
29+ export let onQuickChange: (list: QuickEntry[]) => void;
3030 export let onUser: (userId: string, x: number, y: number, menu: boolean) => void;
3131 export let onCloneSticker: (eventId: string, body: string, x: number, y: number) => void;
3232 export let onMore: (event: TimelineEvent, x: number, y: number) => void;
Modified file: web/src/lib/SettingsPanel.svelte+5 additions−5 deletions
@@ -11,20 +11,20 @@
1111 messageSpacing
1212 } from './appearance';
1313 import { DEFAULT_QUICK_REACTIONS } from './quick-reactions';
14+ import { sameQuick } from './quick-reactions-model';
1415 import ReactionPicker from './ReactionPicker.svelte';
1516 import ThemeSettings from './ThemeSettings.svelte';
1617 import KeywordSettings from './KeywordSettings.svelte';
1718 import NotificationSettings from './NotificationSettings.svelte';
18- import type { ImagePack, QuickReaction } from './types';
19+ import type { ImagePack, QuickEntry } from './types';
1920
20- export let quick: QuickReaction[];
21+ export let quick: QuickEntry[];
2122 export let packs: ImagePack[] = [];
22- export let onQuickChange: (list: QuickReaction[]) => void;
23+ export let onQuickChange: (list: QuickEntry[]) => void;
2324
2425 const dispatch = createEventDispatcher<{ close: void; packs: void }>();
2526
26- $: quickIsDefault =
27- quick.length === DEFAULT_QUICK_REACTIONS.length && quick.every((r, i) => r.key === DEFAULT_QUICK_REACTIONS[i].key);
27+ $: quickIsDefault = sameQuick(quick, DEFAULT_QUICK_REACTIONS);
2828
2929 </script>
3030
Modified file: web/src/lib/ThreadPane.svelte+3 additions−3 deletions
@@ -32,7 +32,7 @@
3232 import { releaseStaged, stageFiles } from './staging';
3333 import { pushBanner, threadKey, threadTimelines, timelines } from './store';
3434 import { pinnedThreads, togglePinnedThread } from './threads';
35- import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, TimelineEvent, TimelineItem } from './types';
35+ import type { ImagePack, LinkPreview, Person, QuickEntry, ReplyReference, TimelineEvent, TimelineItem } from './types';
3636
3737 export let roomId: string;
3838 export let rootEventId: string;
@@ -41,11 +41,11 @@
4141 export let self: string | null;
4242 export let people: Record<string, Person> = {};
4343 export let senderAvatarFor: (userId: string) => string | null = () => null;
44- export let quickReactions: QuickReaction[] = [];
44+ export let quickReactions: QuickEntry[] = [];
4545 export let packs: ImagePack[] = [];
4646 export let linkPreviews: Record<string, LinkPreview | null> = {};
4747 export let loadPreview: ((url: string) => void) | null = null;
48- export let onQuickChange: (list: QuickReaction[]) => void = () => {};
48+ export let onQuickChange: (list: QuickEntry[]) => void = () => {};
4949 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
5050 export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
5151 export let onMentionStart: () => void = () => {};
Modified file: web/src/lib/pack-preferences-model.ts+4 additions−4 deletions
@@ -1,4 +1,4 @@
1-import type { ImagePack, QuickReaction } from './types';
1+import type { ImagePack, QuickEntry } from './types';
22
33 export type DisabledPackImages = Record<string, string[]>;
44
@@ -66,10 +66,10 @@ export function enabledPacks(packs: ImagePack[], disabled: DisabledPackImages):
6666 }
6767
6868 export function enabledQuickReactions(
69- quick: QuickReaction[],
69+ quick: QuickEntry[],
7070 packs: ImagePack[],
7171 disabled: DisabledPackImages
72-): QuickReaction[] {
72+): QuickEntry[] {
7373 const hidden = new Set<string>();
7474 const enabled = new Set<string>();
7575 for (const pack of packs) {
@@ -77,5 +77,5 @@ export function enabledQuickReactions(
7777 (imageEnabled(disabled, pack.id, image.shortcode) ? enabled : hidden).add(image.reaction_key);
7878 }
7979 }
80- return quick.filter((reaction) => !hidden.has(reaction.key) || enabled.has(reaction.key));
80+ return quick.filter((entry) => 'separator' in entry || !hidden.has(entry.key) || enabled.has(entry.key));
8181 }
Added file: web/src/lib/quick-reactions-model.ts+70 additions
@@ -0,0 +1,70 @@
1+import type { QuickEntry, QuickReaction, QuickSeparator } from './types';
2+
3+export function isSeparator(entry: QuickEntry): entry is QuickSeparator {
4+ return 'separator' in entry;
5+}
6+
7+export function normaliseQuick(entries: readonly QuickEntry[]): QuickEntry[] {
8+ const seen = new Set<string>();
9+ const out: QuickEntry[] = [];
10+ for (const entry of entries) {
11+ if (isSeparator(entry)) {
12+ const last = out[out.length - 1];
13+ if (last && !isSeparator(last)) out.push(entry);
14+ continue;
15+ }
16+ if (seen.has(entry.key)) continue;
17+ seen.add(entry.key);
18+ out.push(entry);
19+ }
20+ const last = out[out.length - 1];
21+ if (last && isSeparator(last)) out.pop();
22+ return out;
23+}
24+
25+export function parseQuickReactions(raw: unknown): QuickEntry[] | null {
26+ if (typeof raw !== 'object' || raw === null) return null;
27+ const list = (raw as Record<string, unknown>)['reactions'];
28+ if (!Array.isArray(list)) return null;
29+ const entries: QuickEntry[] = [];
30+ for (const item of list) {
31+ if (typeof item !== 'object' || item === null) continue;
32+ const { key, shortcode, separator } = item as Record<string, unknown>;
33+ if (separator === true) {
34+ entries.push({ separator: true });
35+ continue;
36+ }
37+ if (typeof key !== 'string' || !key) continue;
38+ entries.push({ key, shortcode: typeof shortcode === 'string' ? shortcode : null });
39+ }
40+ return normaliseQuick(entries);
41+}
42+
43+export function quickGroups(entries: readonly QuickEntry[]): QuickReaction[][] {
44+ const groups: QuickReaction[][] = [];
45+ let group: QuickReaction[] = [];
46+ for (const entry of entries) {
47+ if (!isSeparator(entry)) {
48+ group.push(entry);
49+ continue;
50+ }
51+ if (group.length > 0) groups.push(group);
52+ group = [];
53+ }
54+ if (group.length > 0) groups.push(group);
55+ return groups;
56+}
57+
58+export function quickColumns(groups: readonly QuickReaction[][], perRow: number): number {
59+ return groups.reduce((cols, group) => Math.max(cols, Math.min(group.length, perRow)), 0);
60+}
61+
62+export function sameQuick(a: readonly QuickEntry[], b: readonly QuickEntry[]): boolean {
63+ return (
64+ a.length === b.length &&
65+ a.every((entry, i) => {
66+ const other = b[i];
67+ return isSeparator(entry) ? isSeparator(other) : !isSeparator(other) && entry.key === other.key;
68+ })
69+ );
70+}
Modified file: web/src/lib/quick-reactions.ts+6 additions−20 deletions
@@ -1,6 +1,7 @@
11 import { get, writable } from 'svelte/store';
22 import { getAccountData, setAccountData } from './api';
3-import type { QuickReaction } from './types';
3+import { normaliseQuick, parseQuickReactions } from './quick-reactions-model';
4+import type { QuickEntry, QuickReaction } from './types';
45
56 const EVENT_TYPE = 'dev.malice.quick_reactions';
67
@@ -13,33 +14,18 @@ export const DEFAULT_QUICK_REACTIONS: QuickReaction[] = [
1314 { key: '😢', shortcode: 'cry' }
1415 ];
1516
16-export const quickReactions = writable<QuickReaction[]>(DEFAULT_QUICK_REACTIONS);
17-
18-function parse(raw: unknown): QuickReaction[] | null {
19- if (typeof raw !== 'object' || raw === null) return null;
20- const list = (raw as Record<string, unknown>)['reactions'];
21- if (!Array.isArray(list)) return null;
22- const seen = new Set<string>();
23- const out: QuickReaction[] = [];
24- for (const item of list) {
25- if (typeof item !== 'object' || item === null) continue;
26- const { key, shortcode } = item as Record<string, unknown>;
27- if (typeof key !== 'string' || !key || seen.has(key)) continue;
28- seen.add(key);
29- out.push({ key, shortcode: typeof shortcode === 'string' ? shortcode : null });
30- }
31- return out;
32-}
17+export const quickReactions = writable<QuickEntry[]>(DEFAULT_QUICK_REACTIONS);
3318
3419 export async function loadQuickReactions(): Promise<void> {
3520 try {
36- quickReactions.set(parse(await getAccountData(EVENT_TYPE)) ?? DEFAULT_QUICK_REACTIONS);
21+ quickReactions.set(parseQuickReactions(await getAccountData(EVENT_TYPE)) ?? DEFAULT_QUICK_REACTIONS);
3722 } catch {
3823 quickReactions.set(DEFAULT_QUICK_REACTIONS);
3924 }
4025 }
4126
42-export async function saveQuickReactions(reactions: QuickReaction[]): Promise<void> {
27+export async function saveQuickReactions(entries: QuickEntry[]): Promise<void> {
28+ const reactions = normaliseQuick(entries);
4329 const previous = get(quickReactions);
4430 quickReactions.set(reactions);
4531 try {
Added file: web/src/lib/reorder-model.ts+57 additions
@@ -0,0 +1,57 @@
1+export interface Box {
2+ left: number;
3+ top: number;
4+ width: number;
5+ height: number;
6+}
7+
8+export interface Slot {
9+ slot: number;
10+ after: boolean;
11+}
12+
13+function rowsOf(boxes: readonly Box[]): number[][] {
14+ const rows: number[][] = [];
15+ boxes.forEach((box, i) => {
16+ const row = rows[rows.length - 1];
17+ const lead = row && boxes[row[0]];
18+ if (row && lead && Math.abs(box.top - lead.top) < lead.height / 2) row.push(i);
19+ else rows.push([i]);
20+ });
21+ return rows;
22+}
23+
24+export function dropSlot(boxes: readonly Box[], x: number, y: number): Slot {
25+ const rows = rowsOf(boxes);
26+ let best = rows[0];
27+ let bestGap = Infinity;
28+ for (const row of rows) {
29+ const { top, height } = boxes[row[0]];
30+ const gap = y < top ? top - y : y > top + height ? y - top - height : 0;
31+ if (gap < bestGap) {
32+ best = row;
33+ bestGap = gap;
34+ }
35+ }
36+ if (!best) return { slot: 0, after: false };
37+ for (const i of best) {
38+ if (x < boxes[i].left + boxes[i].width / 2) return { slot: i, after: false };
39+ }
40+ return { slot: best[best.length - 1] + 1, after: true };
41+}
42+
43+export function moveToSlot<T>(list: T[], from: number, slot: number): T[] {
44+ if (from < 0 || from >= list.length) return list;
45+ const to = Math.min(list.length, Math.max(0, slot));
46+ const target = to > from ? to - 1 : to;
47+ if (target === from) return list;
48+ const next = list.slice();
49+ const [item] = next.splice(from, 1);
50+ next.splice(target, 0, item);
51+ return next;
52+}
53+
54+export function moveBy<T>(list: T[], from: number, delta: number): T[] {
55+ const to = Math.min(list.length - 1, Math.max(0, from + delta));
56+ return moveToSlot(list, from, to > from ? to + 1 : to);
57+}
Added file: web/src/lib/reorder.ts+182 additions
@@ -0,0 +1,182 @@
1+import { tick } from 'svelte';
2+import { dropSlot, type Box } from './reorder-model';
3+
4+export interface ReorderOptions {
5+ onMove: (from: number, slot: number) => void;
6+}
7+
8+const ITEM = '[data-reorder]';
9+const HANDLE = '[data-reorder-handle]';
10+const SLOP = 5;
11+const MOTION_MS = 140;
12+const EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)';
13+
14+interface Drag {
15+ pointerId: number;
16+ el: HTMLElement;
17+ from: number;
18+ x: number;
19+ y: number;
20+ started: boolean;
21+ boxes: Box[];
22+ gap: number;
23+ mark: HTMLElement | null;
24+ slot: number;
25+ after: boolean;
26+}
27+
28+function reducedMotion(): boolean {
29+ return matchMedia('(prefers-reduced-motion: reduce)').matches;
30+}
31+
32+export async function flipAfter(root: HTMLElement, change: () => void): Promise<void> {
33+ const before = new Map<string, DOMRect>();
34+ for (const el of root.querySelectorAll<HTMLElement>(ITEM)) before.set(el.dataset.reorder ?? '', el.getBoundingClientRect());
35+ change();
36+ await tick();
37+ if (reducedMotion()) return;
38+ const running: Animation[] = [];
39+ for (const el of root.querySelectorAll<HTMLElement>(ITEM)) {
40+ const from = before.get(el.dataset.reorder ?? '');
41+ if (!from) continue;
42+ const to = el.getBoundingClientRect();
43+ const dx = from.left - to.left;
44+ const dy = from.top - to.top;
45+ if (dx === 0 && dy === 0) continue;
46+ running.push(el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: MOTION_MS, easing: EASING }));
47+ }
48+ await Promise.allSettled(running.map((a) => a.finished));
49+}
50+
51+export function reorder(node: HTMLElement, options: ReorderOptions) {
52+ let opts = options;
53+ let drag: Drag | null = null;
54+
55+ if (getComputedStyle(node).position === 'static') node.style.position = 'relative';
56+
57+ function items(): HTMLElement[] {
58+ return Array.from(node.querySelectorAll<HTMLElement>(ITEM));
59+ }
60+
61+ function down(ev: PointerEvent): void {
62+ if (drag || !ev.isPrimary || (ev.pointerType === 'mouse' && ev.button !== 0)) return;
63+ const el = (ev.target as Element).closest(HANDLE)?.closest<HTMLElement>(ITEM);
64+ if (!el || !node.contains(el)) return;
65+ drag = { pointerId: ev.pointerId, el, from: items().indexOf(el), x: ev.clientX, y: ev.clientY, started: false, boxes: [], gap: 0, mark: null, slot: 0, after: false };
66+ window.addEventListener('pointermove', move);
67+ window.addEventListener('pointerup', up);
68+ window.addEventListener('pointercancel', cancel);
69+ window.addEventListener('keydown', key, true);
70+ }
71+
72+ function begin(d: Drag): void {
73+ d.started = true;
74+ d.slot = d.from;
75+ d.el.setPointerCapture(d.pointerId);
76+ d.el.dataset.dragging = '';
77+ const origin = node.getBoundingClientRect();
78+ d.boxes = items().map((el) => {
79+ const r = el.getBoundingClientRect();
80+ return { left: r.left - origin.left, top: r.top - origin.top, width: r.width, height: r.height };
81+ });
82+ d.gap = parseFloat(getComputedStyle(node).columnGap) || 0;
83+ const mark = document.createElement('span');
84+ Object.assign(mark.style, {
85+ position: 'absolute',
86+ left: '0',
87+ top: '0',
88+ width: '2px',
89+ background: 'var(--accent)',
90+ pointerEvents: 'none',
91+ transition: `transform ${MOTION_MS}ms ${EASING}, opacity ${MOTION_MS}ms`
92+ });
93+ d.mark = mark;
94+ showMark(d);
95+ node.append(mark);
96+ }
97+
98+ function showMark(d: Drag): void {
99+ const anchor = d.boxes[d.after ? d.slot - 1 : d.slot];
100+ if (!d.mark || !anchor) return;
101+ const x = (d.after ? anchor.left + anchor.width + d.gap / 2 : anchor.left - d.gap / 2) - 1;
102+ d.mark.style.height = `${anchor.height}px`;
103+ d.mark.style.transform = `translate(${x}px, ${anchor.top}px)`;
104+ d.mark.style.opacity = d.slot === d.from || d.slot === d.from + 1 ? '0' : '1';
105+ }
106+
107+ function move(ev: PointerEvent): void {
108+ const d = drag;
109+ if (!d || ev.pointerId !== d.pointerId) return;
110+ const dx = ev.clientX - d.x;
111+ const dy = ev.clientY - d.y;
112+ if (!d.started) {
113+ if (Math.hypot(dx, dy) < SLOP) return;
114+ begin(d);
115+ }
116+ d.el.style.transform = `translate(${dx}px, ${dy}px)`;
117+ const origin = node.getBoundingClientRect();
118+ Object.assign(d, dropSlot(d.boxes, ev.clientX - origin.left, ev.clientY - origin.top));
119+ showMark(d);
120+ }
121+
122+ function swallowClick(): void {
123+ const stop = (e: Event) => e.stopPropagation();
124+ window.addEventListener('click', stop, { capture: true, once: true });
125+ setTimeout(() => window.removeEventListener('click', stop, true), 0);
126+ }
127+
128+ function finish(commit: boolean): void {
129+ const d = drag;
130+ if (!d) return;
131+ drag = null;
132+ window.removeEventListener('pointermove', move);
133+ window.removeEventListener('pointerup', up);
134+ window.removeEventListener('pointercancel', cancel);
135+ window.removeEventListener('keydown', key, true);
136+ if (!d.started) return;
137+ d.mark?.remove();
138+ swallowClick();
139+ const el = d.el;
140+ const release = () => delete el.dataset.dragging;
141+ if (commit && d.slot !== d.from && d.slot !== d.from + 1) {
142+ void flipAfter(node, () => {
143+ el.style.transform = '';
144+ opts.onMove(d.from, d.slot);
145+ }).then(release);
146+ return;
147+ }
148+ el.style.transition = `transform ${MOTION_MS}ms ${EASING}`;
149+ el.style.transform = '';
150+ setTimeout(() => {
151+ el.style.transition = '';
152+ release();
153+ }, reducedMotion() ? 0 : MOTION_MS);
154+ }
155+
156+ const up = (ev: PointerEvent) => {
157+ if (drag && ev.pointerId === drag.pointerId) finish(true);
158+ };
159+ const cancel = (ev: PointerEvent) => {
160+ if (drag && ev.pointerId === drag.pointerId) finish(false);
161+ };
162+ const key = (ev: KeyboardEvent) => {
163+ if (ev.key !== 'Escape' || !drag?.started) return;
164+ ev.stopPropagation();
165+ finish(false);
166+ };
167+ const prevent = (ev: Event) => ev.preventDefault();
168+
169+ node.addEventListener('pointerdown', down);
170+ node.addEventListener('dragstart', prevent);
171+
172+ return {
173+ update(next: ReorderOptions) {
174+ opts = next;
175+ },
176+ destroy() {
177+ finish(false);
178+ node.removeEventListener('pointerdown', down);
179+ node.removeEventListener('dragstart', prevent);
180+ }
181+ };
182+}
Modified file: web/src/lib/types.ts+4 additions
@@ -936,6 +936,10 @@ export interface QuickReaction {
936936 key: string;
937937 shortcode: string | null;
938938 }
939+export interface QuickSeparator {
940+ separator: true;
941+}
942+export type QuickEntry = QuickReaction | QuickSeparator;
939943
940944 export interface LinkPreviewImage {
941945 media_id: string;
Added file: web/test/quick-reactions.test.ts+91 additions
@@ -0,0 +1,91 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { enabledQuickReactions } from '../src/lib/pack-preferences-model.ts';
4+import {
5+ isSeparator,
6+ normaliseQuick,
7+ parseQuickReactions,
8+ quickColumns,
9+ quickGroups,
10+ sameQuick
11+} from '../src/lib/quick-reactions-model.ts';
12+import type { QuickEntry } from '../src/lib/types.ts';
13+
14+const r = (key: string): QuickEntry => ({ key, shortcode: null });
15+const sep = (): QuickEntry => ({ separator: true });
16+const shape = (list: QuickEntry[]) => list.map((e) => (isSeparator(e) ? '|' : e.key)).join(' ');
17+
18+test('normalising drops leading, trailing and doubled separators', () => {
19+ assert.equal(shape(normaliseQuick([sep(), r('a'), sep(), sep(), r('b'), sep()])), 'a | b');
20+ assert.equal(shape(normaliseQuick([sep(), sep()])), '');
21+ assert.equal(shape(normaliseQuick([])), '');
22+});
23+
24+test('normalising dedupes keys and keeps the break between survivors', () => {
25+ assert.equal(shape(normaliseQuick([r('a'), sep(), r('a'), sep(), r('b')])), 'a | b');
26+ assert.equal(shape(normaliseQuick([r('a'), r('b'), sep(), r('a'), sep(), r('c')])), 'a b | c');
27+});
28+
29+test('normalising keeps separator identity and leaves the input alone', () => {
30+ const middle = sep();
31+ const input = [r('a'), middle, r('b'), sep()];
32+ const out = normaliseQuick(input);
33+ assert.equal(out[1], middle);
34+ assert.equal(input.length, 4);
35+});
36+
37+test('parsing keeps separators and tolerates what older builds wrote', () => {
38+ const parsed = parseQuickReactions({
39+ reactions: [{ key: 'a', shortcode: 'x' }, { separator: true }, { key: 'b' }, 7, null, { key: '' }, { key: 'a' }, { separator: true }]
40+ });
41+ assert.deepEqual(parsed, [{ key: 'a', shortcode: 'x' }, { separator: true }, { key: 'b', shortcode: null }]);
42+});
43+
44+test('parsing rejects data without a reactions list', () => {
45+ assert.equal(parseQuickReactions(null), null);
46+ assert.equal(parseQuickReactions({ reactions: 'a' }), null);
47+ assert.equal(parseQuickReactions('a'), null);
48+ assert.deepEqual(parseQuickReactions({ reactions: [] }), []);
49+});
50+
51+test('only a literal true makes a separator', () => {
52+ assert.deepEqual(parseQuickReactions({ reactions: [{ separator: 'yes' }, { separator: false, key: 'a' }] }), [{ key: 'a', shortcode: null }]);
53+});
54+
55+test('groups split at separators and skip empty runs', () => {
56+ const groups = quickGroups([r('a'), r('b'), sep(), sep(), r('c'), sep()]);
57+ assert.deepEqual(groups.map((g) => g.map((e) => e.key)), [['a', 'b'], ['c']]);
58+ assert.deepEqual(quickGroups([]), []);
59+ assert.deepEqual(quickGroups([sep()]), []);
60+});
61+
62+test('the toolbar is as wide as its widest group up to the row limit', () => {
63+ const groups = quickGroups([r('a'), r('b'), r('c'), sep(), r('d')]);
64+ assert.equal(quickColumns(groups, 8), 3);
65+ assert.equal(quickColumns(groups, 2), 2);
66+ assert.equal(quickColumns([], 8), 0);
67+});
68+
69+test('comparison looks at order, keys and separator positions', () => {
70+ assert.ok(sameQuick([r('a'), sep(), r('b')], [r('a'), sep(), r('b')]));
71+ assert.ok(!sameQuick([r('a'), sep(), r('b')], [r('a'), r('b')]));
72+ assert.ok(!sameQuick([r('a'), r('b')], [r('b'), r('a')]));
73+ assert.ok(!sameQuick([sep()], [r('a')]));
74+});
75+
76+test('a hidden pack image leaves no empty group or doubled break', () => {
77+ const pack = {
78+ id: 'p',
79+ display_name: null,
80+ avatar: null,
81+ attribution: 'source',
82+ source: 'User' as const,
83+ images: [{ shortcode: 'one', media_id: 'mxc://x/one', reaction_key: 'mxc://x/one', body: null, mimetype: null, width: null, height: null, usage: ['Emoticon' as const] }],
84+ editable: true,
85+ enabled_everywhere: false
86+ };
87+ const list = [r('a'), sep(), r('mxc://x/one'), sep(), r('b')];
88+ const visible = normaliseQuick(enabledQuickReactions(list, [pack], { p: ['one'] }));
89+ assert.equal(shape(visible), 'a | b');
90+ assert.equal(shape(normaliseQuick(enabledQuickReactions([r('mxc://x/one'), sep(), r('a')], [pack], { p: ['one'] }))), 'a');
91+});
Added file: web/test/reorder.test.ts+61 additions
@@ -0,0 +1,61 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { dropSlot, moveBy, moveToSlot, type Box } from '../src/lib/reorder-model.ts';
4+
5+const box = (left: number, top: number): Box => ({ left, top, width: 30, height: 24 });
6+
7+const wrapped = [box(0, 0), box(34, 0), box(68, 0), box(0, 28), box(34, 28)];
8+
9+test('a pointer left of a chip centre lands before it', () => {
10+ assert.deepEqual(dropSlot(wrapped, 40, 10), { slot: 1, after: false });
11+ assert.deepEqual(dropSlot(wrapped, 2, 10), { slot: 0, after: false });
12+});
13+
14+test('a pointer right of every chip in a row lands after its last chip', () => {
15+ assert.deepEqual(dropSlot(wrapped, 200, 10), { slot: 3, after: true });
16+ assert.deepEqual(dropSlot(wrapped, 200, 30), { slot: 5, after: true });
17+});
18+
19+test('the nearest row wins when the pointer is above or below the chips', () => {
20+ assert.deepEqual(dropSlot(wrapped, 40, -50), { slot: 1, after: false });
21+ assert.deepEqual(dropSlot(wrapped, 40, 90), { slot: 4, after: false });
22+ assert.deepEqual(dropSlot(wrapped, 10, 27), { slot: 3, after: false });
23+});
24+
25+test('chips of slightly different heights share a row', () => {
26+ const mixed = [box(0, 0), { left: 34, top: 2, width: 12, height: 20 }, box(50, 0)];
27+ assert.deepEqual(dropSlot(mixed, 100, 10), { slot: 3, after: true });
28+});
29+
30+test('an empty list always lands at the start', () => {
31+ assert.deepEqual(dropSlot([], 10, 10), { slot: 0, after: false });
32+});
33+
34+test('moving to a slot after the source accounts for the removed item', () => {
35+ assert.deepEqual(moveToSlot(['a', 'b', 'c', 'd'], 0, 3), ['b', 'c', 'a', 'd']);
36+ assert.deepEqual(moveToSlot(['a', 'b', 'c', 'd'], 0, 4), ['b', 'c', 'd', 'a']);
37+ assert.deepEqual(moveToSlot(['a', 'b', 'c', 'd'], 3, 1), ['a', 'd', 'b', 'c']);
38+});
39+
40+test('moving onto its own slot returns the same list', () => {
41+ const list = ['a', 'b', 'c'];
42+ assert.equal(moveToSlot(list, 1, 1), list);
43+ assert.equal(moveToSlot(list, 1, 2), list);
44+ assert.equal(moveToSlot(list, 5, 0), list);
45+ assert.equal(moveToSlot(list, -1, 0), list);
46+});
47+
48+test('moving never mutates the input and clamps the slot', () => {
49+ const list = ['a', 'b', 'c'];
50+ assert.deepEqual(moveToSlot(list, 0, 99), ['b', 'c', 'a']);
51+ assert.deepEqual(moveToSlot(list, 2, -4), ['c', 'a', 'b']);
52+ assert.deepEqual(list, ['a', 'b', 'c']);
53+});
54+
55+test('moving by a step swaps neighbours and stops at the ends', () => {
56+ const list = ['a', 'b', 'c'];
57+ assert.deepEqual(moveBy(list, 0, 1), ['b', 'a', 'c']);
58+ assert.deepEqual(moveBy(list, 2, -1), ['a', 'c', 'b']);
59+ assert.equal(moveBy(list, 0, -1), list);
60+ assert.equal(moveBy(list, 2, 1), list);
61+});