diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index fc4f58b893..ba83dc3f87 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -512,10 +512,13 @@ describe('ChannelList', () => { }); // v10 removed the implicit "float to top" on events: the `ChannelManager` no longer boosts a - // channel on `message.new`; order is governed purely by `sort` (default = stable, by cid). A new - // message therefore updates the channel's preview in place without relocating it. (To force a - // channel to the top an integrator now calls `paginator.boost(cid)`.) - it('should keep the channel in place on a new message with the default sort', async () => { + // channel on `message.new`, so nothing relocates a channel except `sort` itself. (To force a + // channel to the top regardless of the sort an integrator now calls `paginator.boost(cid)`.) + // `ChannelList`'s default `sort` is `[]`, which the paginator reads as "unspecified" and + // resolves to the backend default — `last_message_at` descending — so the receiving channel does + // rise to the top here, on recency rather than on an event-driven boost. These mock channels + // carry no messages, so every other channel stays tied and keeps its cid order. + it('should float the channel to the top on a new message with the default sort', async () => { render( @@ -533,12 +536,16 @@ describe('ChannelList', () => { expect(screen.getByText(newMessage.text as string)).toBeTruthy(); }); - // The new message renders inside the receiving channel's own row (its preview updated in place)… + // The new message renders inside the receiving channel's own row (its preview updated)… expect( within(screen.getByTestId(testChannel3.channel.id)).getByText(newMessage.text as string), ).toBeTruthy(); - // …and the list order is unchanged (no float-to-top). - expect(getRenderedOrder()).toEqual(orderBefore); + // …and that channel is now the most recent one, so it leads the list while the rest hold + // their relative order. + expect(getRenderedOrder()).toEqual([ + testChannel3.channel.id, + ...orderBefore.filter((id) => id !== testChannel3.channel.id), + ]); }); // v10: a `message.new` alone no longer un-hides a channel client-side (only `channel.visible` diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index f191d45950..e289020788 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -51,7 +51,7 @@ export class SqliteClientError extends Error { * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 17; + static dbVersion = 18; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/apis/__tests__/channelQueryCids.test.ts b/package/src/store/apis/__tests__/channelQueryCids.test.ts index 03b8990e5f..36680df467 100644 --- a/package/src/store/apis/__tests__/channelQueryCids.test.ts +++ b/package/src/store/apis/__tests__/channelQueryCids.test.ts @@ -1,9 +1,15 @@ import { BetterSqlite } from '../../../test-utils/BetterSqlite'; import { SqliteClient } from '../../SqliteClient'; -import { selectChannelIdsForFilterSort } from '../queries/selectChannelIdsForFilterSort'; +import { selectChannelQueryForFilterSort } from '../queries/selectChannelQueryForFilterSort'; import { upsertCidsForQuery } from '../upsertCidsForQuery'; describe('channel query cids', () => { + const predefinedFilter = { + name: 'user_messaging', + filter: { archived: false }, + sort: [{ direction: -1 as const, field: 'pinned_at' }], + }; + beforeEach(async () => { await SqliteClient.initializeDatabase(); await BetterSqlite.openDB(); @@ -34,22 +40,76 @@ describe('channel query cids', () => { }); await expect( - selectChannelIdsForFilterSort({ + selectChannelQueryForFilterSort({ filters: {}, options: { predefined_filter: 'user_messaging', }, sort: [], }), - ).resolves.toEqual(['messaging:channel-1']); + ).resolves.toEqual({ cids: ['messaging:channel-1'], predefinedFilter: undefined }); await expect( - selectChannelIdsForFilterSort({ + selectChannelQueryForFilterSort({ filters: {}, options: { predefined_filter: 'team_channels', }, sort: [], }), - ).resolves.toEqual(['messaging:channel-2']); + ).resolves.toEqual({ cids: ['messaging:channel-2'], predefinedFilter: undefined }); + }); + + it('round-trips the backend-resolved rule alongside the order it produced', async () => { + await upsertCidsForQuery({ + cids: ['messaging:channel-1'], + filters: {}, + options: { predefined_filter: 'user_messaging' }, + predefinedFilter, + sort: [], + }); + + await expect( + selectChannelQueryForFilterSort({ + filters: {}, + options: { predefined_filter: 'user_messaging' }, + sort: [], + }), + ).resolves.toEqual({ cids: ['messaging:channel-1'], predefinedFilter }); + }); + + it('clears a stored rule when the same query is re-cached without one', async () => { + const query = { + filters: {}, + options: { predefined_filter: 'user_messaging' }, + sort: [], + }; + await upsertCidsForQuery({ ...query, cids: ['messaging:channel-1'], predefinedFilter }); + + // The upsert builder drops undefined columns, so an omitted rule has to be written as an + // explicit null — otherwise it would outlive the order it described. + await upsertCidsForQuery({ ...query, cids: ['messaging:channel-2'] }); + + await expect(selectChannelQueryForFilterSort(query)).resolves.toEqual({ + cids: ['messaging:channel-2'], + predefinedFilter: undefined, + }); + }); + + it.each([ + ['unparseable', '{"name":"user_messaging"'], + ['valid JSON of the wrong shape', '{"filter":{"archived":false}}'], + ])('surfaces the cid order but ignores a stored rule that is %s', async (_, stored) => { + const query = { + filters: {}, + options: { predefined_filter: 'user_messaging' }, + sort: [], + }; + await upsertCidsForQuery({ ...query, cids: ['messaging:channel-1'], predefinedFilter }); + await SqliteClient.executeSql('UPDATE channelQueries SET predefinedFilter = ?', [stored]); + + await expect(selectChannelQueryForFilterSort(query)).resolves.toEqual({ + cids: ['messaging:channel-1'], + predefinedFilter: undefined, + }); }); }); diff --git a/package/src/store/apis/deleteReactions.ts b/package/src/store/apis/deleteReactions.ts index 61a949b483..0cd5684870 100644 --- a/package/src/store/apis/deleteReactions.ts +++ b/package/src/store/apis/deleteReactions.ts @@ -11,7 +11,7 @@ export const deleteReactionsForMessage = async ({ const query = createDeleteQuery('reactions', { messageId, }); - console.log('deleteReactionsForMessage', { + SqliteClient.logger?.('info', 'deleteReactionsForMessage', { execute, messageId, }); diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index 5d4b37b2f2..1ef07c14c8 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -1,12 +1,12 @@ import type { ChannelFilters, ChannelOptions, + DBGetChannelsForQueryResult, SortParamRequest, - ChannelStateResponseFields, } from 'stream-chat'; import { getChannels } from './getChannels'; -import { selectChannelIdsForFilterSort } from './queries/selectChannelIdsForFilterSort'; +import { selectChannelQueryForFilterSort } from './queries/selectChannelQueryForFilterSort'; import { SqliteClient } from '../SqliteClient'; @@ -18,7 +18,8 @@ import { SqliteClient } from '../SqliteClient'; * @param {Object} param.filters Filters for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters * @param {Object} param.sort Sort for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters * - * @returns Array of channels corresponding to filters & sort. Returns null if filters + sort query doesn't exist in "channelQueries" table. + * @returns The channels corresponding to filters & sort, together with the predefined-filter metadata + * they were cached with. Returns null if filters + sort query doesn't exist in "channelQueries" table. */ export const getChannelsForFilterSort = async ({ currentUserId, @@ -30,7 +31,7 @@ export const getChannelsForFilterSort = async ({ filters?: ChannelFilters; options?: ChannelOptions; sort?: SortParamRequest[]; -}): Promise[] | null> => { +}): Promise => { if (!filters && !sort && !options?.predefined_filter) { console.warn( 'Please provide the query (filters/sort/options.predefined_filter) to fetch channels from the DB.', @@ -40,18 +41,23 @@ export const getChannelsForFilterSort = async ({ SqliteClient.logger?.('info', 'getChannelsForFilterSort', { filters, options, sort }); - const channelIds = await selectChannelIdsForFilterSort({ filters, options, sort }); + const cachedQuery = await selectChannelQueryForFilterSort({ filters, options, sort }); - if (!channelIds) { + if (!cachedQuery) { return null; } - if (channelIds.length === 0) { - return []; + const { cids, predefinedFilter } = cachedQuery; + + if (cids.length === 0) { + return { channels: [], predefinedFilter }; } - return await getChannels({ - channelIds, - currentUserId, - }); + return { + channels: await getChannels({ + channelIds: cids, + currentUserId, + }), + predefinedFilter, + }; }; diff --git a/package/src/store/apis/queries/selectChannelIdsForFilterSort.ts b/package/src/store/apis/queries/selectChannelIdsForFilterSort.ts deleted file mode 100644 index 38eefe9093..0000000000 --- a/package/src/store/apis/queries/selectChannelIdsForFilterSort.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ChannelFilters, ChannelOptions, SortParamRequest } from 'stream-chat'; - -import { createSelectQuery } from '../../sqlite-utils/createSelectQuery'; -import { SqliteClient } from '../../SqliteClient'; - -import { convertFilterSortToQuery } from '../utils/convertFilterSortToQuery'; - -/** - * Gets the channel ids from database for given filter and sort query. - * - * @param {Object} param - * @param {Object} param.filters Filters for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters - * @param {Object} param.sort Sort for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters - * - * @returns Array of channel ids corresponding to filters & sort. Returns null if filters + sort query doesn't exist in "channelQueries" table. - */ - -export const selectChannelIdsForFilterSort = async ({ - filters, - options, - sort, -}: { - filters?: ChannelFilters; - options?: ChannelOptions; - sort?: SortParamRequest[]; -}): Promise => { - const query = convertFilterSortToQuery({ filters, options, sort }); - - SqliteClient.logger?.('info', 'selectChannelIdsForFilterSort', { - query, - }); - - const results = await SqliteClient.executeSql.apply( - null, - createSelectQuery('channelQueries', ['*'], { - id: query, - }), - ); - - const channelIdsStr = results?.[0]?.cids; - return channelIdsStr ? JSON.parse(channelIdsStr) : null; -}; diff --git a/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts b/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts new file mode 100644 index 0000000000..38beca582d --- /dev/null +++ b/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts @@ -0,0 +1,90 @@ +import type { + ChannelFilters, + ChannelOptions, + ParsedPredefinedFilterResponse, + SortParamRequest, +} from 'stream-chat'; + +import { createSelectQuery } from '../../sqlite-utils/createSelectQuery'; +import { SqliteClient } from '../../SqliteClient'; + +import { convertFilterSortToQuery } from '../utils/convertFilterSortToQuery'; + +export type CachedChannelQuery = { + /** Channel ids in the order the cached query produced them. */ + cids: string[]; + /** The backend-resolved predefined filter that order was produced by, when there was one. */ + predefinedFilter?: ParsedPredefinedFilterResponse; +}; + +/** + * A stored `predefined_filter` response is only useful if it still describes a filter and, + * optionally, a sort. Anything else in the column — a truncated write, a row left by an older + * shape — is discarded rather than handed to the paginator, which would otherwise match and order + * the whole list by a malformed rule. + */ +const isPredefinedFilterResponse = (value: unknown): value is ParsedPredefinedFilterResponse => { + if (typeof value !== 'object' || value === null) return false; + const { name, filter, sort } = value as Record; + return ( + typeof name === 'string' && + typeof filter === 'object' && + filter !== null && + (sort === undefined || Array.isArray(sort)) + ); +}; + +const parsePredefinedFilter = ( + serialized: string | null | undefined, +): ParsedPredefinedFilterResponse | undefined => { + if (!serialized) return undefined; + try { + const parsed = JSON.parse(serialized); + return isPredefinedFilterResponse(parsed) ? parsed : undefined; + } catch { + return undefined; + } +}; + +/** + * Gets the cached result of a channel query from the database — the channel ids it produced and the + * backend-resolved predefined filter that produced them. + * + * @param {Object} param + * @param {Object} param.filters Filters for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters + * @param {Object} param.options Full query options, which is what tells two predefined-filter queries apart + * @param {Object} param.sort Sort for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters + * + * @returns The cached query, or null if it doesn't exist in the "channelQueries" table. + */ + +export const selectChannelQueryForFilterSort = async ({ + filters, + options, + sort, +}: { + filters?: ChannelFilters; + options?: ChannelOptions; + sort?: SortParamRequest[]; +}): Promise => { + const query = convertFilterSortToQuery({ filters, options, sort }); + + SqliteClient.logger?.('info', 'selectChannelQueryForFilterSort', { + query, + }); + + const results = await SqliteClient.executeSql.apply( + null, + createSelectQuery('channelQueries', ['*'], { + id: query, + }), + ); + + const channelIdsStr = results?.[0]?.cids; + if (!channelIdsStr) return null; + + return { + cids: JSON.parse(channelIdsStr), + predefinedFilter: parsePredefinedFilter(results?.[0]?.predefinedFilter), + }; +}; diff --git a/package/src/store/apis/upsertCidsForQuery.ts b/package/src/store/apis/upsertCidsForQuery.ts index 4a2695379a..796248c1ec 100644 --- a/package/src/store/apis/upsertCidsForQuery.ts +++ b/package/src/store/apis/upsertCidsForQuery.ts @@ -1,4 +1,9 @@ -import type { ChannelFilters, ChannelOptions, SortParamRequest } from 'stream-chat'; +import type { + ChannelFilters, + ChannelOptions, + ParsedPredefinedFilterResponse, + SortParamRequest, +} from 'stream-chat'; import { convertFilterSortToQuery } from './utils/convertFilterSortToQuery'; @@ -10,26 +15,31 @@ export const upsertCidsForQuery = async ({ filters, execute = true, options, + predefinedFilter, sort, }: { cids: string[]; filters?: ChannelFilters; execute?: boolean; options?: ChannelOptions; + predefinedFilter?: ParsedPredefinedFilterResponse; sort?: SortParamRequest[]; }) => { // Update the database only if the query is provided. const cidsString = JSON.stringify(cids); const id = convertFilterSortToQuery({ filters, options, sort }); + const predefinedFilterString = predefinedFilter ? JSON.stringify(predefinedFilter) : null; const query = createUpsertQuery('channelQueries', { cids: cidsString, id, + predefinedFilter: predefinedFilterString, }); SqliteClient.logger?.('info', 'upsertCidsForQuery', { cids: cidsString, execute, id, + predefinedFilter: predefinedFilterString, }); if (execute) { diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index a14da96dfe..692591c2cf 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -28,6 +28,7 @@ export const tables: Tables = { columns: { cids: 'TEXT', id: 'TEXT', + predefinedFilter: 'TEXT', }, primaryKey: ['id'], }, @@ -347,6 +348,11 @@ export type Schema = { channelQueries: { cids: string; id: string; + /** + * The backend-resolved `predefined_filter` metadata this cid order was produced by, as JSON. + * Null for a plain `filter_conditions` query. + */ + predefinedFilter?: string | null; }; channels: { cid: string;