feat(app): rank forward destinations and preview the message (#2790)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-15 07:23:26 +02:00
1 parent c5aaf65a10
commit f4c5fee17e
80 files changed
+6564 -2712

No files matched your search

@@ -0,0 +1,272 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {autorun, runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
interface FakeChannel {
readonly id: string;
readonly type: number;
readonly recipientIds: ReadonlyArray<string>;
}
type ChannelFrecencyStore = typeof import('@app/features/channel/state/ChannelFrecency')['default'];
interface SyncedFieldFake {
seed: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}> | null;
toMessage: (() => {usage: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}>}) | null;
}
const fakes = await vi.hoisted(async () => {
const {observable} = await import('mobx');
return {
navigation: observable({guildId: null as string | null, channelId: null as string | null}),
voice: observable({
guildId: null as string | null,
channelId: null as string | null,
localDisconnectReason: null as string | null,
}),
guildIds: new Set<string>(),
channels: new Map<string, FakeChannel>(),
synced: {seed: null, toMessage: null} as SyncedFieldFake,
};
});
vi.mock('@app/features/user/state/SyncedField', () => ({
makeSyncedField: async (
store: object,
config: {
toMessage: (store: never) => {usage: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}>};
applyMessage: (store: never, message: {usage: Record<string, unknown>}) => void;
},
) => {
fakes.synced.toMessage = () => config.toMessage(store as never);
if (fakes.synced.seed !== null) {
config.applyMessage(store as never, {usage: fakes.synced.seed});
}
},
}));
vi.mock('@app/features/navigation/state/Navigation', () => ({default: fakes.navigation}));
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: fakes.voice}));
vi.mock('@app/features/guild/state/Guilds', () => ({
default: {getGuild: (guildId: string) => (fakes.guildIds.has(guildId) ? {id: guildId} : undefined)},
}));
vi.mock('@app/features/channel/state/Channels', () => ({
default: {
getChannel: (channelId: string) => fakes.channels.get(channelId),
getPrivateChannels: () => Array.from(fakes.channels.values()),
},
}));
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;
const T0 = Date.UTC(2026, 6, 15, 12);
const GUILD_ID = '1400000000000000100';
const OTHER_GUILD_ID = '1400000000000000200';
const TEXT_CHANNEL_ID = '1400000000000000101';
const OTHER_TEXT_CHANNEL_ID = '1400000000000000102';
const VOICE_CHANNEL_ID = '1400000000000000103';
const OTHER_VOICE_CHANNEL_ID = '1400000000000000104';
const OTHER_GUILD_VOICE_CHANNEL_ID = '1400000000000000201';
const DM_CHANNEL_ID = '1400000000000000301';
const FRIEND_ID = '1400000000000000401';
async function startChannelFrecency(
synced?: ReadonlyArray<readonly [string, {totalUses: number; recentUses: ReadonlyArray<number>}]>,
): Promise<ChannelFrecencyStore> {
vi.resetModules();
fakes.synced.seed =
synced === undefined
? null
: Object.fromEntries(
synced.map(([key, entry]) => [
key,
{totalUses: entry.totalUses, recentUsesMs: entry.recentUses.map((use) => BigInt(use))},
]),
);
const {default: ChannelFrecency} = await import('@app/features/channel/state/ChannelFrecency');
await vi.advanceTimersByTimeAsync(0);
return ChannelFrecency;
}
function navigate(guildId: string | null, channelId: string | null): void {
runInAction(() => {
fakes.navigation.guildId = guildId;
fakes.navigation.channelId = channelId;
});
}
function setVoiceChannel(guildId: string | null, channelId: string | null): void {
runInAction(() => {
fakes.voice.localDisconnectReason = channelId == null ? 'user' : null;
fakes.voice.guildId = guildId;
fakes.voice.channelId = channelId;
});
}
function moveVoiceChannel(guildId: string | null, channelId: string): void {
runInAction(() => {
fakes.voice.localDisconnectReason = 'channelMove';
fakes.voice.guildId = null;
fakes.voice.channelId = null;
});
setVoiceChannel(guildId, channelId);
}
function totalUses(store: ChannelFrecencyStore, key: string): number {
return store.usageHistory.get(key)?.totalUses ?? 0;
}
function keyFor(index: number): string {
return `1400000000000000${String(index).padStart(3, '0')}`;
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(T0);
navigate(null, null);
setVoiceChannel(null, null);
runInAction(() => {
fakes.voice.localDisconnectReason = null;
});
fakes.guildIds.clear();
fakes.channels.clear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('ChannelFrecency tracking', () => {
it('counts the landing channel and its guild once when tracking starts', async () => {
navigate(GUILD_ID, TEXT_CHANNEL_ID);
const store = await startChannelFrecency();
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(1);
expect(totalUses(store, GUILD_ID)).toBe(1);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(100);
});
it('counts a channel again after a page without a channel but not a guild the user stays in', async () => {
const store = await startChannelFrecency();
navigate(GUILD_ID, TEXT_CHANNEL_ID);
navigate(GUILD_ID, OTHER_TEXT_CHANNEL_ID);
navigate(GUILD_ID, TEXT_CHANNEL_ID);
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(2);
expect(totalUses(store, GUILD_ID)).toBe(1);
navigate(null, null);
navigate(GUILD_ID, TEXT_CHANNEL_ID);
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(3);
expect(totalUses(store, GUILD_ID)).toBe(2);
});
it('treats direct messages as guildless and never stores ids that are not snowflakes', async () => {
const store = await startChannelFrecency();
navigate(GUILD_ID, TEXT_CHANNEL_ID);
navigate('@me', DM_CHANNEL_ID);
navigate(GUILD_ID, 'settings');
navigate(GUILD_ID, TEXT_CHANNEL_ID);
expect([...store.usageHistory.keys()]).toEqual([TEXT_CHANNEL_ID, GUILD_ID, DM_CHANNEL_ID]);
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(2);
expect(totalUses(store, GUILD_ID)).toBe(2);
expect(totalUses(store, DM_CHANNEL_ID)).toBe(1);
});
it('counts voice joins through the same last selection as navigation', async () => {
const store = await startChannelFrecency();
navigate(GUILD_ID, VOICE_CHANNEL_ID);
setVoiceChannel(GUILD_ID, VOICE_CHANNEL_ID);
expect(totalUses(store, VOICE_CHANNEL_ID)).toBe(1);
navigate(GUILD_ID, TEXT_CHANNEL_ID);
setVoiceChannel(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
navigate(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
expect(totalUses(store, OTHER_VOICE_CHANNEL_ID)).toBe(1);
setVoiceChannel(null, null);
setVoiceChannel(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
expect(totalUses(store, OTHER_VOICE_CHANNEL_ID)).toBe(2);
setVoiceChannel(OTHER_GUILD_ID, OTHER_GUILD_VOICE_CHANNEL_ID);
expect(totalUses(store, OTHER_GUILD_VOICE_CHANNEL_ID)).toBe(1);
expect(totalUses(store, OTHER_GUILD_ID)).toBe(1);
expect(totalUses(store, GUILD_ID)).toBe(2);
});
it('counts a move between voice channels in one guild as a single guild use', async () => {
const store = await startChannelFrecency();
setVoiceChannel(GUILD_ID, VOICE_CHANNEL_ID);
moveVoiceChannel(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
expect(totalUses(store, VOICE_CHANNEL_ID)).toBe(1);
expect(totalUses(store, OTHER_VOICE_CHANNEL_ID)).toBe(1);
expect(totalUses(store, GUILD_ID)).toBe(1);
});
it('keeps at most 100 keys and drops the one used longest ago', async () => {
const store = await startChannelFrecency();
for (let index = 0; index <= 100; index++) {
store.track(keyFor(index), T0 + index);
}
expect(store.usageHistory.size).toBe(100);
expect(store.usageHistory.has(keyFor(0))).toBe(false);
expect(store.usageHistory.has(keyFor(100))).toBe(true);
});
});
describe('ChannelFrecency account sync', () => {
it('re-derives persisted frecency against the current time on start', async () => {
const store = await startChannelFrecency([
[TEXT_CHANNEL_ID, {totalUses: 5, recentUses: [T0 - 8 * DAY_MS - HOUR_MS, T0 - 8 * DAY_MS]}],
[GUILD_ID, {totalUses: 1, recentUses: []}],
]);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(50);
expect(store.usageHistory.has(GUILD_ID)).toBe(false);
});
it('re-derives frecency again as a long session ages', async () => {
const store = await startChannelFrecency([[TEXT_CHANNEL_ID, {totalUses: 3, recentUses: [T0 - DAY_MS]}]]);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(210);
await vi.advanceTimersByTimeAsync(6 * DAY_MS);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(30);
});
it('hands the account sync only the uses, not the derived scores', async () => {
await startChannelFrecency();
navigate(GUILD_ID, TEXT_CHANNEL_ID);
await vi.advanceTimersByTimeAsync(1_000);
expect(fakes.synced.toMessage?.()).toEqual({
usage: {
[TEXT_CHANNEL_ID]: {totalUses: 1, recentUsesMs: [BigInt(T0)]},
[GUILD_ID]: {totalUses: 1, recentUsesMs: [BigInt(T0)]},
},
});
});
});
describe('ChannelFrecency ranking', () => {
it('ranks resolvable guild, channel and direct message keys by frecency', async () => {
fakes.guildIds.add(GUILD_ID);
fakes.channels.set(TEXT_CHANNEL_ID, {id: TEXT_CHANNEL_ID, type: ChannelTypes.GUILD_TEXT, recipientIds: []});
fakes.channels.set(DM_CHANNEL_ID, {id: DM_CHANNEL_ID, type: ChannelTypes.DM, recipientIds: [FRIEND_ID]});
const store = await startChannelFrecency();
store.track(GUILD_ID);
store.track(FRIEND_ID);
store.track(OTHER_TEXT_CHANNEL_ID);
store.track(TEXT_CHANNEL_ID);
store.track(TEXT_CHANNEL_ID);
expect(store.frequentIds).toEqual([TEXT_CHANNEL_ID, GUILD_ID, DM_CHANNEL_ID]);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(200);
expect(store.getScore(DM_CHANNEL_ID)).toBe(0);
});
it('lets observers follow the ranking as the user moves around', async () => {
fakes.guildIds.add(GUILD_ID);
fakes.channels.set(TEXT_CHANNEL_ID, {id: TEXT_CHANNEL_ID, type: ChannelTypes.GUILD_TEXT, recipientIds: []});
const store = await startChannelFrecency();
const seen: Array<ReadonlyArray<string>> = [];
const dispose = autorun(() => {
seen.push(store.frequentIds);
});
navigate(GUILD_ID, TEXT_CHANNEL_ID);
dispose();
expect(seen[0]).toEqual([]);
expect(seen.at(-1)).toEqual([TEXT_CHANNEL_ID, GUILD_ID]);
});
});
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Channels from '@app/features/channel/state/Channels';
import {
type ChannelFrecencyEntry,
capChannelFrecencyHistory,
channelFrecencyHistoryFromWire,
channelFrecencyHistoryToWire,
computeChannelFrecency,
mergeChannelFrecencyWireUsage,
rankFrequentChannelIds,
restoreChannelFrecencyHistory,
trackChannelUse,
} from '@app/features/channel/utils/ChannelFrecencyCalculator';
import Guilds from '@app/features/guild/state/Guilds';
import Navigation from '@app/features/navigation/state/Navigation';
import {makeSyncedField} from '@app/features/user/state/SyncedField';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {ChannelFrecencyStateSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb';
import {comparer, isObservableMap, makeAutoObservable, observable, reaction} from 'mobx';
const TRACKABLE_ID_PATTERN = /^\d{17,19}$/;
const FRECENCY_REFRESH_INTERVAL_MS = 3_600_000;
const FRECENCY_SYNC_DEBOUNCE_MS = 1_500;
let lastChannelId: string | null = null;
let lastGuildId: string | null = null;
function isTrackableId(id: string | null): id is string {
return id !== null && TRACKABLE_ID_PATTERN.test(id);
}
function findDMChannelIdForRecipient(userId: string): string | null {
for (const channel of Channels.getPrivateChannels()) {
if (channel.type === ChannelTypes.DM && channel.recipientIds.includes(userId)) {
return channel.id;
}
}
return null;
}
function resolveFrecencyRecordId(key: string): string | null {
return Guilds.getGuild(key)?.id ?? Channels.getChannel(key)?.id ?? findDMChannelIdForRecipient(key);
}
class ChannelFrecency {
usageHistory = new Map<string, ChannelFrecencyEntry>();
constructor() {
makeAutoObservable(this, {usageHistory: observable.shallow}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makeSyncedField(this, {
field: 'channelFrecency',
schema: ChannelFrecencyStateSchema,
persist: ['usageHistory'],
debounceMs: FRECENCY_SYNC_DEBOUNCE_MS,
toMessage: (store) => ({usage: channelFrecencyHistoryToWire(store.usageHistory)}),
applyMessage: (store, message) => {
store.usageHistory = channelFrecencyHistoryFromWire(message.usage, Date.now());
},
mergeRemote: (local, incoming) => ({
usage: mergeChannelFrecencyWireUsage(local.usage, incoming.usage, Date.now()),
}),
});
this.refreshHistory();
setInterval(this.refreshHistory, FRECENCY_REFRESH_INTERVAL_MS);
reaction(
() => [Navigation.guildId, Navigation.channelId] as const,
([guildId, channelId]) => this.recordSelection(guildId, channelId),
{equals: comparer.shallow, fireImmediately: true},
);
reaction(
() => [MediaEngine.guildId, MediaEngine.channelId] as const,
([guildId, channelId]) => {
if (channelId === null && MediaEngine.localDisconnectReason === 'channelMove') return;
this.recordSelection(guildId, channelId);
},
{equals: comparer.shallow},
);
}
get frequentIds(): ReadonlyArray<string> {
return rankFrequentChannelIds(this.usageHistory, resolveFrecencyRecordId);
}
getScore(id: string): number {
return this.usageHistory.get(id)?.frecency ?? 0;
}
track(key: string, timestamp?: number): void {
trackChannelUse(this.usageHistory, key, timestamp);
capChannelFrecencyHistory(this.usageHistory);
computeChannelFrecency(this.usageHistory, Date.now());
}
recordSelection(guildId: string | null, channelId: string | null): void {
const selectedGuildId = guildId === ME ? null : guildId;
if (channelId !== lastChannelId) {
lastChannelId = channelId;
if (isTrackableId(channelId)) {
this.track(channelId);
}
}
if (selectedGuildId !== lastGuildId) {
lastGuildId = selectedGuildId;
if (isTrackableId(selectedGuildId)) {
this.track(selectedGuildId);
}
}
}
private refreshHistory(): void {
const current: unknown = this.usageHistory;
if (!isObservableMap(current)) return;
this.usageHistory = restoreChannelFrecencyHistory(current.entries(), Date.now());
}
}
export default new ChannelFrecency();
@@ -0,0 +1,305 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ChannelFrecencyEntry,
type ChannelFrecencyHistory,
capChannelFrecencyHistory,
channelFrecencyDayDiff,
channelFrecencyHistoryFromWire,
channelFrecencyHistoryToWire,
channelFrecencyWeight,
computeChannelFrecency,
mergeChannelFrecencyWireUsage,
rankFrequentChannelIds,
restoreChannelFrecencyHistory,
trackChannelUse,
} from '@app/features/channel/utils/ChannelFrecencyCalculator';
import {afterEach, describe, expect, it, vi} from 'vitest';
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;
const NOW = Date.UTC(2026, 6, 15, 12);
const CHANNEL_ID = '1400000000000000001';
const STOCKHOLM_SUMMER_TIME_START = Date.UTC(2026, 2, 29, 1);
const STOCKHOLM_SUMMER_TIME_END = Date.UTC(2026, 9, 25, 1);
function useStockholmClock(): void {
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(function (this: Date) {
const time = this.getTime();
return time >= STOCKHOLM_SUMMER_TIME_START && time < STOCKHOLM_SUMMER_TIME_END ? -120 : -60;
});
}
function computedEntry(frecency: number): ChannelFrecencyEntry {
return {totalUses: 1, recentUses: [NOW], frecency, score: frecency};
}
function keyFor(index: number): string {
return `1400000000000000${String(index).padStart(3, '0')}`;
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('channelFrecencyWeight', () => {
it.each([
[0, 100],
[1, 70],
[2, 50],
[3, 50],
[4, 30],
[6, 30],
[7, 10],
[365, 10],
[-1, 1],
[-3, 1],
])('weights an age of %i days as %i', (dayDiff, weight) => {
expect(channelFrecencyWeight(dayDiff)).toBe(weight);
});
});
describe('channelFrecencyDayDiff', () => {
it('counts whole elapsed days and truncates toward zero', () => {
expect(channelFrecencyDayDiff(NOW, NOW - 23 * HOUR_MS)).toBe(0);
expect(channelFrecencyDayDiff(NOW, NOW - 25 * HOUR_MS)).toBe(1);
expect(channelFrecencyDayDiff(NOW, NOW - 6 * DAY_MS - 23 * HOUR_MS)).toBe(6);
expect(channelFrecencyDayDiff(NOW, NOW + 12 * HOUR_MS)).toBe(0);
expect(channelFrecencyDayDiff(NOW, NOW + 36 * HOUR_MS)).toBe(-1);
});
it('counts noon to noon across the spring clock change as one day although only 23 hours pass', () => {
useStockholmClock();
const saturdayNoon = Date.UTC(2026, 2, 28, 11);
const sundayNoon = Date.UTC(2026, 2, 29, 10);
expect(sundayNoon - saturdayNoon).toBe(23 * HOUR_MS);
expect(channelFrecencyDayDiff(sundayNoon, saturdayNoon)).toBe(1);
expect(channelFrecencyWeight(channelFrecencyDayDiff(sundayNoon, saturdayNoon))).toBe(70);
});
it('keeps 24.5 hours across the autumn clock change inside the same day', () => {
useStockholmClock();
const saturdayNoon = Date.UTC(2026, 9, 24, 10);
const sundayHalfPastEleven = Date.UTC(2026, 9, 25, 10, 30);
expect(sundayHalfPastEleven - saturdayNoon).toBe(24.5 * HOUR_MS);
expect(channelFrecencyDayDiff(sundayHalfPastEleven, saturdayNoon)).toBe(0);
});
});
describe('trackChannelUse', () => {
it('starts a new key with one uncomputed use', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, CHANNEL_ID, NOW);
expect(history.get(CHANNEL_ID)).toEqual({totalUses: 1, recentUses: [NOW], frecency: -1, score: 0});
});
it('keeps arrival order for untimed uses and sorts when a timestamp is given', () => {
vi.useFakeTimers();
const history: ChannelFrecencyHistory = new Map();
vi.setSystemTime(NOW);
trackChannelUse(history, CHANNEL_ID);
vi.setSystemTime(NOW - HOUR_MS);
trackChannelUse(history, CHANNEL_ID);
expect(history.get(CHANNEL_ID)?.recentUses).toEqual([NOW, NOW - HOUR_MS]);
trackChannelUse(history, CHANNEL_ID, NOW - 2 * HOUR_MS);
expect(history.get(CHANNEL_ID)?.recentUses).toEqual([NOW - 2 * HOUR_MS, NOW - HOUR_MS, NOW]);
expect(history.get(CHANNEL_ID)?.totalUses).toBe(3);
});
});
describe('computeChannelFrecency', () => {
it('scores 12 uses whose last 10 are 4 today, 3 yesterday and 3 five days ago as 840', () => {
const history: ChannelFrecencyHistory = new Map();
const uses = [
NOW - 20 * DAY_MS,
NOW - 20 * DAY_MS,
NOW - 5 * DAY_MS - HOUR_MS,
NOW - 5 * DAY_MS - 2 * HOUR_MS,
NOW - 5 * DAY_MS - 3 * HOUR_MS,
NOW - DAY_MS - HOUR_MS,
NOW - DAY_MS - 2 * HOUR_MS,
NOW - DAY_MS - 3 * HOUR_MS,
NOW - HOUR_MS,
NOW - 2 * HOUR_MS,
NOW - 3 * HOUR_MS,
NOW - 4 * HOUR_MS,
];
for (const use of uses) {
trackChannelUse(history, CHANNEL_ID, use);
}
computeChannelFrecency(history, NOW);
expect(history.get(CHANNEL_ID)).toEqual({
totalUses: 12,
recentUses: [
NOW - 5 * DAY_MS - 3 * HOUR_MS,
NOW - 5 * DAY_MS - 2 * HOUR_MS,
NOW - 5 * DAY_MS - HOUR_MS,
NOW - DAY_MS - 3 * HOUR_MS,
NOW - DAY_MS - 2 * HOUR_MS,
NOW - DAY_MS - HOUR_MS,
NOW - 4 * HOUR_MS,
NOW - 3 * HOUR_MS,
NOW - 2 * HOUR_MS,
NOW - HOUR_MS,
],
frecency: 840,
score: 700,
});
});
it('leaves computed entries alone until the key is used again', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, CHANNEL_ID, NOW - 8 * DAY_MS);
computeChannelFrecency(history, NOW - 8 * DAY_MS);
const computed = history.get(CHANNEL_ID);
expect(computed?.frecency).toBe(100);
computeChannelFrecency(history, NOW);
expect(history.get(CHANNEL_ID)).toBe(computed);
trackChannelUse(history, CHANNEL_ID, NOW);
computeChannelFrecency(history, NOW);
expect(history.get(CHANNEL_ID)).toEqual({
totalUses: 2,
recentUses: [NOW - 8 * DAY_MS, NOW],
frecency: 110,
score: 110,
});
});
});
describe('capChannelFrecencyHistory', () => {
it('drops the key whose last use is oldest once there are more than 100 keys', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, keyFor(0), NOW - 10 * DAY_MS);
trackChannelUse(history, keyFor(0), NOW + 500);
for (let index = 1; index <= 100; index++) {
trackChannelUse(history, keyFor(index), NOW + index);
}
capChannelFrecencyHistory(history);
expect(history.size).toBe(100);
expect(history.has(keyFor(0))).toBe(true);
expect(history.has(keyFor(1))).toBe(false);
});
it('drops the earlier key when two share the oldest last use', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, keyFor(0), NOW);
trackChannelUse(history, keyFor(1), NOW);
for (let index = 2; index <= 100; index++) {
trackChannelUse(history, keyFor(index), NOW + index);
}
capChannelFrecencyHistory(history);
expect(history.has(keyFor(0))).toBe(false);
expect(history.has(keyFor(1))).toBe(true);
});
});
describe('rankFrequentChannelIds', () => {
it('sorts resolved ids by frecency, keeps key order on ties and drops unresolved keys', () => {
const history: ChannelFrecencyHistory = new Map([
['a', computedEntry(100)],
['b', computedEntry(300)],
['c', computedEntry(100)],
['user', computedEntry(200)],
['gone', computedEntry(999)],
]);
const resolved = new Map([
['a', 'a'],
['b', 'b'],
['c', 'c'],
['user', 'dm-channel'],
]);
expect(rankFrequentChannelIds(history, (key) => resolved.get(key) ?? null)).toEqual(['b', 'dm-channel', 'a', 'c']);
});
it('returns at most 100 ids', () => {
const history: ChannelFrecencyHistory = new Map();
for (let index = 0; index < 150; index++) {
history.set(keyFor(index), computedEntry(100));
}
const ranked = rankFrequentChannelIds(history, (key) => key);
expect(ranked).toHaveLength(100);
expect(ranked[99]).toBe(keyFor(99));
});
});
describe('restoreChannelFrecencyHistory', () => {
it('re-derives every persisted entry against now and drops unusable ones', () => {
const persisted: ReadonlyArray<readonly [unknown, unknown]> = [
['a', {totalUses: 5, recentUses: [NOW - 8 * DAY_MS - HOUR_MS, NOW - 8 * DAY_MS], frecency: 999, score: 999}],
['b', {totalUses: 2, recentUses: [], frecency: 50, score: 50}],
['c', {totalUses: 'many', recentUses: [NOW]}],
['d', {totalUses: 1, recentUses: [NOW - HOUR_MS, 'soon', -5, null], frecency: 1, score: 1}],
[42, {totalUses: 1, recentUses: [NOW]}],
['e', null],
];
const restored = restoreChannelFrecencyHistory(persisted, NOW);
expect([...restored.keys()]).toEqual(['a', 'd']);
expect(restored.get('a')).toEqual({
totalUses: 5,
recentUses: [NOW - 8 * DAY_MS - HOUR_MS, NOW - 8 * DAY_MS],
frecency: 50,
score: 20,
});
expect(restored.get('d')).toEqual({totalUses: 1, recentUses: [NOW - HOUR_MS], frecency: 100, score: 100});
});
});
describe('channel frecency account sync', () => {
it('carries only the uses over the wire and re-derives on the way back', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, 'a', NOW - HOUR_MS);
trackChannelUse(history, 'a', NOW);
computeChannelFrecency(history, NOW);
const usage = channelFrecencyHistoryToWire(history);
expect(usage).toEqual({a: {totalUses: 2, recentUsesMs: [BigInt(NOW - HOUR_MS), BigInt(NOW)]}});
const restored = channelFrecencyHistoryFromWire(usage, NOW);
expect(restored.get('a')).toEqual({
totalUses: 2,
recentUses: [NOW - HOUR_MS, NOW],
frecency: 200,
score: 200,
});
});
it('drops wire entries whose uses have all aged out of scoring', () => {
const restored = channelFrecencyHistoryFromWire({a: {totalUses: 3, recentUsesMs: []}}, NOW);
expect(restored.size).toBe(0);
});
it('merges two devices by keeping the higher count and the newest ten uses', () => {
const merged = mergeChannelFrecencyWireUsage(
{
a: {totalUses: 9, recentUsesMs: [BigInt(NOW - 2 * DAY_MS), BigInt(NOW - HOUR_MS)]},
b: {totalUses: 1, recentUsesMs: [BigInt(NOW - HOUR_MS)]},
},
{
a: {totalUses: 4, recentUsesMs: [BigInt(NOW - HOUR_MS), BigInt(NOW)]},
c: {totalUses: 2, recentUsesMs: [BigInt(NOW)]},
},
NOW,
);
expect(merged.a).toEqual({
totalUses: 9,
recentUsesMs: [BigInt(NOW - 2 * DAY_MS), BigInt(NOW - HOUR_MS), BigInt(NOW)],
});
expect(Object.keys(merged).sort()).toEqual(['a', 'b', 'c']);
});
it('keeps at most ten uses and a hundred keys when merging', () => {
const uses = Array.from({length: 8}, (_value, index) => BigInt(NOW - index * HOUR_MS));
const local: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}> = {};
const incoming: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}> = {};
for (let index = 0; index < 60; index++) {
local[keyFor(index)] = {totalUses: 1, recentUsesMs: [BigInt(NOW - index * HOUR_MS)]};
}
for (let index = 60; index < 130; index++) {
incoming[keyFor(index)] = {totalUses: 1, recentUsesMs: [BigInt(NOW - index * HOUR_MS)]};
}
local.a = {totalUses: 20, recentUsesMs: uses};
incoming.a = {totalUses: 20, recentUsesMs: [BigInt(NOW + HOUR_MS), BigInt(NOW + 2 * HOUR_MS), BigInt(NOW)]};
const merged = mergeChannelFrecencyWireUsage(local, incoming, NOW);
expect(merged.a.recentUsesMs).toHaveLength(10);
expect(Object.keys(merged)).toHaveLength(100);
});
});
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ChannelFrecencyEntry {
readonly totalUses: number;
readonly recentUses: ReadonlyArray<number>;
readonly frecency: number;
readonly score: number;
}
export type ChannelFrecencyHistory = Map<string, ChannelFrecencyEntry>;
interface RankedChannelFrecency {
readonly id: string;
readonly frecency: number;
}
const CHANNEL_FRECENCY_MAX_ITEMS = 100;
const CHANNEL_FRECENCY_MAX_SAMPLES = 10;
const CHANNEL_FRECENCY_UNCOMPUTED = -1;
const DAY_MS = 86_400_000;
const MINUTE_MS = 60_000;
function createEntry(
totalUses: number,
recentUses: ReadonlyArray<number>,
frecency: number,
score: number,
): ChannelFrecencyEntry {
return Object.freeze({totalUses, recentUses: Object.freeze(recentUses), frecency, score});
}
function utcOffsetMinutes(timestamp: number): number {
return -new Date(timestamp).getTimezoneOffset();
}
function compareAscending(a: number, b: number): number {
return a - b;
}
export function channelFrecencyDayDiff(now: number, then: number): number {
const zoneDeltaMs = (utcOffsetMinutes(then) - utcOffsetMinutes(now)) * MINUTE_MS;
return Math.trunc((now - then - zoneDeltaMs) / DAY_MS) || 0;
}
export function channelFrecencyWeight(dayDiff: number): number {
if (dayDiff === 0) return 100;
if (dayDiff >= 1 && dayDiff < 2) return 70;
if (dayDiff >= 2 && dayDiff < 4) return 50;
if (dayDiff >= 4 && dayDiff < 7) return 30;
if (dayDiff >= 7) return 10;
return 1;
}
function scoreRecentUses(recentUses: ReadonlyArray<number>, now: number): number {
const sampleCount = Math.min(recentUses.length, CHANNEL_FRECENCY_MAX_SAMPLES);
let score = 0;
for (let index = 0; index < sampleCount; index++) {
score += channelFrecencyWeight(channelFrecencyDayDiff(now, recentUses[index]));
}
return score;
}
export function trackChannelUse(history: ChannelFrecencyHistory, key: string, timestamp?: number): void {
const use = timestamp ?? Date.now();
const entry = history.get(key);
if (entry === undefined) {
history.set(key, createEntry(1, [use], CHANNEL_FRECENCY_UNCOMPUTED, 0));
return;
}
const recentUses = [...entry.recentUses, use];
if (timestamp !== undefined) {
recentUses.sort(compareAscending);
}
history.set(
key,
createEntry(
entry.totalUses + 1,
recentUses.slice(-CHANNEL_FRECENCY_MAX_SAMPLES),
CHANNEL_FRECENCY_UNCOMPUTED,
entry.score,
),
);
}
export function computeChannelFrecency(history: ChannelFrecencyHistory, now: number): void {
for (const [key, entry] of history) {
if (entry.frecency !== CHANNEL_FRECENCY_UNCOMPUTED) continue;
const score = scoreRecentUses(entry.recentUses, now);
if (score > 0) {
const frecency = Math.ceil(entry.totalUses * (score / entry.recentUses.length));
history.set(key, createEntry(entry.totalUses, entry.recentUses, frecency, score));
} else {
history.delete(key);
}
}
}
function findOldestLastUseKey(history: ReadonlyMap<string, ChannelFrecencyEntry>): string | null {
let oldestKey: string | null = null;
let oldestLastUse = Number.POSITIVE_INFINITY;
for (const [key, entry] of history) {
const lastUse = entry.recentUses.at(-1);
if (lastUse !== undefined && lastUse < oldestLastUse) {
oldestKey = key;
oldestLastUse = lastUse;
}
}
return oldestKey;
}
export function capChannelFrecencyHistory(history: ChannelFrecencyHistory): void {
while (history.size > CHANNEL_FRECENCY_MAX_ITEMS) {
const oldestKey = findOldestLastUseKey(history);
if (oldestKey === null) return;
history.delete(oldestKey);
}
}
export function rankFrequentChannelIds(
history: ReadonlyMap<string, ChannelFrecencyEntry>,
resolveRecordId: (key: string) => string | null,
): ReadonlyArray<string> {
const ranked: Array<RankedChannelFrecency> = [];
for (const [key, entry] of history) {
const id = resolveRecordId(key);
if (id !== null) {
ranked.push({id, frecency: entry.frecency});
}
}
ranked.sort((a, b) => b.frecency - a.frecency);
return Object.freeze(ranked.slice(0, CHANNEL_FRECENCY_MAX_ITEMS).map((item) => item.id));
}
interface ChannelFrecencyWireEntry {
readonly totalUses: number;
readonly recentUsesMs: ReadonlyArray<bigint>;
}
export type ChannelFrecencyWireUsage = Record<string, {totalUses: number; recentUsesMs: Array<bigint>}>;
export type ChannelFrecencyWireUsageInput = Readonly<Record<string, ChannelFrecencyWireEntry | undefined>>;
function entryToWire(entry: ChannelFrecencyEntry): {totalUses: number; recentUsesMs: Array<bigint>} {
return {totalUses: entry.totalUses, recentUsesMs: entry.recentUses.map((use) => BigInt(use))};
}
export function channelFrecencyHistoryToWire(
history: ReadonlyMap<string, ChannelFrecencyEntry>,
): ChannelFrecencyWireUsage {
const usage: ChannelFrecencyWireUsage = {};
for (const [key, entry] of history) {
usage[key] = entryToWire(entry);
}
return usage;
}
function readWireUses(entry: ChannelFrecencyWireEntry | undefined): Array<number> {
if (entry === undefined) return [];
return entry.recentUsesMs.map((use) => Number(use)).filter(isUseTimestamp);
}
export function channelFrecencyHistoryFromWire(
usage: ChannelFrecencyWireUsageInput,
now: number,
): ChannelFrecencyHistory {
const persisted: Array<readonly [string, unknown]> = [];
for (const [key, entry] of Object.entries(usage)) {
persisted.push([key, {totalUses: entry?.totalUses, recentUses: readWireUses(entry)}]);
}
return restoreChannelFrecencyHistory(persisted, now);
}
export function mergeChannelFrecencyWireUsage(
local: ChannelFrecencyWireUsageInput,
incoming: ChannelFrecencyWireUsageInput,
now: number,
): ChannelFrecencyWireUsage {
const merged: ChannelFrecencyHistory = new Map();
const keys = new Set([...Object.keys(local), ...Object.keys(incoming)]);
for (const key of keys) {
const localEntry = local[key];
const incomingEntry = incoming[key];
const uses = [...new Set([...readWireUses(localEntry), ...readWireUses(incomingEntry)])];
if (uses.length === 0) continue;
uses.sort(compareAscending);
const totalUses = Math.max(localEntry?.totalUses ?? 0, incomingEntry?.totalUses ?? 0);
if (!Number.isFinite(totalUses) || totalUses <= 0) continue;
merged.set(key, createEntry(totalUses, uses.slice(-CHANNEL_FRECENCY_MAX_SAMPLES), CHANNEL_FRECENCY_UNCOMPUTED, 0));
}
computeChannelFrecency(merged, now);
capChannelFrecencyHistory(merged);
return channelFrecencyHistoryToWire(merged);
}
function isUseTimestamp(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function readPersistedEntry(value: unknown): ChannelFrecencyEntry | null {
if (typeof value !== 'object' || value === null) return null;
const {totalUses, recentUses} = value as {readonly totalUses?: unknown; readonly recentUses?: unknown};
if (typeof totalUses !== 'number' || !Number.isFinite(totalUses) || !Array.isArray(recentUses)) return null;
return createEntry(totalUses, recentUses.filter(isUseTimestamp), CHANNEL_FRECENCY_UNCOMPUTED, 0);
}
export function restoreChannelFrecencyHistory(
persisted: Iterable<readonly [unknown, unknown]>,
now: number,
): ChannelFrecencyHistory {
const history: ChannelFrecencyHistory = new Map();
for (const [key, value] of persisted) {
if (typeof key !== 'string') continue;
const entry = readPersistedEntry(value);
if (entry !== null) {
history.set(key, entry);
}
}
computeChannelFrecency(history, now);
return history;
}