import type { MessageResource } from '../../types/resources/message' import type { PersonResource } from '../../types/resources/person' import { groupMessages } from '../group_messages' const author = { id: 1, type: 'Person', name: 'A', avatar: null } as unknown as PersonResource const otherAuthor = { id: 2, type: 'Person', name: 'B', avatar: null, } as unknown as PersonResource const message = (id: string, overrides: Partial = {}): MessageResource => ({ type: 'Message', id, text: `msg ${id}`, html: `

msg ${id}

`, createdAt: '2026-01-01T00:00:00Z', deletedAt: null, textEditedAt: null, mine: false, attachments: [], author, reactionCounts: [], replyCount: 0, replyRootId: null, messageType: 'message', personIdsForSystemEvent: null, systemTextParts: null, ...overrides, }) as MessageResource const findEnriched = (items: ReturnType, id: string) => items.find(item => 'id' in item && item.id === id && !('type' in item && item.type !== 'Message')) describe('groupMessages — immutability', () => { it('does not mutate the input messages', () => { const input = [message('02'), message('01')] const snapshot = JSON.parse(JSON.stringify(input)) groupMessages({ ms: input }) expect(input).toEqual(snapshot) }) }) describe('groupMessages — grouping', () => { it('breaks groups across a >5min gap (lastInGroup flips on the older message)', () => { const messages = [ message('02', { createdAt: '2026-01-01T00:06:00Z' }), message('01', { createdAt: '2026-01-01T00:00:00Z' }), ] const enriched = groupMessages({ ms: messages }) expect((findEnriched(enriched, '01') as MessageResource).lastInGroup).toBe(true) }) it('breaks groups when authors differ (renderAuthor flips on the newer message)', () => { const messages = [message('02', { author }), message('01', { author: otherAuthor })] const enriched = groupMessages({ ms: messages }) expect((findEnriched(enriched, '02') as MessageResource).renderAuthor).toBe(true) }) it('marks the first own message as myLatestInConversation and not later ones', () => { const messages = [ message('03', { mine: true, createdAt: '2026-01-01T00:02:00Z' }), message('02', { mine: true, createdAt: '2026-01-01T00:01:00Z' }), message('01', { mine: false, createdAt: '2026-01-01T00:00:00Z' }), ] const enriched = groupMessages({ ms: messages }) expect((findEnriched(enriched, '03') as MessageResource).myLatestInConversation).toBe(true) expect((findEnriched(enriched, '02') as MessageResource).myLatestInConversation).toBe(false) }) }) describe('groupMessages — render order at thread + date boundaries', () => { it('reply shadow precedes date separator so the date renders above', () => { const messages = [ message('02', { createdAt: '2026-01-02T12:00:00Z', replyRootId: 'rootB' }), message('01', { createdAt: '2026-01-01T12:00:00Z', replyRootId: 'rootA' }), ] const enriched = groupMessages({ ms: messages }) const shadowIdx = enriched.findIndex( item => 'type' in item && item.type === 'ReplyShadowMessage' && item.id === '02-rootB' ) const dateSepIdx = enriched.findIndex( item => 'type' in item && item.type === 'DateSeparator' && item.id === 'day-divider-02' ) expect(shadowIdx).toBeGreaterThan(-1) expect(dateSepIdx).toBeGreaterThan(shadowIdx) }) it('skips reply-shadow insertion when inReplyScreen is false but threadPosition still resolves', () => { const messages = [ message('02', { replyRootId: 'rootA', createdAt: '2026-01-01T00:01:00Z' }), message('01', { id: 'rootA', replyRootId: 'rootA', createdAt: '2026-01-01T00:00:00Z' }), ] const enriched = groupMessages({ ms: messages }) expect((findEnriched(enriched, '02') as MessageResource).threadPosition).toBe('last') expect((findEnriched(enriched, 'rootA') as MessageResource).threadPosition).toBe('first') }) }) describe('groupMessages — nextRendersAuthor mirrors the newer enriched neighbor', () => { it('propagates the newer neighbor’s computed renderAuthor across an author-change boundary', () => { const messages = [message('02', { author: otherAuthor }), message('01', { author })] const enriched = groupMessages({ ms: messages }) const newer = findEnriched(enriched, '02') as MessageResource const older = findEnriched(enriched, '01') as MessageResource expect(newer.renderAuthor).toBe(true) expect(older.nextRendersAuthor).toBe(true) }) }) describe('groupMessages — reply roots', () => { const findShadow = (items: ReturnType, id: string) => items.find(item => 'type' in item && item.type === 'ReplyShadowMessage' && item.id === id) it('carries the root from the page onto the reply and its shadow', () => { const messages = [ message('03', { replyRootId: 'rootA' }), message('02'), message('rootA', { replyRootId: 'rootA', text: 'fresh' }), ] const enriched = groupMessages({ ms: messages }) expect((findEnriched(enriched, '03') as MessageResource).replyRoot?.text).toBe('fresh') expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { id: 'rootA' } }) }) it('prefers the page copy of the root over the reply’s included snapshot', () => { const snapshot = message('rootA', { replyRootId: 'rootA', text: 'stale' }) const messages = [ message('03', { replyRootId: 'rootA', replyRoot: snapshot }), message('02'), message('rootA', { replyRootId: 'rootA', text: 'fresh' }), ] const enriched = groupMessages({ ms: messages }) expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'fresh' } }) }) it('falls back to the included snapshot when the root is not on the page', () => { const snapshot = message('rootA', { replyRootId: 'rootA', text: 'included' }) const messages = [message('02', { replyRootId: 'rootA', replyRoot: snapshot })] const enriched = groupMessages({ ms: messages }) expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: { text: 'included' } }) }) it('leaves replyRoot null when the root is neither on the page nor included', () => { const messages = [message('02', { replyRootId: 'rootA' })] const enriched = groupMessages({ ms: messages }) expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: null }) }) it.each([ ['is still saving', { pending: true }], ['failed to save', { error: 'Failed to send message' }], ])('keeps the saved snapshot while a root edit %s', (_label, unsavedState) => { const snapshot = message('rootA', { replyRootId: 'rootA', text: 'saved' }) const messages = [ message('03', { replyRootId: 'rootA', replyRoot: snapshot }), message('02'), message('rootA', { replyRootId: 'rootA', text: 'unsaved', ...unsavedState }), ] const enriched = groupMessages({ ms: messages }) expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'saved' } }) expect((findEnriched(enriched, 'rootA') as MessageResource).text).toBe('unsaved') }) it('shows the unsaved root edit when the reply carries no snapshot to fall back on', () => { const messages = [ message('03', { replyRootId: 'rootA' }), message('02'), message('rootA', { replyRootId: 'rootA', text: 'unsaved', pending: true }), ] const enriched = groupMessages({ ms: messages }) expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'unsaved' } }) }) }) describe('groupMessages — system messages', () => { it('flags lastInGroup true and renderAuthor false on system messages', () => { const messages = [ message('sys', { messageType: 'user_joined', systemTextFormat: 'people', systemTextParts: { names: ['A'], overflowCount: 0, action: 'joined' }, personIdsForSystemEvent: [1], }), ] const enriched = groupMessages({ ms: messages }) const sys = findEnriched(enriched, 'sys') as MessageResource expect(sys.lastInGroup).toBe(true) expect(sys.renderAuthor).toBe(false) }) })