atagen

malice

public
Star0
Watch0

Commit 705b1e1062c2

tarball

atagen <boss@atagen.co>2026-10-24 06:07 UTC

12 files changed+290-141
web: render mention pills, pin the timeline and drop explainer text

Some clients put `[name](https://matrix.to/#/@user)` markdown in the plain body, which MessageText showed raw along with a useless matrix.to link card. Mentions now come from formatted_body and stripped markdown pills, and rows that mention you are highlighted. Link cards only appear when the homeserver returned real preview content. Scrolling only unsticks the timeline on user input, so late layout from images or previews no longer drops it off the bottom, and spaces reopen their last channel.
Modified file: web/src/App.svelte+123 additions−90 deletions
@@ -59,7 +59,7 @@
5959 } from './lib/link-previews';
6060 import { notifyTyping, stopTyping } from './lib/typing';
6161 import { formatDay } from './lib/format';
62- import { lineHeight, messageSpacing, navFolded } from './lib/appearance';
62+ import { lastSpaceRooms, lineHeight, messageSpacing, navFolded } from './lib/appearance';
6363 import SettingsPanel from './lib/SettingsPanel.svelte';
6464 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
6565 import type { MemberSummary, OwnProfile, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
@@ -417,7 +417,13 @@
417417 selectedRoomId.set(null);
418418 try {
419419 const res = await getSpaceChildren(spaceId);
420- if (selectedSpaceId === spaceId) spaceChildIds = res.room_ids ?? [];
420+ if (selectedSpaceId === spaceId) {
421+ spaceChildIds = res.room_ids ?? [];
422+ const last = $lastSpaceRooms[spaceId];
423+ if (last && spaceChildIds.includes(last) && $rooms.some((r) => r.room_id === last && !r.space)) {
424+ void selectRoom(last);
425+ }
426+ }
421427 } catch (e) {
422428 if (selectedSpaceId === spaceId) {
423429 spaceChildrenError = e instanceof Error ? e.message : 'Could not load rooms in this space';
@@ -435,6 +441,8 @@
435441 return;
436442 }
437443 stopTyping();
444+ const spaceId = selectedSpaceId;
445+ if (spaceId) lastSpaceRooms.update((all) => ({ ...all, [spaceId]: roomId }));
438446 selectedRoomId.set(roomId);
439447 composer = drafts[roomId] ?? '';
440448 replyingTo = null;
@@ -493,10 +501,35 @@
493501 if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
494502 }
495503
504+ let userScrollUntil = 0;
505+ let draggingScrollbar = false;
506+
507+ function noteUserScroll(): void {
508+ userScrollUntil = performance.now() + 300;
509+ }
510+
511+ function onTimelineKey(e: KeyboardEvent): void {
512+ if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) noteUserScroll();
513+ }
514+
515+ function onTimelinePointer(e: PointerEvent): void {
516+ if (e.target === timelineEl) draggingScrollbar = true;
517+ }
518+
496519 function onTimelineScroll(): void {
497520 if (!timelineEl) return;
498521 const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
499- stickToBottom = gap < 80;
522+ if (gap < 80) stickToBottom = true;
523+ else if (draggingScrollbar || performance.now() < userScrollUntil) stickToBottom = false;
524+ }
525+
526+ function pinTimeline(node: HTMLElement) {
527+ const observer = new ResizeObserver(() => {
528+ if (stickToBottom) scrollToBottom();
529+ });
530+ observer.observe(node);
531+ if (node.parentElement) observer.observe(node.parentElement);
532+ return { destroy: () => observer.disconnect() };
500533 }
501534
502535 $: void trackTimeline($selectedRoomId, items);
@@ -946,7 +979,7 @@
946979 }
947980 </script>
948981
949-<svelte:window on:keydown={onKeydown} />
982+<svelte:window on:keydown={onKeydown} on:pointerup={() => (draggingScrollbar = false)} />
950983
951984 {#if !booted}
952985 <div class="splash" role="status" aria-label="Loading Malice">
@@ -1027,7 +1060,6 @@
10271060 <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
10281061 <div class="space-context-text">
10291062 <strong>{selectedSpace.name}</strong>
1030- <span>Space — channels below</span>
10311063 </div>
10321064 {#if spaceParents.has(selectedSpace.room_id)}
10331065 {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
@@ -1046,21 +1078,20 @@
10461078 <div class="space-context">
10471079 <div class="space-context-text">
10481080 <strong>Home</strong>
1049- <span>All channels</span>
10501081 </div>
10511082 </div>
10521083 {/if}
10531084 {#if roomsLoading && $rooms.length === 0}
10541085 <p class="side-note">Loading rooms…</p>
10551086 {:else if $rooms.length === 0}
1056- <p class="side-note">No rooms yet. Join one or create your own.</p>
1087+ <p class="side-note">No rooms yet</p>
10571088 <div class="side-cta">
10581089 <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>
10591090 <button type="button" on:click={() => (dialog = 'create')}>Create one</button>
10601091 </div>
10611092 {:else}
10621093 {#if selectedSpaceId && spaceChildrenLoading}
1063- <p class="side-note">Loading channels in this space…</p>
1094+ <p class="side-note">Loading channels…</p>
10641095 {:else if selectedSpaceId && spaceChildrenError}
10651096 <p class="side-note" role="alert">{spaceChildrenError}</p>
10661097 <div class="side-cta">
@@ -1068,7 +1099,7 @@
10681099 <button type="button" on:click={() => void selectHome()}>Back to Home</button>
10691100 </div>
10701101 {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}
1071- <p class="side-note">No channels found in this space yet.</p>
1102+ <p class="side-note">No channels</p>
10721103 <div class="side-cta">
10731104 <button type="button" on:click={() => void selectHome()}>Back to Home</button>
10741105 </div>
@@ -1137,7 +1168,7 @@
11371168 {/if}
11381169
11391170 {#if groupRooms.length > 0}
1140- <h2 class="side-h">{selectedSpace ? 'Channels in this space' : 'Rooms'}</h2>
1171+ <h2 class="side-h">{selectedSpace ? 'Channels' : 'Rooms'}</h2>
11411172 <ul class="room-list">
11421173 {#each groupRooms as room (room.room_id)}
11431174 <li>
@@ -1252,10 +1283,8 @@
12521283 <div class="empty-mark" aria-hidden="true">◈</div>
12531284 {#if selectedSpace}
12541285 <h1>{selectedSpace.name}</h1>
1255- <p>Select a channel below to start chatting. Spaces group channels — they are not chat rooms.</p>
12561286 {:else}
12571287 <h1>Welcome to Malice</h1>
1258- <p>Pick a room from the sidebar, or join one to get started.</p>
12591288 {/if}
12601289 <div class="empty-cta">
12611290 <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>
@@ -1361,20 +1390,20 @@
13611390
13621391 {#if recoveryBanner}
13631392 <div class="sec-banner warn" role="status">
1364- <span>Message history is locked — recovery is {recoveryBanner.recovery}. Enter your recovery key to decrypt.</span>
1393+ <span>Message history is locked</span>
13651394 <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
13661395 </div>
13671396 {/if}
13681397 {#if verificationBanner}
13691398 <div class="sec-banner" role="status">
13701399 {#if verificationBanner.kind === 'Incoming'}
1371- <span>A device wants to verify this session. Review the request to accept it.</span>
1400+ <span>A device wants to verify this session</span>
13721401 <button type="button" on:click={() => (dialog = 'security')}>Review request</button>
13731402 {:else if verificationBanner.kind === 'Comparison'}
1374- <span>Verification codes are ready — compare emojis and numbers.</span>
1403+ <span>Verification codes ready</span>
13751404 <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>
13761405 {:else if verificationBanner.kind === 'Waiting'}
1377- <span>Verification started — approve on your other device, then compare codes here.</span>
1406+ <span>Verification started</span>
13781407 <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>
13791408 {:else if verificationBanner.kind === 'Failed'}
13801409 <span>Verification failed: {verificationBanner.message}</span>
@@ -1383,91 +1412,96 @@
13831412 <span>Verification cancelled: {verificationBanner.reason}</span>
13841413 <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
13851414 {:else}
1386- <span>Verification in progress.</span>
1415+ <span>Verification in progress</span>
13871416 <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
13881417 {/if}
13891418 </div>
13901419 {/if}
1391- {#if $connection !== 'online'}
1392- <p class="conn-banner" role="status">
1393- Sync is {$connection}. New messages will appear when the connection recovers.
1394- </p>
1395- {/if}
1396- {#if $selectedRoomId && !openReady[$selectedRoomId] && !openFailed[$selectedRoomId]}
1397- <p class="conn-banner" role="status">Opening {selectedRoom.name}… sending is disabled until the room is ready.</p>
1398- {/if}
13991420 {#if $selectedRoomId && openFailed[$selectedRoomId]}
14001421 <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>
14011422 {/if}
14021423
1403- <div class="timeline" bind:this={timelineEl} on:scroll={onTimelineScroll} aria-label={`Messages in ${selectedRoom.name}`}>
1404- <div class="timeline-top">
1405- {#if hasStart}
1406- <p class="start-note">This is the beginning of the visible history.</p>
1424+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1425+ <div
1426+ class="timeline"
1427+ bind:this={timelineEl}
1428+ on:scroll={onTimelineScroll}
1429+ on:wheel={noteUserScroll}
1430+ on:touchmove={noteUserScroll}
1431+ on:keydown={onTimelineKey}
1432+ on:pointerdown={onTimelinePointer}
1433+ aria-label={`Messages in ${selectedRoom.name}`}
1434+ >
1435+ <div class="timeline-inner" use:pinTimeline>
1436+ <div class="timeline-top">
1437+ {#if hasStart}
1438+ <p class="start-note">Beginning of history</p>
1439+ {:else}
1440+ <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1441+ {paginateBusy ? 'Loading…' : 'Load older messages'}
1442+ </button>
1443+ {/if}
1444+ </div>
1445+ {#if grouped.length === 0}
1446+ <p class="side-note timeline-empty">No messages yet</p>
14071447 {:else}
1408- <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1409- {paginateBusy ? 'Loading…' : 'Load older messages'}
1410- </button>
1448+ {#each grouped as g (g.key)}
1449+ {#if g.item.kind === 'day'}
1450+ <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1451+ {:else if g.item.kind === 'readMarker'}
1452+ <div class="new-row"><span>New</span></div>
1453+ {:else if g.item.kind === 'start'}
1454+ <p class="start-note">Beginning of history</p>
1455+ {:else if g.item.kind === 'unknown'}
1456+ <p class="unknown-note">Unsupported event ({g.item.label})</p>
1457+ {:else}
1458+ <MessageRow
1459+ itemId={g.key}
1460+ event={g.item.event}
1461+ showSender={g.showSender}
1462+ senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
1463+ {reacting}
1464+ onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1465+ quickReactions={$quickReactions}
1466+ packs={roomPacks}
1467+ linkPreviews={$linkPreviews}
1468+ {loadPreview}
1469+ onQuickChange={(list) => void changeQuickReactions(list)}
1470+ {people}
1471+ self={$sessionUser ?? null}
1472+ onReply={selectedRoom.membership === 'Joined' ? startReply : null}
1473+ onJump={jumpTo}
1474+ onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
1475+ editing={editing !== null && editing.eventId === g.item.event.event_id}
1476+ showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
1477+ />
1478+ {/if}
1479+ {/each}
14111480 {/if}
1412- </div>
1413- {#if grouped.length === 0}
1414- <p class="side-note timeline-empty">No messages yet. Say hello!</p>
1415- {:else}
1416- {#each grouped as g (g.key)}
1417- {#if g.item.kind === 'day'}
1418- <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1419- {:else if g.item.kind === 'readMarker'}
1420- <div class="new-row"><span>New</span></div>
1421- {:else if g.item.kind === 'start'}
1422- <p class="start-note">This is the beginning of the visible history.</p>
1423- {:else if g.item.kind === 'unknown'}
1424- <p class="unknown-note">Unsupported event ({g.item.label})</p>
1425- {:else}
1426- <MessageRow
1427- itemId={g.key}
1428- event={g.item.event}
1429- showSender={g.showSender}
1430- senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
1431- {reacting}
1432- onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1433- quickReactions={$quickReactions}
1434- packs={roomPacks}
1435- linkPreviews={$linkPreviews}
1436- {loadPreview}
1437- onQuickChange={(list) => void changeQuickReactions(list)}
1438- {people}
1439- onReply={selectedRoom.membership === 'Joined' ? startReply : null}
1440- onJump={jumpTo}
1441- onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
1442- editing={editing !== null && editing.eventId === g.item.event.event_id}
1443- showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
1444- />
1481+ {#each pendingItems as p, i (p.id)}
1482+ <MessageRow
1483+ itemId={`pending-${p.id}`}
1484+ event={pendingToEvent(p)}
1485+ onJump={jumpTo}
1486+ showSender={pendingShowSender(i)}
1487+ senderAvatar={senderAvatarFor($sessionUser ?? '')}
1488+ {reacting}
1489+ onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1490+ quickReactions={$quickReactions}
1491+ packs={roomPacks}
1492+ linkPreviews={$linkPreviews}
1493+ {loadPreview}
1494+ onQuickChange={(list) => void changeQuickReactions(list)}
1495+ />
1496+ {#if p.status === 'failed'}
1497+ <div class="pending-actions">
1498+ <span class="pending-error" role="alert">{p.error}</span>
1499+ <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1500+ <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1501+ </div>
14451502 {/if}
14461503 {/each}
1447- {/if}
1448- {#each pendingItems as p, i (p.id)}
1449- <MessageRow
1450- itemId={`pending-${p.id}`}
1451- event={pendingToEvent(p)}
1452- onJump={jumpTo}
1453- showSender={pendingShowSender(i)}
1454- senderAvatar={senderAvatarFor($sessionUser ?? '')}
1455- {reacting}
1456- onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1457- quickReactions={$quickReactions}
1458- packs={roomPacks}
1459- linkPreviews={$linkPreviews}
1460- {loadPreview}
1461- onQuickChange={(list) => void changeQuickReactions(list)}
1462- />
1463- {#if p.status === 'failed'}
1464- <div class="pending-actions">
1465- <span class="pending-error" role="alert">{p.error}</span>
1466- <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1467- <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1468- </div>
1469- {/if}
1470- {/each}
1504+ </div>
14711505 </div>
14721506
14731507 {#if selectedRoom.membership === 'Joined'}
@@ -1578,7 +1612,6 @@
15781612 Send
15791613 </button>
15801614 </form>
1581- <p class="composer-note">Sending and failed states show under your message.</p>
15821615 {:else}
15831616 <div class="join-bar">
15841617 <p>You are not a member of this room.</p>
Modified file: web/src/app.css−1 deletions
@@ -212,7 +212,6 @@ input, textarea { font: inherit; }
212212 padding: 3px 10px;
213213 font-size: 12.5px;
214214 }
215-.composer-note { margin: 0; padding: 0 16px 10px; font-size: 11.5px; color: var(--muted); }
216215 .head-room { display: flex; align-items: center; gap: 12px; min-width: 0; }
217216 .head-room-text { min-width: 0; flex: 1; }
218217 .conn-banner.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-text); }
Modified file: web/src/lib/LoginPanel.svelte−4 deletions
@@ -55,7 +55,6 @@
5555 <form class="login-card" on:submit|preventDefault={submit} aria-label="Log in to Matrix">
5656 <div class="login-brand" aria-hidden="true">M</div>
5757 <h1>Malice</h1>
58- <p class="login-sub">Hosted Matrix chat. Sign in with your homeserver account.</p>
5958 {#if error}
6059 <p class="login-error" role="alert">{error}</p>
6160 {/if}
@@ -102,7 +101,6 @@
102101 <button type="submit" class="primary" disabled={busy}>
103102 {busy ? 'Signing in…' : 'Sign in'}
104103 </button>
105- <p class="login-note">Your password is sent to this server for Matrix sign-in. Access tokens stay server-side.</p>
106104 </form>
107105 </div>
108106
@@ -137,7 +135,6 @@
137135 background: linear-gradient(135deg, var(--accent), var(--accent-2));
138136 }
139137 h1 { font-size: 24px; margin: 2px 0 0; }
140- .login-sub { color: var(--muted); font-size: 13.5px; margin: 0 0 6px; }
141138 .login-error {
142139 background: var(--danger-bg);
143140 border: 1px solid var(--danger-border);
@@ -192,5 +189,4 @@
192189 }
193190 .primary:disabled { opacity: 0.6; cursor: wait; }
194191 .primary:not(:disabled):hover { filter: brightness(1.1); }
195- .login-note { color: var(--muted); font-size: 12px; margin: 4px 0 0; line-height: 1.5; }
196192 </style>
Modified file: web/src/lib/MessageRow.svelte+19 additions−11 deletions
@@ -11,6 +11,7 @@
1111 import { senderLabel } from './types';
1212 import { formatClock, formatDay, initialsOf, senderHue } from './format';
1313 import { findUrls } from './urls';
14+ import { mentionsFromHtml, stripMarkdownPills } from './mentions';
1415
1516 export let itemId: string;
1617 export let event: TimelineEvent;
@@ -29,6 +30,7 @@
2930 export let onEdit: ((eventId: string, body: string) => void) | null = null;
3031 export let editing = false;
3132 export let showSent = true;
33+ export let self: string | null = null;
3234
3335 const PICKER_WIDTH = 320;
3436 const PICKER_HEIGHT = 380;
@@ -62,8 +64,15 @@
6264 $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
6365 $: readers = event.read_by.map((id) => people[id] ?? { user_id: id, name: id, avatar: null });
6466 $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
65- $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];
66- $: bundled = event.content.kind === 'Message' ? event.content.message.link_previews : [];
67+ $: message = event.content.kind === 'Message' ? event.content.message : null;
68+ $: stripped = stripMarkdownPills(message ? (message.media ? message.media.caption : message.body) ?? '' : '');
69+ $: mentions = message ? [...mentionsFromHtml(message.formatted_body), ...stripped.mentions] : [];
70+ $: mentionsMe = self !== null && mentions.some((m) => m.user_id === self);
71+ $: previews = findUrls(stripped.text);
72+ $: bundled = message ? message.link_previews : [];
73+ $: cards = previews
74+ .map((link) => ({ link, preview: bundled.find((b) => b.url === link.href) ?? linkPreviews[link.href] ?? null }))
75+ .filter((c) => c.preview && (c.preview.title || c.preview.description || c.preview.image));
6776 $: if (loadPreview) {
6877 for (const link of previews) {
6978 if (!bundled.some((b) => b.url === link.href) && !(link.href in linkPreviews)) loadPreview(link.href);
@@ -153,7 +162,7 @@
153162 }
154163
155164 function replyPreview(content: TimelineContent): string {
156- if (content.kind === 'Message') return content.message.body;
165+ if (content.kind === 'Message') return stripMarkdownPills(content.message.body).text;
157166 if (content.kind === 'Sticker') return content.body || 'Sticker';
158167 if (content.kind === 'Poll') return `Poll: ${content.question}`;
159168 if (content.kind === 'Redacted') return 'Message deleted';
@@ -222,6 +231,7 @@
222231 class="msg"
223232 class:own={event.own}
224233 class:editing
234+ class:mentioned={mentionsMe}
225235 class:system={!interactive}
226236 class:has-readers={interactive && readers.length > 0}
227237 class:selected
@@ -334,20 +344,19 @@
334344
335345 {#if event.content.kind === 'Message'}
336346 {@const m = event.content.message}
337- {@const text = m.media ? m.media.caption : m.body}
338- {#if text}
347+ {#if stripped.text}
339348 <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
340- {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText {text} jumbo={m.kind === 'Text'} />
349+ {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText text={stripped.text} jumbo={m.kind === 'Text'} {mentions} {self} />
341350 {#if m.edited && !showSender}<span class="edited" title="Edited">(edited)</span>{/if}
342351 </div>
343352 {/if}
344353 {#if m.media}
345354 <MediaAttachmentView attachment={m.media} />
346355 {/if}
347- {#if previews.length > 0}
356+ {#if cards.length > 0}
348357 <div class="link-previews">
349- {#each previews as preview (preview.href)}
350- <LinkCard link={preview} preview={bundled.find((b) => b.url === preview.href) ?? linkPreviews[preview.href] ?? null} />
358+ {#each cards as card (card.link.href)}
359+ <LinkCard link={card.link} preview={card.preview} />
351360 {/each}
352361 </div>
353362 {/if}
@@ -384,7 +393,6 @@
384393 <span class="reactors" role="tooltip" id={`${itemId}-reactors-${r.key}`}>
385394 <span class="reactors-key">{r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}</span>
386395 {reactorNames(r.senders)}
387- {#if r.own}<span class="reactors-hint">Click to remove yours</span>{/if}
388396 </span>
389397 </button>
390398 {/each}
@@ -430,6 +438,7 @@
430438 .time, .edited { color: var(--muted); font-size: 11.5px; }
431439 .text .edited { margin-left: 6px; }
432440 .msg.editing { background: color-mix(in srgb, var(--accent) 10%, transparent); }
441+ .msg.mentioned { background: color-mix(in srgb, var(--warn) 8%, transparent); box-shadow: inset 2px 0 0 var(--warn); }
433442 .reply-quote {
434443 display: flex; gap: 6px; align-items: baseline;
435444 max-width: min(100%, 520px);
@@ -494,7 +503,6 @@
494503 pointer-events: none;
495504 }
496505 .reactors-key { display: block; color: var(--muted); font-size: 11px; }
497- .reactors-hint { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; }
498506 .reaction:hover .reactors, .reaction:focus-visible .reactors { display: block; }
499507 .reaction.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
500508 .reaction:disabled { cursor: default; opacity: 0.85; }
Modified file: web/src/lib/MessageText.svelte+21 additions−3 deletions
@@ -1,15 +1,20 @@
11 <script lang="ts">
22 import EmojiText from './EmojiText.svelte';
3- import { hasCode, parseCode } from './code';
3+ import { parseCode } from './code';
4+ import { segmentInline } from './inline';
5+ import type { Mention } from './mentions';
46
57 export let text: string;
68 export let jumbo = false;
9+ export let mentions: Mention[] = [];
10+ export let self: string | null = null;
711
812 $: segments = parseCode(text);
9- $: plain = !hasCode(segments);
13+ $: pieces = segments.map((s) => (s.kind === 'text' ? segmentInline(s.text, mentions) : []));
14+ $: single = segments.length === 1 && pieces[0].length === 1;
1015 </script>
1116
12-{#if plain}<EmojiText {text} {jumbo} />{:else}{#each segments as s}{#if s.kind === 'inline'}<code class="inline">{s.text}</code>{:else if s.kind === 'block'}<pre class="block"><code>{s.text}</code></pre>{:else}<EmojiText text={s.text} />{/if}{/each}{/if}
17+{#each segments as s, i}{#if s.kind === 'inline'}<code class="inline">{s.text}</code>{:else if s.kind === 'block'}<pre class="block"><code>{s.text}</code></pre>{:else}{#each pieces[i] as p}{#if p.kind === 'mention'}<span class="mention" class:me={p.user_id === self} title={p.user_id}><EmojiText text={p.text} /></span>{:else if p.kind === 'link'}<a class="link" href={p.href} target="_blank" rel="noopener noreferrer">{p.text}</a>{:else}<EmojiText text={p.text} jumbo={jumbo && single} />{/if}{/each}{/if}{/each}
1318
1419 <style>
1520 code {
@@ -32,4 +37,17 @@
3237 white-space: pre;
3338 line-height: 1.4;
3439 }
40+ .mention {
41+ padding: 0 4px;
42+ border-radius: 4px;
43+ font-weight: 600;
44+ color: var(--accent);
45+ background: color-mix(in srgb, var(--accent) 18%, transparent);
46+ }
47+ .mention.me {
48+ color: var(--warn-text);
49+ background: color-mix(in srgb, var(--warn) 28%, transparent);
50+ }
51+ .link { color: var(--accent); overflow-wrap: anywhere; }
52+ .link:hover { text-decoration: underline; }
3553 </style>
Modified file: web/src/lib/SecurityPanel.svelte+2 additions−9 deletions
@@ -20,7 +20,6 @@
2020 let recoveryKey = '';
2121 let busy: VerifyAction | 'recover' | 'start' | null = null;
2222
23- $: recoveryNeeded = encryptionState !== null && encryptionState.recovery !== 'Enabled';
2423
2524 function verificationTitle(state: VerificationState): string {
2625 if (state.kind === 'Waiting') return 'Verification started — waiting for the other device.';
@@ -99,17 +98,12 @@
9998 Recovery: {encryptionState.recovery} · Key backup: {encryptionState.key_backup}
10099 </p>
101100 {:else}
102- <p class="sec-line muted">Encryption status not loaded yet — it arrives with the next sync.</p>
101+ <p class="sec-line muted">Encryption status not loaded yet</p>
103102 {/if}
104103 </section>
105104
106105 <section aria-label="Recovery">
107106 <h3 class="sec-h">Recovery</h3>
108- {#if recoveryNeeded}
109- <p class="sec-line muted">Enter your recovery key to unlock message history on this device.</p>
110- {:else}
111- <p class="sec-line muted">Recovery looks enabled. Re-entering the key is only needed after a reset.</p>
112- {/if}
113107 <form on:submit|preventDefault={recover}>
114108 <label class="field">
115109 <span>Recovery key</span>
@@ -126,7 +120,6 @@
126120 {busy === 'recover' ? 'Sending…' : 'Recover with key'}
127121 </button>
128122 </form>
129- <p class="sec-note">Success is confirmed when the status above changes.</p>
130123 </section>
131124
132125 <section aria-label="Verification">
@@ -191,7 +184,7 @@
191184 </div>
192185 {/if}
193186 {:else}
194- <p class="sec-line muted">No active verification. Start one to prove this device to your other sessions.</p>
187+ <p class="sec-line muted">No active verification</p>
195188 <div class="row">
196189 <button type="button" class="primary-btn" on:click={start} disabled={busy !== null}>
197190 {busy === 'start' ? 'Starting…' : 'Start verification'}
Modified file: web/src/lib/SettingsPanel.svelte−3 deletions
@@ -105,7 +105,6 @@
105105 Reset
106106 </button>
107107 </div>
108- <p class="sec-note">Theme and spacing are saved on this device only.</p>
109108 </section>
110109
111110 <section aria-label="Quick reactions">
@@ -116,7 +115,6 @@
116115 </button>
117116 </div>
118117 <ReactionPicker embedded {quick} {packs} {onQuickChange} onPick={() => {}} onClose={() => dispatch('close')} />
119- <p class="sec-note">Synced to your account. Emoji from this room's packs can be added too.</p>
120118 </section>
121119 </div>
122120
@@ -127,7 +125,6 @@
127125 .sec-head .sec-h { margin: 0; }
128126 .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }
129127 .range + .sec-h { margin-top: 12px; }
130- .sec-note { color: var(--muted); font-size: 12.5px; margin: 8px 0 0; }
131128 .range { display: flex; align-items: center; gap: 10px; }
132129 .range input[type='range'] { flex: 1; accent-color: var(--accent); }
133130 .value { font-size: 13px; min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
Modified file: web/src/lib/appearance.ts+5 additions
@@ -38,6 +38,11 @@ const isLineHeight = (v: unknown): v is number =>
3838
3939 export const lineHeight = persisted('malice.lineHeight', LINE_HEIGHT_DEFAULT, isLineHeight);
4040
41+const isStringRecord = (v: unknown): v is Record<string, string> =>
42+ typeof v === 'object' && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === 'string');
43+
44+export const lastSpaceRooms = persisted<Record<string, string>>('malice.lastSpaceRooms', {}, isStringRecord);
45+
4146 export const THEMES = [
4247 { id: 'system', label: 'Match system' },
4348 { id: 'dark', label: 'Dark' },
Added file: web/src/lib/inline.ts+52 additions
@@ -0,0 +1,52 @@
1+import type { Mention } from './mentions';
2+import { linkSpans } from './urls';
3+
4+export type InlineSegment =
5+ | { kind: 'text'; text: string }
6+ | { kind: 'mention'; text: string; user_id: string }
7+ | { kind: 'link'; text: string; href: string };
8+
9+interface Span {
10+ start: number;
11+ end: number;
12+ segment: InlineSegment;
13+}
14+
15+const WORD_CHAR = /[\p{L}\p{N}_]/u;
16+
17+function escapeRegExp(text: string): string {
18+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19+}
20+
21+function mentionSpans(text: string, mentions: Mention[], taken: Span[]): Span[] {
22+ const byText = new Map(mentions.filter((m) => m.text).map((m) => [m.text, m.user_id]));
23+ if (byText.size === 0) return [];
24+ const names = [...byText.keys()].sort((a, b) => b.length - a.length);
25+ const out: Span[] = [];
26+ for (const m of text.matchAll(new RegExp(names.map(escapeRegExp).join('|'), 'g'))) {
27+ const start = m.index;
28+ const end = start + m[0].length;
29+ if (WORD_CHAR.test(text[start - 1] ?? '') || WORD_CHAR.test(text[end] ?? '')) continue;
30+ if (taken.some((s) => start < s.end && end > s.start)) continue;
31+ out.push({ start, end, segment: { kind: 'mention', text: m[0], user_id: byText.get(m[0])! } });
32+ }
33+ return out;
34+}
35+
36+export function segmentInline(text: string, mentions: Mention[]): InlineSegment[] {
37+ const links: Span[] = linkSpans(text).map((l) => ({
38+ start: l.start,
39+ end: l.end,
40+ segment: { kind: 'link', text: text.slice(l.start, l.end), href: l.href }
41+ }));
42+ const spans = [...links, ...mentionSpans(text, mentions, links)].sort((a, b) => a.start - b.start);
43+ const out: InlineSegment[] = [];
44+ let last = 0;
45+ for (const s of spans) {
46+ if (s.start > last) out.push({ kind: 'text', text: text.slice(last, s.start) });
47+ out.push(s.segment);
48+ last = s.end;
49+ }
50+ if (last < text.length) out.push({ kind: 'text', text: text.slice(last) });
51+ return out;
52+}
Modified file: web/src/lib/mentions.ts+16 additions
@@ -75,6 +75,22 @@ export function mentionsFromHtml(html: string | null): Mention[] {
7575 return out;
7676 }
7777
78+const MARKDOWN_PILL = /\[([^\]\n]+)\]\(https:\/\/matrix\.to\/#\/(@[^)\s?]+)[^)\s]*\)/g;
79+
80+/** Some clients send `[name](https://matrix.to/#/@user)` as the plain body instead of just the name. */
81+export function stripMarkdownPills(body: string): { text: string; mentions: Mention[] } {
82+ const mentions: Mention[] = [];
83+ const text = body.replace(MARKDOWN_PILL, (whole: string, name: string, id: string) => {
84+ try {
85+ mentions.push({ user_id: decodeURIComponent(id), text: name });
86+ return name;
87+ } catch {
88+ return whole;
89+ }
90+ });
91+ return { text, mentions };
92+}
93+
7894 export function mentionedUserIds(mentions: Mention[]): string[] {
7995 return [...new Set(mentions.map((m) => m.user_id))];
8096 }
Modified file: web/src/lib/urls.ts+31 additions−19 deletions
@@ -47,35 +47,47 @@ function trimUrl(raw: string): string {
4747 return out;
4848 }
4949
50-/**
51- * Find http(s) URLs in message text without fetching anything: rendering is
52- * purely local (hostname/path/link card) so no third-party request can leak
53- * reader IPs. Callers MUST NOT auto-fetch link targets.
54- */
55-export function findUrls(text: string): UrlPreview[] {
50+export interface LinkSpan {
51+ start: number;
52+ end: number;
53+ href: string;
54+}
55+
56+export function linkSpans(text: string): LinkSpan[] {
5657 if (!text || !text.includes('http')) return [];
57- const out: UrlPreview[] = [];
58- const seen = new Set<string>();
59- const pattern = /https?:\/\/[^\s<>"'`\]]+/gi;
60- let match: RegExpExecArray | null;
61- while (out.length < MAX_LINKS && (match = pattern.exec(text)) !== null) {
62- let candidate = match[0];
63- if (candidate.length > MAX_URL_LENGTH) candidate = candidate.slice(0, MAX_URL_LENGTH);
64- const trimmed = trimUrl(candidate);
58+ const out: LinkSpan[] = [];
59+ for (const match of text.matchAll(/https?:\/\/[^\s<>"'`\]]+/gi)) {
60+ const trimmed = trimUrl(match[0].slice(0, MAX_URL_LENGTH));
6561 if (trimmed.length < 8) continue;
66- let parsed: URL | null = null;
62+ let parsed: URL;
6763 try {
6864 parsed = new URL(trimmed);
6965 } catch {
7066 continue;
7167 }
7268 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
73- const href = parsed.toString();
74- if (seen.has(href)) continue;
75- seen.add(href);
69+ out.push({ start: match.index, end: match.index + trimmed.length, href: parsed.toString() });
70+ }
71+ return out;
72+}
73+
74+/**
75+ * Find http(s) URLs in message text without fetching anything: rendering is
76+ * purely local (hostname/path/link card) so no third-party request can leak
77+ * reader IPs. Callers MUST NOT auto-fetch link targets.
78+ */
79+export function findUrls(text: string): UrlPreview[] {
80+ const out: UrlPreview[] = [];
81+ const seen = new Set<string>();
82+ for (const span of linkSpans(text)) {
83+ if (out.length >= MAX_LINKS) break;
84+ const parsed = new URL(span.href);
85+ if (parsed.hostname === 'matrix.to' || seen.has(span.href)) continue;
86+ seen.add(span.href);
87+ const trimmed = text.slice(span.start, span.end);
7688 const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;
7789 out.push({
78- href,
90+ href: span.href,
7991 display: trimmed.length > 72 ? `${trimmed.slice(0, 69)}…` : trimmed,
8092 hostname: parsed.hostname,
8193 path: rest && rest !== '/' ? (rest.length > 48 ? `${rest.slice(0, 45)}…` : rest) : ''
Modified file: web/test/mentions.test.ts+21 additions−1 deletions
@@ -1,7 +1,8 @@
11 import assert from 'node:assert/strict';
22 import { test } from 'node:test';
33 import { codeToHtml } from '../src/lib/code.ts';
4-import { mentionQueryAt, mentionedUserIds, rankPeople, resolveMentions } from '../src/lib/mentions.ts';
4+import { mentionQueryAt, mentionedUserIds, rankPeople, resolveMentions, stripMarkdownPills } from '../src/lib/mentions.ts';
5+import { findUrls } from '../src/lib/urls.ts';
56
67 const alice = { user_id: '@alice:example.org', name: 'Alice Liddell', avatar: null };
78 const bob = { user_id: '@bob:example.org', name: 'Bob', avatar: null };
@@ -37,3 +38,22 @@ test('links mentions outside code', () => {
3738 );
3839 assert.equal(codeToHtml('nobody here', mentions), null);
3940 });
41+
42+test('markdown pills in the plain body become names', () => {
43+ const body = 'Good Morning [lillis](https://matrix.to/#/@lillis:matrix.org),\nhi';
44+ assert.deepEqual(stripMarkdownPills(body), {
45+ text: 'Good Morning lillis,\nhi',
46+ mentions: [{ user_id: '@lillis:matrix.org', text: 'lillis' }]
47+ });
48+ assert.deepEqual(stripMarkdownPills('[x](https://matrix.to/#/@%E0%A4%A)'), {
49+ text: '[x](https://matrix.to/#/@%E0%A4%A)',
50+ mentions: []
51+ });
52+});
53+
54+test('matrix.to permalinks get no link card', () => {
55+ assert.deepEqual(
56+ findUrls('https://matrix.to/#/@lillis:matrix.org and https://example.org/a').map((u) => u.href),
57+ ['https://example.org/a']
58+ );
59+});