mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): rank forward destinations and preview the message (#2790)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user