atagen

malice

public
Star0
Watch0

Commit a6320fb35dc6

tarball

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

3 files changed+60-2
web: hide the New divider over messages watched as they arrived

The SDK's read marker follows the fully-read receipt, which malice sends 800ms after a message lands, so every message arriving while the window was focused and pinned to the bottom flashed a New divider above it. While the window is active and following, a marker above only messages that arrived during that stretch is dropped. Unread messages from before it still get one.
Modified file: web/src/App.svelte+26 additions−2 deletions
@@ -105,6 +105,7 @@
105105 import { setRoomMode, setSpaceMuted, spaceMutedRooms, startMuteSync } from './lib/mute';
106106 import { isRoomMuted, mutingSpace, type MutedSpaces } from './lib/mute-model';
107107 import { paneBadges } from './lib/pane-badges';
108+ import { withoutLiveMarker } from './lib/read-marker';
108109 import { loadMutedSpaces, mutedSpaces } from './lib/muted-spaces';
109110 import { loadMessageSound } from './lib/message-sound';
110111 import {
@@ -327,7 +328,10 @@
327328 const onPopState = (ev: PopStateEvent) => {
328329 if (!backStack.pop(ev.state)) void restoreRoute();
329330 };
330- const onAttention = () => queueReadReceipt();
331+ const onAttention = () => {
332+ windowActive = document.visibilityState === 'visible' && document.hasFocus();
333+ queueReadReceipt();
334+ };
331335 let pointerAt: { x: number; y: number } | null = null;
332336 let pointerScroller: HTMLElement | null = null;
333337 const hidePointer = (e: KeyboardEvent) => {
@@ -351,6 +355,7 @@
351355 backStack.init();
352356 window.addEventListener('popstate', onPopState);
353357 window.addEventListener('focus', onAttention);
358+ window.addEventListener('blur', onAttention);
354359 document.addEventListener('visibilitychange', onAttention);
355360 window.addEventListener('keydown', hidePointer, true);
356361 window.addEventListener('pointermove', movePointer);
@@ -367,6 +372,7 @@
367372 stopMuteSync();
368373 window.removeEventListener('popstate', onPopState);
369374 window.removeEventListener('focus', onAttention);
375+ window.removeEventListener('blur', onAttention);
370376 document.removeEventListener('visibilitychange', onAttention);
371377 window.removeEventListener('keydown', hidePointer, true);
372378 window.removeEventListener('pointermove', movePointer);
@@ -760,6 +766,24 @@
760766 return count;
761767 }
762768
769+ let windowActive = document.visibilityState === 'visible' && document.hasFocus();
770+ let liveFrom: string | null = null;
771+ let liveRoom: string | null = null;
772+ $: watching = windowActive && stickToBottom && $selectedRoomId !== null;
773+ $: trackLive(watching, $selectedRoomId, items);
774+ $: shownItems = withoutLiveMarker(items, watching ? liveFrom : null);
775+
776+ function trackLive(on: boolean, roomId: string | null, list: TimelineItem[]): void {
777+ if (!on) {
778+ liveFrom = null;
779+ return;
780+ }
781+ if (liveRoom !== roomId || liveFrom === null) {
782+ liveRoom = roomId;
783+ liveFrom = lastEventId(list);
784+ }
785+ }
786+
763787 let seenLatest: string | null = null;
764788 $: if (stickToBottom) seenLatest = lastEventId(items);
765789 $: if (stickToBottom) farFromLatest = false;
@@ -2729,7 +2753,7 @@
27292753 <RoomTimelineRows
27302754 bind:this={timelineRows}
27312755 following={stickToBottom}
2732- {items}
2756+ items={shownItems}
27332757 pending={pendingItems}
27342758 joined={selectedRoom.membership === 'Joined'}
27352759 self={$sessionUser ?? null}
Added file: web/src/lib/read-marker.ts+11 additions
@@ -0,0 +1,11 @@
1+import type { TimelineItem } from './types';
2+
3+/** The SDK's read marker trails the fully-read receipt, so messages watched as they arrived briefly sat under a New divider. */
4+export function withoutLiveMarker(items: TimelineItem[], liveFrom: string | null): TimelineItem[] {
5+ if (liveFrom === null) return items;
6+ const marker = items.findIndex((item) => item.kind === 'readMarker');
7+ if (marker < 0) return items;
8+ const seen = items.findIndex((item) => item.kind === 'event' && item.event.item_id === liveFrom);
9+ if (seen < 0 || marker < seen) return items;
10+ return items.filter((_, index) => index !== marker);
11+}
Added file: web/test/read-marker.test.ts+23 additions
@@ -0,0 +1,23 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { withoutLiveMarker } from '../src/lib/read-marker.ts';
4+import type { TimelineItem } from '../src/lib/types.ts';
5+
6+const event = (id: string) => ({ kind: 'event', event: { item_id: id } }) as unknown as TimelineItem;
7+const marker = { kind: 'readMarker' } as TimelineItem;
8+
9+test('a marker above messages that were unread before watching stays', () => {
10+ const items = [event('a'), marker, event('b'), event('c')];
11+ assert.equal(withoutLiveMarker(items, 'c').length, 4);
12+});
13+
14+test('a marker above messages that arrived while watching goes', () => {
15+ const items = [event('a'), event('b'), marker, event('c')];
16+ assert.deepEqual(withoutLiveMarker(items, 'b'), [event('a'), event('b'), event('c')]);
17+});
18+
19+test('nothing is hidden while not watching or when the watch point is gone', () => {
20+ const items = [event('a'), marker, event('b')];
21+ assert.equal(withoutLiveMarker(items, null).length, 3);
22+ assert.equal(withoutLiveMarker(items, 'z').length, 3);
23+});