Commit f93c7e88ea05
tarball4 files changed+281-175
@@ -2,13 +2,13 @@
22
import { onMount, tick } from 'svelte';33
import { fade, fly } from 'svelte/transition';44
import LoginPanel from './lib/LoginPanel.svelte';5
- import MessageRow from './lib/MessageRow.svelte';65
import MentionsInbox from './lib/MentionsInbox.svelte';76
import MxcAvatar from './lib/MxcAvatar.svelte';87
import PinButton from './lib/PinButton.svelte';98
import ThreadPane from './lib/ThreadPane.svelte';109
import Composer from './lib/Composer.svelte';1110
import SearchView from './lib/SearchView.svelte';11
+ import RoomTimelineRows from './lib/RoomTimelineRows.svelte';1212
import type { ScopeOption } from './lib/search-model';1313
import { releaseStaged, stageFiles as stageAttachments, type StagedAttachment, type StickerHandlers } from './lib/staging';1414
import RoomThreads from './lib/RoomThreads.svelte';@@ -98,7 +98,6 @@
9898
urlPreviewSettings9999
} from './lib/link-previews';100100
import { notifyTyping, stopTyping } from './lib/typing';101
- import { formatDay } from './lib/format';102101
import {103102
MEMBERS_WIDTH,104103
SIDE_WIDTH,@@ -114,7 +113,7 @@
114113
} from './lib/appearance';115114
import SettingsPanel from './lib/SettingsPanel.svelte';116115
import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';117
- import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';116
+ import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, TimelineEvent, TimelineItem } from './lib/types';118117
119118
let roomsLoading = false;120119
let booted = false;@@ -231,86 +230,7 @@
231230
$: memberGroups = groupMembers(members, roomLevels);232231
$: people = peopleIn(items, members);233232
$: hasStart = items.some((i) => i.kind === 'start');234
- $: grouped = buildGroups(items);235
- $: lastSentId = lastOwnSent(items);236233
$: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];237
- $: lastRealOwn = lastSenderIsOwn(items);238
-239
- function lastSenderIsOwn(list: TimelineItem[]): boolean {240
- for (let i = list.length - 1; i >= 0; i -= 1) {241
- const it = list[i];242
- if (it.kind === 'event') return it.event.sender.user_id === $sessionUser;243
- }244
- return false;245
- }246
-247
- function pendingShowSender(index: number): boolean {248
- return index === 0 ? !lastRealOwn : false;249
- }250
-251
- function pendingToEvent(p: PendingMessage): TimelineEvent {252
- const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };253
- return {254
- item_id: `pending-${p.id}`,255
- event_id: null,256
- transaction_id: p.id,257
- sender: {258
- user_id: $sessionUser ?? '',259
- display_name: profile?.display_name ?? null,260
- avatar: null,261
- name_ambiguous: false262
- },263
- timestamp_ms: p.createdAt,264
- own: true,265
- send_state: state,266
- content: {267
- kind: 'Message',268
- message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: p.formatted, edited: false, media: null, link_previews: [] }269
- },270
- reply_to: p.replyTo,271
- thread_root: null,272
- thread_summary: null,273
- reactions: [],274
- read_by: [],275
- editable: false276
- };277
- }278
-279
- interface Group {280
- key: string;281
- item: TimelineItem;282
- showSender: boolean;283
- }284
-285
- function lastOwnSent(list: TimelineItem[]): string | null {286
- for (let i = list.length - 1; i >= 0; i--) {287
- const item = list[i];288
- if (item.kind === 'event' && item.event.own && item.event.send_state.kind === 'Sent') return item.event.item_id;289
- }290
- return null;291
- }292
-293
- function buildGroups(list: TimelineItem[]): Group[] {294
- const out: Group[] = [];295
- let prevSender: string | null = null;296
- let prevTs = 0;297
- list.forEach((item, index) => {298
- let showSender = true;299
- if (item.kind === 'event') {300
- const e = item.event;301
- showSender =302
- prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > 5 * 60 * 1000;303
- prevSender = e.sender.user_id;304
- prevTs = e.timestamp_ms;305
- out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });306
- } else {307
- prevSender = null;308
- const key = item.kind === 'historyGap' ? `gap-${item.gap.id}` : `${item.kind}-${index}`;309
- out.push({ key, item, showSender });310
- }311
- });312
- return out;313
- }314234
315235
function roomUnread(r: RoomSummary): number {316236
return r.unread_messages + r.unread_mentions;@@ -2463,99 +2383,39 @@
24632383
aria-label={`Messages in ${selectedRoom.name}`}24642384
>24652385
<div class="timeline-inner" use:pinTimeline>2466
- <div class="timeline-top">2467
- {#if recentState === 'Loading'}2468
- <p class="side-note">Loading recent history…</p>2469
- {:else if recentState && typeof recentState === 'object'}2470
- <div class="history-gap">2471
- <span>Recent history could not be loaded</span>2472
- <button type="button" class="ghost-btn" disabled={!!recentRetrying[selectedRoom.room_id]} on:click={() => void retryRecentHistory(selectedRoom.room_id)}>2473
- {recentRetrying[selectedRoom.room_id] ? 'Loading…' : 'Retry'}2474
- </button>2475
- </div>2476
- {/if}2477
- {#if hasStart}2478
- <p class="start-note">Beginning of history</p>2479
- {:else}2480
- <button type="button" class="ghost-btn" disabled={paginateBusy || recentState === 'Loading'} on:click={() => void paginate()}>2481
- {paginateBusy ? 'Loading…' : 'Load older messages'}2482
- </button>2483
- {/if}2484
- </div>2485
- {#if grouped.length === 0}2486
- <p class="side-note timeline-empty">No messages yet</p>2487
- {:else}2488
- {#each grouped as g (g.key)}2489
- {#if g.item.kind === 'day'}2490
- <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>2491
- {:else if g.item.kind === 'readMarker'}2492
- <div class="new-row"><span>New</span></div>2493
- {:else if g.item.kind === 'start'}2494
- <p class="start-note">Beginning of history</p>2495
- {:else if g.item.kind === 'historyGap'}2496
- {#if g.item.gap.continuity !== 'Verified'}2497
- {@const gap = g.item.gap}2498
- {@const filling = fillingGaps[selectedRoom.room_id] === gap.id || gap.lifecycle === 'Fetching'}2499
- <div class="history-gap">2500
- <span>{gap.failure ?? 'Some history is not loaded'}</span>2501
- <button type="button" class="ghost-btn" disabled={!!fillingGaps[selectedRoom.room_id] || gap.lifecycle === 'Fetching' || gap.lifecycle === 'Unavailable'} on:click={() => void fillGap(gap.id)}>2502
- {filling ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}2503
- </button>2504
- </div>2505
- {/if}2506
- {:else if g.item.kind === 'unknown'}2507
- <p class="unknown-note">Unsupported event ({g.item.label})</p>2508
- {:else}2509
- <MessageRow2510
- itemId={g.key}2511
- event={g.item.event}2512
- showSender={g.showSender}2513
- senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}2514
- {reacting}2515
- onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}2516
- quickReactions={usableQuickReactions}2517
- packs={roomPacks}2518
- linkPreviews={$linkPreviews}2519
- {loadPreview}2520
- onQuickChange={(list) => void changeQuickReactions(list)}2521
- {people}2522
- self={$sessionUser ?? null}2523
- onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}2524
- onCloneSticker={openCloneStickerMenu}2525
- onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}2526
- onOpenThread={selectedRoom.membership === 'Joined' || g.item.event.thread_summary ? openThreadHere : null}2527
- onReply={selectedRoom.membership === 'Joined' ? startReply : null}2528
- onJump={jumpTo}2529
- onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}2530
- editing={editing !== null && editing.eventId === g.item.event.event_id}2531
- showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}2532
- />2533
- {/if}2534
- {/each}2535
- {/if}2536
- {#each pendingItems as p, i (p.id)}2537
- <MessageRow2538
- itemId={`pending-${p.id}`}2539
- event={pendingToEvent(p)}2540
- onJump={jumpTo}2541
- showSender={pendingShowSender(i)}2542
- senderAvatar={senderAvatarFor($sessionUser ?? '')}2543
- {reacting}2544
- onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}2545
- quickReactions={usableQuickReactions}2546
- packs={roomPacks}2547
- linkPreviews={$linkPreviews}2548
- {loadPreview}2549
- onQuickChange={(list) => void changeQuickReactions(list)}2550
- />2551
- {#if p.status === 'failed'}2552
- <div class="pending-actions">2553
- <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>2554
- <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>2555
- <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>2556
- </div>2557
- {/if}2558
- {/each}2386
+ <RoomTimelineRows2387
+ {items}2388
+ pending={pendingItems}2389
+ joined={selectedRoom.membership === 'Joined'}2390
+ self={$sessionUser ?? null}2391
+ selfName={profile?.display_name ?? null}2392
+ {people}2393
+ {senderAvatarFor}2394
+ {reacting}2395
+ quickReactions={usableQuickReactions}2396
+ packs={roomPacks}2397
+ linkPreviews={$linkPreviews}2398
+ {loadPreview}2399
+ editingEventId={editing?.eventId ?? null}2400
+ {recentState}2401
+ recentRetrying={!!recentRetrying[selectedRoom.room_id]}2402
+ paginating={paginateBusy}2403
+ fillingGap={fillingGaps[selectedRoom.room_id] ?? null}2404
+ onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}2405
+ onQuickChange={(list) => void changeQuickReactions(list)}2406
+ onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}2407
+ onCloneSticker={openCloneStickerMenu}2408
+ onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}2409
+ onOpenThread={openThreadHere}2410
+ onReply={startReply}2411
+ onJump={jumpTo}2412
+ onEdit={startEdit}2413
+ onPaginate={() => void paginate()}2414
+ onFillGap={(gapId) => void fillGap(gapId)}2415
+ onRetryRecent={() => void retryRecentHistory(selectedRoom.room_id)}2416
+ onRetryPending={retryPending}2417
+ onDiscardPending={discardPending}2418
+ />25592419
</div>25602420
</div>25612421
@@ -0,0 +1,139 @@
1
+<script lang="ts">2
+ import EmojiText from './EmojiText.svelte';3
+ import MessageRow from './MessageRow.svelte';4
+ import { formatDay } from './format';5
+ import type { PendingMessage } from './pending';6
+ import { buildGroups, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';7
+ import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';8
+9
+ export let items: TimelineItem[];10
+ export let pending: PendingMessage[] = [];11
+ export let joined: boolean;12
+ export let self: string | null;13
+ export let selfName: string | null = null;14
+ export let people: Record<string, Person> = {};15
+ export let senderAvatarFor: (userId: string) => string | null;16
+ export let reacting: boolean;17
+ export let quickReactions: QuickReaction[] = [];18
+ export let packs: ImagePack[] = [];19
+ export let linkPreviews: Record<string, LinkPreview | null> = {};20
+ export let loadPreview: ((url: string) => void) | null = null;21
+ export let editingEventId: string | null = null;22
+ export let recentState: RecentHistoryState | undefined = undefined;23
+ export let recentRetrying = false;24
+ export let paginating = false;25
+ export let fillingGap: string | null = null;26
+ export let onReact: (eventId: string, key: string, shortcode?: string) => void;27
+ export let onQuickChange: (list: QuickReaction[]) => void;28
+ export let onUser: (userId: string, x: number, y: number, menu: boolean) => void;29
+ export let onCloneSticker: (eventId: string, body: string, x: number, y: number) => void;30
+ export let onMore: (event: TimelineEvent, x: number, y: number) => void;31
+ export let onOpenThread: (rootEventId: string) => void;32
+ export let onReply: (target: ReplyReference) => void;33
+ export let onJump: (eventId: string) => void;34
+ export let onEdit: (eventId: string, body: string) => void;35
+ export let onPaginate: () => void;36
+ export let onFillGap: (gapId: string) => void;37
+ export let onRetryRecent: () => void;38
+ export let onRetryPending: (id: string) => void;39
+ export let onDiscardPending: (id: string) => void;40
+41
+ $: hasStart = items.some((item) => item.kind === 'start');42
+ $: grouped = buildGroups(items);43
+ $: lastSentId = lastOwnSent(items);44
+ $: pendingFollowsSelf = lastSenderIs(items, self);45
+</script>46
+47
+<div class="timeline-top">48
+ {#if recentState === 'Loading'}49
+ <p class="side-note">Loading recent history…</p>50
+ {:else if recentState && typeof recentState === 'object'}51
+ <div class="history-gap">52
+ <span>Recent history could not be loaded</span>53
+ <button type="button" class="ghost-btn" disabled={recentRetrying} on:click={onRetryRecent}>54
+ {recentRetrying ? 'Loading…' : 'Retry'}55
+ </button>56
+ </div>57
+ {/if}58
+ {#if hasStart}59
+ <p class="start-note">Beginning of history</p>60
+ {:else}61
+ <button type="button" class="ghost-btn" disabled={paginating || recentState === 'Loading'} on:click={onPaginate}>62
+ {paginating ? 'Loading…' : 'Load older messages'}63
+ </button>64
+ {/if}65
+</div>66
+{#if grouped.length === 0}67
+ <p class="side-note timeline-empty">No messages yet</p>68
+{:else}69
+ {#each grouped as g (g.key)}70
+ {#if g.item.kind === 'day'}71
+ <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>72
+ {:else if g.item.kind === 'readMarker'}73
+ <div class="new-row"><span>New</span></div>74
+ {:else if g.item.kind === 'start'}75
+ <p class="start-note">Beginning of history</p>76
+ {:else if g.item.kind === 'historyGap'}77
+ {#if g.item.gap.continuity !== 'Verified'}78
+ {@const gap = g.item.gap}79
+ {@const filling = fillingGap === gap.id || gap.lifecycle === 'Fetching'}80
+ <div class="history-gap">81
+ <span>{gap.failure ?? 'Some history is not loaded'}</span>82
+ <button type="button" class="ghost-btn" disabled={!!fillingGap || gap.lifecycle === 'Fetching' || gap.lifecycle === 'Unavailable'} on:click={() => onFillGap(gap.id)}>83
+ {filling ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}84
+ </button>85
+ </div>86
+ {/if}87
+ {:else if g.item.kind === 'unknown'}88
+ <p class="unknown-note">Unsupported event ({g.item.label})</p>89
+ {:else}90
+ <MessageRow91
+ itemId={g.key}92
+ event={g.item.event}93
+ showSender={g.showSender}94
+ senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}95
+ {reacting}96
+ {onReact}97
+ {quickReactions}98
+ {packs}99
+ {linkPreviews}100
+ {loadPreview}101
+ {onQuickChange}102
+ {people}103
+ {self}104
+ {onUser}105
+ {onCloneSticker}106
+ {onMore}107
+ onOpenThread={joined || g.item.event.thread_summary ? onOpenThread : null}108
+ onReply={joined ? onReply : null}109
+ {onJump}110
+ onEdit={joined ? onEdit : null}111
+ editing={editingEventId !== null && editingEventId === g.item.event.event_id}112
+ showSent={g.item.event.item_id === lastSentId && pending.length === 0}113
+ />114
+ {/if}115
+ {/each}116
+{/if}117
+{#each pending as p, i (p.id)}118
+ <MessageRow119
+ itemId={`pending-${p.id}`}120
+ event={pendingToEvent(p, self, selfName)}121
+ {onJump}122
+ showSender={i === 0 && !pendingFollowsSelf}123
+ senderAvatar={senderAvatarFor(self ?? '')}124
+ {reacting}125
+ {onReact}126
+ {quickReactions}127
+ {packs}128
+ {linkPreviews}129
+ {loadPreview}130
+ {onQuickChange}131
+ />132
+ {#if p.status === 'failed'}133
+ <div class="pending-actions">134
+ <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>135
+ <button type="button" class="ghost-btn" on:click={() => onRetryPending(p.id)}>Retry</button>136
+ <button type="button" class="ghost-btn danger" on:click={() => onDiscardPending(p.id)}>Discard</button>137
+ </div>138
+ {/if}139
+{/each}@@ -0,0 +1,70 @@
1
+import type { PendingMessage } from './pending';2
+import type { SendState, TimelineEvent, TimelineItem } from './types';3
+4
+const SENDER_RUN_MS = 5 * 60 * 1000;5
+6
+export interface Group {7
+ key: string;8
+ item: TimelineItem;9
+ showSender: boolean;10
+}11
+12
+/** Consecutive events from one sender within a few minutes share a header. */13
+export function buildGroups(list: TimelineItem[]): Group[] {14
+ const out: Group[] = [];15
+ let prevSender: string | null = null;16
+ let prevTs = 0;17
+ list.forEach((item, index) => {18
+ if (item.kind === 'event') {19
+ const e = item.event;20
+ const showSender = prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > SENDER_RUN_MS;21
+ prevSender = e.sender.user_id;22
+ prevTs = e.timestamp_ms;23
+ out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });24
+ } else {25
+ prevSender = null;26
+ const key = item.kind === 'historyGap' ? `gap-${item.gap.id}` : `${item.kind}-${index}`;27
+ out.push({ key, item, showSender: true });28
+ }29
+ });30
+ return out;31
+}32
+33
+export function lastOwnSent(list: TimelineItem[]): string | null {34
+ for (let i = list.length - 1; i >= 0; i--) {35
+ const item = list[i];36
+ if (item.kind === 'event' && item.event.own && item.event.send_state.kind === 'Sent') return item.event.item_id;37
+ }38
+ return null;39
+}40
+41
+export function lastSenderIs(list: TimelineItem[], userId: string | null): boolean {42
+ for (let i = list.length - 1; i >= 0; i -= 1) {43
+ const item = list[i];44
+ if (item.kind === 'event') return item.event.sender.user_id === userId;45
+ }46
+ return false;47
+}48
+49
+export function pendingToEvent(p: PendingMessage, self: string | null, displayName: string | null): TimelineEvent {50
+ const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };51
+ return {52
+ item_id: `pending-${p.id}`,53
+ event_id: null,54
+ transaction_id: p.id,55
+ sender: { user_id: self ?? '', display_name: displayName, avatar: null, name_ambiguous: false },56
+ timestamp_ms: p.createdAt,57
+ own: true,58
+ send_state: state,59
+ content: {60
+ kind: 'Message',61
+ message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: p.formatted, edited: false, media: null, link_previews: [] }62
+ },63
+ reply_to: p.replyTo,64
+ thread_root: null,65
+ thread_summary: null,66
+ reactions: [],67
+ read_by: [],68
+ editable: false69
+ };70
+}@@ -0,0 +1,37 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import { buildGroups, lastOwnSent, lastSenderIs } from '../src/lib/timeline-groups.ts';4
+import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts';5
+6
+const MINUTE = 60 * 1000;7
+8
+function event(id: string, sender: string, timestamp_ms: number, extra: Partial<TimelineEvent> = {}): TimelineItem {9
+ return {10
+ kind: 'event',11
+ event: { item_id: id, event_id: id, timestamp_ms, own: false, send_state: { kind: 'Sent' }, sender: { user_id: sender }, ...extra } as TimelineEvent12
+ };13
+}14
+15
+test('a sender run shares one header until a gap or another sender', () => {16
+ const groups = buildGroups([17
+ event('a', '@x', 0),18
+ event('b', '@x', MINUTE),19
+ event('c', '@x', 10 * MINUTE),20
+ event('d', '@y', 11 * MINUTE),21
+ { kind: 'day', timestamp_ms: 12 * MINUTE },22
+ event('e', '@y', 12 * MINUTE)23
+ ]);24
+ assert.deepEqual(groups.map((g) => g.showSender), [true, false, true, true, true, true]);25
+});26
+27
+test('gap rows are keyed by their id so pagination keeps them mounted', () => {28
+ const gap = { kind: 'historyGap', gap: { id: 'g1', older_boundary: null, newer_boundary: null, lifecycle: 'Idle', continuity: 'Unresolved', failure: null } } as TimelineItem;29
+ assert.equal(buildGroups([event('a', '@x', 0), gap])[1].key, 'gap-g1');30
+});31
+32
+test('the latest own sent event and the latest sender are found from the end', () => {33
+ const list = [event('a', '@me', 0, { own: true }), event('b', '@x', 1), event('c', '@me', 2, { own: true, send_state: { kind: 'Sending' } })];34
+ assert.equal(lastOwnSent(list), 'a');35
+ assert.equal(lastSenderIs(list, '@me'), true);36
+ assert.equal(lastSenderIs([], '@me'), false);37
+});