diff --git a/apps/web/src/features/email-view/queries/use-email-query.ts b/apps/web/src/features/email-view/queries/use-email-query.ts index 2825811073b..d1495cac0c9 100644 --- a/apps/web/src/features/email-view/queries/use-email-query.ts +++ b/apps/web/src/features/email-view/queries/use-email-query.ts @@ -448,7 +448,9 @@ export function useEmailDataSource( // bucketed by date. const result: EmailDataSourceItem[] = search.isSearching() ? buildFlatSoupRows(listEntities()) - : buildGroupedSoupRows(groupEmailEntitiesByDate(listEntities())); + : buildGroupedSoupRows(groupEmailEntitiesByDate(listEntities()), { + keyByEntity: true, + }); if (hasMore()) { result.push( diff --git a/apps/web/src/features/soup/collection/rows.test.ts b/apps/web/src/features/soup/collection/rows.test.ts index 94494606003..7011c5af084 100644 --- a/apps/web/src/features/soup/collection/rows.test.ts +++ b/apps/web/src/features/soup/collection/rows.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getSoupMenuEntities } from './rows'; +import { buildGroupedSoupRows, getSoupMenuEntities } from './rows'; describe('getSoupMenuEntities', () => { const rows = [ @@ -20,3 +20,40 @@ describe('getSoupMenuEntities', () => { expect(getSoupMenuEntities(rows[0], [])).toEqual([rows[0]]); }); }); + +describe('buildGroupedSoupRows keyed by entity', () => { + const thread = { id: 't1', type: 'email' }; + const other = { id: 't2', type: 'email' }; + const rowIdOf = (rows: { kind: string; id: string }[], index: number) => + rows.filter((row) => row.kind === 'entity')[index]?.id; + + it('keeps a row id when its entity moves to another group', () => { + const before = buildGroupedSoupRows( + [ + { id: 'today', label: 'Today', entities: [other] }, + { id: 'yesterday', label: 'Yesterday', entities: [thread] }, + ], + { keyByEntity: true } + ); + // A reply arrives: the thread is now today's. + const after = buildGroupedSoupRows( + [{ id: 'today', label: 'Today', entities: [thread, other] }], + { keyByEntity: true } + ); + + expect(rowIdOf(after, 0)).toBe(rowIdOf(before, 1)); + expect(rowIdOf(after, 1)).toBe(rowIdOf(before, 0)); + }); + + it('still gives an entity listed in two groups two rows', () => { + const rows = buildGroupedSoupRows( + [ + { id: 'today', label: 'Today', entities: [thread] }, + { id: 'yesterday', label: 'Yesterday', entities: [thread] }, + ], + { keyByEntity: true } + ); + + expect(rowIdOf(rows, 0)).not.toBe(rowIdOf(rows, 1)); + }); +}); diff --git a/apps/web/src/features/soup/collection/rows.ts b/apps/web/src/features/soup/collection/rows.ts index a7fd349c613..ebcfb9761e5 100644 --- a/apps/web/src/features/soup/collection/rows.ts +++ b/apps/web/src/features/soup/collection/rows.ts @@ -23,6 +23,8 @@ const createSoupRowId = ( export type CreateSoupEntityRowOptions = { groupId?: string; occurrenceId?: string; + /** Leave the group out of the row id, so moving groups keeps the row. */ + keyByEntity?: boolean; }; export function createSoupEntityRow( @@ -35,7 +37,7 @@ export function createSoupEntityRow( 'entity', entity.type, entity.id, - options.groupId, + options.keyByEntity ? undefined : options.groupId, options.occurrenceId ), entity, @@ -125,6 +127,12 @@ export function buildFlatSoupRows( } export type BuildGroupedSoupRowsOptions = { + /** + * Identify rows by entity alone, for lists where each entity sits in one + * group (date buckets). A thread moving to another group then keeps its + * row, its DOM and the keyboard cursor instead of becoming a new row. + */ + keyByEntity?: boolean; getOccurrenceId?: ( entity: TEntity, index: number, @@ -137,6 +145,8 @@ export function buildGroupedSoupRows( options: BuildGroupedSoupRowsOptions = {} ): SoupRow[] { const groupIds = new Set(); + // Keyed by entity, a repeat in another group still needs its own id. + const listOccurrences = new Map(); const rows = groups.flatMap((group) => { if (groupIds.has(group.id)) { throw new Error( @@ -146,11 +156,14 @@ export function buildGroupedSoupRows( groupIds.add(group.id); const groupRows: SoupRow[] = [createSoupGroupHeaderRow(group)]; - const occurrences = new Map(); + const occurrences = options.keyByEntity + ? listOccurrences + : new Map(); group.entities.forEach((entity, index) => { groupRows.push( createSoupEntityRow(entity, { groupId: group.id, + keyByEntity: options.keyByEntity, occurrenceId: options.getOccurrenceId?.(entity, index, group) ?? nextDuplicateOccurrence(entity, occurrences),