diff --git a/src/app/features/room/thread/threadSummaryData.test.ts b/src/app/features/room/thread/threadSummaryData.test.ts new file mode 100644 index 000000000..2449ee0f2 --- /dev/null +++ b/src/app/features/room/thread/threadSummaryData.test.ts @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk'; +import { getThreadSummary, isPendingThreadReply } from './threadSummaryData'; + +// getThreadSummary reads either the live Thread (preferred) or the +// server-aggregated `m.thread` bundle. We stub only the members it touches and +// cast through `unknown` to MatrixEvent, mirroring the light mocking used in +// the state tests. + +type ThreadStub = { length: number; lastReplyTs?: number }; +type BundleStub = { count: number; latestTs?: number }; + +const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => { + const thread = opts.thread + ? { + length: opts.thread.length, + lastReply: () => + opts.thread?.lastReplyTs === undefined + ? null + : ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent), + } + : undefined; + + return { + getThread: () => thread, + getServerAggregatedRelation: (relType: string) => { + if (relType !== RelationType.Thread || !opts.bundle) return undefined; + return { + count: opts.bundle.count, + latest_event: + opts.bundle.latestTs === undefined + ? undefined + : { origin_server_ts: opts.bundle.latestTs }, + }; + }, + } as unknown as MatrixEvent; +}; + +// --------------------------------------------------------------------------- +// getThreadSummary +// --------------------------------------------------------------------------- + +test('prefers the live thread: count from length, latestTs from lastReply', () => { + const rootEvent = makeRootEvent({ + thread: { length: 3, lastReplyTs: 1700 }, + bundle: { count: 99, latestTs: 1 }, + }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 }); +}); + +test('live thread with no replies yields undefined latestTs', () => { + const rootEvent = makeRootEvent({ thread: { length: 0 } }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined }); +}); + +test('falls back to the server bundle when no live thread', () => { + const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 }); +}); + +test('bundle without latest_event yields undefined latestTs', () => { + const rootEvent = makeRootEvent({ bundle: { count: 2 } }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined }); +}); + +test('returns undefined when there is neither a thread nor a bundle', () => { + const rootEvent = makeRootEvent({}); + assert.equal(getThreadSummary(rootEvent), undefined); +}); + +// --------------------------------------------------------------------------- +// isPendingThreadReply +// --------------------------------------------------------------------------- + +const ROOT = '$root:server'; + +const makeReply = (opts: { + status: EventStatus | null; + threadRootId?: string; + relation?: { rel_type?: string; event_id?: string } | null; +}): MatrixEvent => + ({ + status: opts.status, + threadRootId: opts.threadRootId, + getRelation: () => opts.relation ?? null, + }) as unknown as MatrixEvent; + +test('SENDING with matching threadRootId is pending', () => { + const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), true); +}); + +test('NOT_SENT with matching threadRootId is pending', () => { + const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), true); +}); + +test('SENDING resolved via the m.thread relation content is pending', () => { + const event = makeReply({ + status: EventStatus.SENDING, + relation: { rel_type: RelationType.Thread, event_id: ROOT }, + }); + assert.equal(isPendingThreadReply(event, ROOT), true); +}); + +test('SENT (confirmed) event is not pending', () => { + const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('null status is not pending', () => { + const event = makeReply({ status: null, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('SENDING but for a different thread is not pending', () => { + const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('SENDING with a non-thread relation is not pending', () => { + const event = makeReply({ + status: EventStatus.SENDING, + relation: { rel_type: RelationType.Reference, event_id: ROOT }, + }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('SENDING with no relation and no threadRootId is not pending', () => { + const event = makeReply({ status: EventStatus.SENDING }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); diff --git a/src/app/features/room/thread/threadSummaryData.ts b/src/app/features/room/thread/threadSummaryData.ts new file mode 100644 index 000000000..6896332db --- /dev/null +++ b/src/app/features/room/thread/threadSummaryData.ts @@ -0,0 +1,55 @@ +import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk'; + +export type ThreadSummaryData = { + count: number; + latestTs: number | undefined; +}; + +/** + * Summary data for a thread root's "N replies" chip. + * + * Prefers the live {@link Thread} object when it exists (it reflects local + * echo + pagination), otherwise falls back to the server-aggregated bundle + * (`unsigned['m.relations']['m.thread']`) so the chip renders before any + * Thread object has been created. Returns `undefined` when the root has no + * thread at all. + */ +export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => { + const thread = rootEvent.getThread(); + if (thread) { + const lastReply = thread.lastReply(); + return { + count: thread.length, + latestTs: lastReply?.getTs(), + }; + } + + const bundle = rootEvent.getServerAggregatedRelation( + RelationType.Thread, + ); + if (bundle) { + return { + count: bundle.count, + latestTs: bundle.latest_event?.origin_server_ts, + }; + } + + return undefined; +}; + +/** + * True when `event` is a still-in-flight (local echo) reply belonging to the + * given thread root. Used to render the pending strip, since pending thread + * sends never enter the thread's timelineSet. + */ +export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => { + const { status } = event; + if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false; + + // Prefer the SDK's resolved thread root id; fall back to the raw relation + // content for events the SDK hasn't associated with a thread yet. + if (event.threadRootId === threadRootId) return true; + + const relation = event.getRelation(); + return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId; +}; diff --git a/src/app/features/room/thread/useThread.ts b/src/app/features/room/thread/useThread.ts index 43f170b2e..0a7bffc1b 100644 --- a/src/app/features/room/thread/useThread.ts +++ b/src/app/features/room/thread/useThread.ts @@ -12,7 +12,7 @@ import { ThreadEvent, } from 'matrix-js-sdk'; import { getLinkedTimelines } from '../RoomTimeline'; -import { isPendingThreadReply } from './threadSummary'; +import { isPendingThreadReply } from './threadSummaryData'; /** * Resolve (or bootstrap) the live {@link Thread} for a root event. diff --git a/src/app/hooks/useThreadSummary.ts b/src/app/hooks/useThreadSummary.ts index 4018ec470..a259f6b51 100644 --- a/src/app/hooks/useThreadSummary.ts +++ b/src/app/hooks/useThreadSummary.ts @@ -8,7 +8,7 @@ import { RoomEventHandlerMap, ThreadEvent, } from 'matrix-js-sdk'; -import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary'; +import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData'; import { threadNotificationsAtom } from '../state/threadNotifications'; import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications'; diff --git a/src/app/utils/caseCollision.test.ts b/src/app/utils/caseCollision.test.ts new file mode 100644 index 000000000..76e7f1645 --- /dev/null +++ b/src/app/utils/caseCollision.test.ts @@ -0,0 +1,43 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Guard against same-directory filenames that differ only by case (e.g. + * `threadSummary.ts` vs `ThreadSummary.tsx`). On case-insensitive filesystems + * (the Windows release runner) an extensionless import of one can resolve to + * the OTHER file — rolldown tries `.ts` before `.tsx` — producing + * MISSING_EXPORT failures that never reproduce on the Linux/macOS machines the + * project is developed and web-deployed on. This broke the desktop release + * build twice before being diagnosed; this test makes the collision a local, + * immediate failure instead. + */ +const findCaseCollisions = (dir: string, collisions: string[]): void => { + const entries = readdirSync(dir, { withFileTypes: true }); + const seen = new Map(); + entries.forEach((entry) => { + // Compare basenames without extension: `Foo.tsx` collides with `foo.ts` + // because module resolution is extensionless. + const stem = entry.isDirectory() ? entry.name : entry.name.replace(/\.[^.]+$/, ''); + const key = stem.toLowerCase(); + const existing = seen.get(key); + if (existing !== undefined && existing !== stem) { + collisions.push(`${dir}: "${existing}" vs "${stem}"`); + } + if (existing === undefined) seen.set(key, stem); + if (entry.isDirectory()) { + findCaseCollisions(join(dir, entry.name), collisions); + } + }); +}; + +test('no same-directory filenames differing only by case under src/', () => { + const collisions: string[] = []; + findCaseCollisions('src', collisions); + assert.deepEqual( + collisions, + [], + `Case-colliding names break Windows builds:\n${collisions.join('\n')}`, + ); +});