Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions package/src/components/ChannelList/__tests__/ChannelList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Chat client={chatClient}>
<WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
Expand All @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion package/src/store/SqliteClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 65 additions & 5 deletions package/src/store/apis/__tests__/channelQueryCids.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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,
});
});
});
2 changes: 1 addition & 1 deletion package/src/store/apis/deleteReactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const deleteReactionsForMessage = async ({
const query = createDeleteQuery('reactions', {
messageId,
});
console.log('deleteReactionsForMessage', {
SqliteClient.logger?.('info', 'deleteReactionsForMessage', {
execute,
messageId,
});
Expand Down
30 changes: 18 additions & 12 deletions package/src/store/apis/getChannelsForFilterSort.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand All @@ -30,7 +31,7 @@ export const getChannelsForFilterSort = async ({
filters?: ChannelFilters;
options?: ChannelOptions;
sort?: SortParamRequest[];
}): Promise<Omit<ChannelStateResponseFields, 'duration'>[] | null> => {
}): Promise<DBGetChannelsForQueryResult | null> => {
if (!filters && !sort && !options?.predefined_filter) {
console.warn(
'Please provide the query (filters/sort/options.predefined_filter) to fetch channels from the DB.',
Expand All @@ -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,
};
};
42 changes: 0 additions & 42 deletions package/src/store/apis/queries/selectChannelIdsForFilterSort.ts

This file was deleted.

90 changes: 90 additions & 0 deletions package/src/store/apis/queries/selectChannelQueryForFilterSort.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<CachedChannelQuery | null> => {
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),
};
};
12 changes: 11 additions & 1 deletion package/src/store/apis/upsertCidsForQuery.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) {
Expand Down
Loading
Loading