diff --git a/src/lib/realtime/__tests__/stream-reveal-buffer.test.ts b/src/lib/realtime/__tests__/stream-reveal-buffer.test.ts new file mode 100644 index 0000000..ee259c3 --- /dev/null +++ b/src/lib/realtime/__tests__/stream-reveal-buffer.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { StreamRevealBuffer } from '../stream-reveal-buffer'; + +describe('StreamRevealBuffer', () => { + it('auto-reveals incoming items when scrolled to top on a visible tab', () => { + const buffer = new StreamRevealBuffer([{ id: 'tx-1', timestampMs: 1_000 }]); + + const state = buffer.pushIncoming({ id: 'tx-2', timestampMs: 2_000 }); + expect(state.pendingCount).toBe(0); + expect(state.showRevealBanner).toBe(false); + expect(state.visibleItems.map((i) => i.id)).toEqual(['tx-2', 'tx-1']); + }); + + it('buffers incoming items when scrolled down or tab hidden and commits in order on reveal', () => { + const buffer = new StreamRevealBuffer([{ id: 'tx-1', timestampMs: 1_000 }]); + buffer.setScrollPosition(false); + + buffer.pushIncoming({ id: 'tx-2', timestampMs: 2_000 }); + const mid = buffer.pushIncoming({ id: 'tx-3', timestampMs: 3_000 }); + // Duplicate is ignored + buffer.pushIncoming({ id: 'tx-2', timestampMs: 2_000 }); + + expect(mid.pendingCount).toBe(2); + expect(mid.showRevealBanner).toBe(true); + expect(mid.revealBannerLabel).toBe('2 new updates available'); + expect(mid.visibleItems.map((i) => i.id)).toEqual(['tx-1']); + + const revealed = buffer.revealPending(); + expect(revealed.pendingCount).toBe(0); + expect(revealed.showRevealBanner).toBe(false); + expect(revealed.visibleItems.map((i) => i.id)).toEqual(['tx-3', 'tx-2', 'tx-1']); + }); + + it('bounds the pending buffer and respects prefers-reduced-motion', () => { + const buffer = new StreamRevealBuffer([], { + maxBufferSize: 2, + prefersReducedMotion: true, + }); + buffer.setTabVisibility(false); + + buffer.pushIncoming({ id: 'a', timestampMs: 100 }); + buffer.pushIncoming({ id: 'b', timestampMs: 200 }); + const state = buffer.pushIncoming({ id: 'c', timestampMs: 300 }); + + expect(state.pendingCount).toBe(2); + expect(state.animateReveal).toBe(false); + + const committed = buffer.revealPending(); + expect(committed.visibleItems.map((i) => i.id)).toEqual(['c', 'b']); + }); +}); diff --git a/src/lib/realtime/stream-reveal-buffer.ts b/src/lib/realtime/stream-reveal-buffer.ts new file mode 100644 index 0000000..9d2576a --- /dev/null +++ b/src/lib/realtime/stream-reveal-buffer.ts @@ -0,0 +1,131 @@ +export interface StreamedFeedItem { + id: string; + timestampMs: number; +} + +export interface StreamRevealControlState { + visibleItems: T[]; + pendingCount: number; + showRevealBanner: boolean; + revealBannerLabel: string; + animateReveal: boolean; +} + +export interface StreamRevealBufferOptions { + maxBufferSize?: number; + maxVisibleSize?: number; + prefersReducedMotion?: boolean; +} + +/** + * Buffers incoming streamed feed items when the user is scrolled down or in a background tab, + * preventing mid-read scroll jumps while surfacing a "New Updates Available" reveal control. + */ +export class StreamRevealBuffer { + private readonly visibleById = new Map(); + private readonly pendingById = new Map(); + private readonly maxBufferSize: number; + private readonly maxVisibleSize: number; + private prefersReducedMotion: boolean; + private scrolledToTop = true; + private tabVisible = true; + + constructor( + initialItems: readonly T[] = [], + options: StreamRevealBufferOptions = {} + ) { + this.maxBufferSize = options.maxBufferSize ?? 200; + this.maxVisibleSize = options.maxVisibleSize ?? 500; + this.prefersReducedMotion = options.prefersReducedMotion ?? false; + for (const item of initialItems) { + this.visibleById.set(item.id, item); + } + this.trimVisible(); + } + + setScrollPosition(isScrolledToTop: boolean): void { + this.scrolledToTop = isScrolledToTop; + } + + setTabVisibility(isVisible: boolean): void { + this.tabVisible = isVisible; + } + + setReducedMotion(reduced: boolean): void { + this.prefersReducedMotion = reduced; + } + + /** + * Pushes an incoming streamed item. Auto-reveals if the user is at the top of a visible tab; + * otherwise queues into the bounded pending buffer. + */ + pushIncoming(item: T): StreamRevealControlState { + if (this.visibleById.has(item.id) || this.pendingById.has(item.id)) { + return this.getState(); + } + + if (this.scrolledToTop && this.tabVisible && this.pendingById.size === 0) { + this.visibleById.set(item.id, item); + this.trimVisible(); + return this.getState(); + } + + this.pendingById.set(item.id, item); + this.trimPending(); + return this.getState(); + } + + /** + * Commits all buffered items into the visible list in descending timestamp order. + */ + revealPending(): StreamRevealControlState { + for (const [id, item] of this.pendingById.entries()) { + if (!this.visibleById.has(id)) { + this.visibleById.set(id, item); + } + } + this.pendingById.clear(); + this.trimVisible(); + return this.getState(); + } + + getState(): StreamRevealControlState { + const visibleItems = Array.from(this.visibleById.values()).sort( + (a, b) => b.timestampMs - a.timestampMs || a.id.localeCompare(b.id) + ); + const pendingCount = this.pendingById.size; + + return { + visibleItems, + pendingCount, + showRevealBanner: pendingCount > 0, + revealBannerLabel: + pendingCount === 1 + ? '1 new update available' + : `${pendingCount} new updates available`, + animateReveal: !this.prefersReducedMotion && pendingCount > 0, + }; + } + + private trimPending(): void { + if (this.pendingById.size <= this.maxBufferSize) return; + const sortedOldestFirst = Array.from(this.pendingById.values()).sort( + (a, b) => a.timestampMs - b.timestampMs + ); + const excess = this.pendingById.size - this.maxBufferSize; + for (let i = 0; i < excess; i++) { + this.pendingById.delete(sortedOldestFirst[i].id); + } + } + + private trimVisible(): void { + if (this.visibleById.size <= this.maxVisibleSize) return; + const sortedOldestFirst = Array.from(this.visibleById.values()).sort( + (a, b) => a.timestampMs - b.timestampMs + ); + const excess = this.visibleById.size - this.maxVisibleSize; + for (let i = 0; i < excess; i++) { + this.visibleById.delete(sortedOldestFirst[i].id); + } + } +}