Commit 705b1e1062c2
tarball12 files changed+290-141
@@ -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
+ <div1426
+ 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
+ <MessageRow1459
+ 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
- <MessageRow1427
- 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
+ <MessageRow1483
+ 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
- <MessageRow1450
- 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
Send15791613
</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>@@ -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); }@@ -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>@@ -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 = previews74
+ .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:editing234
+ 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; }@@ -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>@@ -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'}@@ -105,7 +105,6 @@
105105
Reset106106
</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; }@@ -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' },@@ -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
+}@@ -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
}@@ -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 is52
- * purely local (hostname/path/link card) so no third-party request can leak53
- * 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 is76
+ * purely local (hostname/path/link card) so no third-party request can leak77
+ * 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) : ''@@ -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
+});