Commit 5059ed753f8b
tarball14 files changed+620-79
@@ -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) {@@ -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
- <button400
- 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
+ <button403
+ 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;@@ -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 separator149
+ </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
+ <button155
+ type="button"156
+ class="grab"157
+ data-reorder-handle158
+ 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
<button113170
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);@@ -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;@@ -11,20 +11,20 @@
1111
messageSpacing1212
} 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
@@ -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 = () => {};@@ -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: DisabledPackImages72
-): 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
}@@ -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
+}@@ -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 {@@ -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
+}@@ -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
+}@@ -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;@@ -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
+ sameQuick11
+} 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: false86
+ };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
+});@@ -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
+});