feat(app): rework quick switcher ranking and show origin icons (#2953)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-25 13:59:25 +02:00
1 parent 6730a242db
commit 1c36a59b2c
58 files changed
+2854 -1703

No files matched your search

@@ -0,0 +1,59 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.frame {
--origin-badge-size: calc(var(--origin-icon-size) * 0.5625);
--origin-badge-offset: calc(var(--origin-icon-size) * -0.09375);
--origin-badge-gap: calc(var(--origin-icon-size) * 0.0625);
--origin-badge-center: calc(var(--origin-icon-size) - var(--origin-badge-size) / 2 - var(--origin-badge-offset));
--origin-badge-cutout: calc(var(--origin-badge-size) / 2 + var(--origin-badge-gap));
position: relative;
display: flex;
flex-shrink: 0;
width: var(--origin-icon-size);
height: var(--origin-icon-size);
}
.cutout {
display: flex;
width: 100%;
height: 100%;
mask-image: radial-gradient(
circle at var(--origin-badge-center) var(--origin-badge-center),
transparent var(--origin-badge-cutout),
#000 calc(var(--origin-badge-cutout) + 0.03125rem)
);
}
.badge {
position: absolute;
right: var(--origin-badge-offset);
bottom: var(--origin-badge-offset);
display: flex;
align-items: center;
justify-content: center;
width: var(--origin-badge-size);
height: var(--origin-badge-size);
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary-muted);
}
.badgeHighlighted {
color: var(--text-primary);
}
.badgeIcon {
width: calc(var(--origin-icon-size) * 0.375);
height: calc(var(--origin-icon-size) * 0.375);
}
.fallbackIcon {
width: var(--origin-icon-size);
height: var(--origin-icon-size);
flex-shrink: 0;
color: var(--text-primary-muted);
}
.fallbackIconHighlighted {
color: var(--text-primary);
}
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/channel/components/ChannelOriginIcon.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import Guilds from '@app/features/guild/state/Guilds';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type {CSSProperties, ReactNode} from 'react';
type OriginIconStyle = CSSProperties & {'--origin-icon-size': string};
interface ChannelBadgedIconProps {
channel: Channel;
children: ReactNode;
highlighted?: boolean;
size: number;
}
interface ChannelOriginIconProps {
channel: Channel;
highlighted?: boolean;
size: number;
}
function originIconStyle(size: number): OriginIconStyle {
return {'--origin-icon-size': remFromPx(size)};
}
export function ChannelBadgedIcon({channel, children, highlighted = false, size}: ChannelBadgedIconProps) {
return (
<div
className={styles.frame}
style={originIconStyle(size)}
data-flx="channel.channel-origin-icon.channel-badged-icon.frame"
>
<div className={styles.cutout} data-flx="channel.channel-origin-icon.channel-badged-icon.cutout">
{children}
</div>
<div
className={clsx(styles.badge, highlighted && styles.badgeHighlighted)}
data-flx="channel.channel-origin-icon.channel-badged-icon.badge"
>
{ChannelUtils.getIcon(channel, {className: styles.badgeIcon, weight: 'bold'})}
</div>
</div>
);
}
export const ChannelOriginIcon = observer(function ChannelOriginIcon({
channel,
highlighted = false,
size,
}: ChannelOriginIconProps) {
const guild = channel.guildId == null ? undefined : Guilds.getGuild(channel.guildId);
if (guild == null) {
return (
<span className={styles.frame} style={originIconStyle(size)} data-flx="channel.channel-origin-icon.frame">
{ChannelUtils.getIcon(channel, {
className: clsx(styles.fallbackIcon, highlighted && styles.fallbackIconHighlighted),
weight: 'bold',
})}
</span>
);
}
return (
<ChannelBadgedIcon
channel={channel}
highlighted={highlighted}
size={size}
data-flx="channel.channel-origin-icon.channel-badged-icon"
>
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
sizePx={size}
data-flx="channel.channel-origin-icon.guild-icon"
/>
</ChannelBadgedIcon>
);
});
@@ -46,10 +46,10 @@ function resolveFrecencyRecordId(key: string): string | null {
}
class ChannelFrecency {
usageHistory = new Map<string, ChannelFrecencyEntry>();
useLog = new Map<string, ChannelFrecencyEntry>();
constructor() {
makeAutoObservable(this, {usageHistory: observableShallow}, {autoBind: true});
makeAutoObservable(this, {useLog: observableShallow}, {autoBind: true});
void this.initPersistence();
}
@@ -57,11 +57,11 @@ class ChannelFrecency {
await makeSyncedField(this, {
field: 'channelFrecency',
schema: ChannelFrecencyStateSchema,
persist: ['usageHistory'],
persist: ['useLog'],
debounceMs: FRECENCY_SYNC_DEBOUNCE_MS,
toMessage: (store) => ({usage: channelFrecencyHistoryToWire(store.usageHistory)}),
toMessage: (store) => ({usage: channelFrecencyHistoryToWire(store.useLog)}),
applyMessage: (store, message) => {
store.usageHistory = channelFrecencyHistoryFromWire(message.usage, Date.now());
store.useLog = channelFrecencyHistoryFromWire(message.usage, Date.now());
},
mergeRemote: (local, incoming) => ({
usage: mergeChannelFrecencyWireUsage(local.usage, incoming.usage, Date.now()),
@@ -85,17 +85,17 @@ class ChannelFrecency {
}
get frequentIds(): ReadonlyArray<string> {
return rankFrequentChannelIds(this.usageHistory, resolveFrecencyRecordId);
return rankFrequentChannelIds(this.useLog, resolveFrecencyRecordId);
}
getScore(id: string): number {
return this.usageHistory.get(id)?.frecency ?? 0;
scoreFor(id: string): number {
return this.useLog.get(id)?.heat ?? 0;
}
track(key: string, timestamp?: number): void {
trackChannelUse(this.usageHistory, key, timestamp);
capChannelFrecencyHistory(this.usageHistory);
computeChannelFrecency(this.usageHistory, Date.now());
recordUse(key: string, timestamp?: number): void {
trackChannelUse(this.useLog, key, timestamp);
capChannelFrecencyHistory(this.useLog);
computeChannelFrecency(this.useLog, Date.now());
}
recordSelection(guildId: string | null, channelId: string | null): void {
@@ -103,21 +103,21 @@ class ChannelFrecency {
if (channelId !== lastChannelId) {
lastChannelId = channelId;
if (isTrackableId(channelId)) {
this.track(channelId);
this.recordUse(channelId);
}
}
if (selectedGuildId !== lastGuildId) {
lastGuildId = selectedGuildId;
if (isTrackableId(selectedGuildId)) {
this.track(selectedGuildId);
this.recordUse(selectedGuildId);
}
}
}
private refreshHistory(): void {
const current: unknown = this.usageHistory;
const current: unknown = this.useLog;
if (!isObservableMap(current)) return;
this.usageHistory = restoreChannelFrecencyHistory(current.entries(), Date.now());
this.useLog = restoreChannelFrecencyHistory(current.entries(), Date.now());
}
}
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ChannelFrecencyEntry {
readonly totalUses: number;
readonly recentUses: ReadonlyArray<number>;
readonly frecency: number;
readonly hitCount: number;
readonly recentHits: ReadonlyArray<number>;
readonly heat: number;
readonly score: number;
}
@@ -11,7 +11,7 @@ export type ChannelFrecencyHistory = Map<string, ChannelFrecencyEntry>;
interface RankedChannelFrecency {
readonly id: string;
readonly frecency: number;
readonly heat: number;
}
const CHANNEL_FRECENCY_MAX_ITEMS = 100;
@@ -22,12 +22,12 @@ const DAY_MS = 86_400_000;
const MINUTE_MS = 60_000;
function createEntry(
totalUses: number,
recentUses: ReadonlyArray<number>,
frecency: number,
hitCount: number,
recentHits: ReadonlyArray<number>,
heat: number,
score: number,
): ChannelFrecencyEntry {
return Object.freeze({totalUses, recentUses: Object.freeze(recentUses), frecency, score});
return Object.freeze({hitCount, recentHits: Object.freeze(recentHits), heat, score});
}
function utcOffsetMinutes(timestamp: number): number {
@@ -52,11 +52,11 @@ export function channelFrecencyWeight(dayDiff: number): number {
return 1;
}
function scoreRecentUses(recentUses: ReadonlyArray<number>, now: number): number {
const sampleCount = Math.min(recentUses.length, CHANNEL_FRECENCY_MAX_SAMPLES);
function scoreRecentUses(recentHits: ReadonlyArray<number>, now: number): number {
const sampleCount = Math.min(recentHits.length, CHANNEL_FRECENCY_MAX_SAMPLES);
let score = 0;
for (let index = 0; index < sampleCount; index++) {
score += channelFrecencyWeight(channelFrecencyDayDiff(now, recentUses[index]));
score += channelFrecencyWeight(channelFrecencyDayDiff(now, recentHits[index]));
}
return score;
}
@@ -68,15 +68,15 @@ export function trackChannelUse(history: ChannelFrecencyHistory, key: string, ti
history.set(key, createEntry(1, [use], CHANNEL_FRECENCY_UNCOMPUTED, 0));
return;
}
const recentUses = [...entry.recentUses, use];
const recentHits = [...entry.recentHits, use];
if (timestamp !== undefined) {
recentUses.sort(compareAscending);
recentHits.sort(compareAscending);
}
history.set(
key,
createEntry(
entry.totalUses + 1,
recentUses.slice(-CHANNEL_FRECENCY_MAX_SAMPLES),
entry.hitCount + 1,
recentHits.slice(-CHANNEL_FRECENCY_MAX_SAMPLES),
CHANNEL_FRECENCY_UNCOMPUTED,
entry.score,
),
@@ -85,11 +85,11 @@ export function trackChannelUse(history: ChannelFrecencyHistory, key: string, ti
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 (entry.heat !== CHANNEL_FRECENCY_UNCOMPUTED) continue;
const score = scoreRecentUses(entry.recentHits, now);
if (score > 0) {
const frecency = Math.ceil(entry.totalUses * (score / entry.recentUses.length));
history.set(key, createEntry(entry.totalUses, entry.recentUses, frecency, score));
const heat = Math.ceil(entry.hitCount * (score / entry.recentHits.length));
history.set(key, createEntry(entry.hitCount, entry.recentHits, heat, score));
} else {
history.delete(key);
}
@@ -100,7 +100,7 @@ function findOldestLastUseKey(history: ReadonlyMap<string, ChannelFrecencyEntry>
let oldestKey: string | null = null;
let oldestLastUse = Number.POSITIVE_INFINITY;
for (const [key, entry] of history) {
const lastUse = entry.recentUses.at(-1);
const lastUse = entry.recentHits.at(-1);
if (lastUse !== undefined && lastUse < oldestLastUse) {
oldestKey = key;
oldestLastUse = lastUse;
@@ -125,10 +125,10 @@ export function rankFrequentChannelIds(
for (const [key, entry] of history) {
const id = resolveRecordId(key);
if (id !== null) {
ranked.push({id, frecency: entry.frecency});
ranked.push({id, heat: entry.heat});
}
}
ranked.sort((a, b) => b.frecency - a.frecency);
ranked.sort((a, b) => b.heat - a.heat);
return Object.freeze(ranked.slice(0, CHANNEL_FRECENCY_MAX_ITEMS).map((item) => item.id));
}
@@ -142,7 +142,7 @@ export type ChannelFrecencyWireUsage = Record<string, {totalUses: number; recent
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))};
return {totalUses: entry.hitCount, recentUsesMs: entry.recentHits.map((use) => BigInt(use))};
}
export function channelFrecencyHistoryToWire(
@@ -164,11 +164,11 @@ export function channelFrecencyHistoryFromWire(
usage: ChannelFrecencyWireUsageInput,
now: number,
): ChannelFrecencyHistory {
const persisted: Array<readonly [string, unknown]> = [];
const revived: Array<readonly [string, unknown]> = [];
for (const [key, entry] of Object.entries(usage)) {
persisted.push([key, {totalUses: entry?.totalUses, recentUses: readWireUses(entry)}]);
revived.push([key, {hitCount: entry?.totalUses, recentHits: readWireUses(entry)}]);
}
return restoreChannelFrecencyHistory(persisted, now);
return restoreChannelFrecencyHistory(revived, now);
}
export function mergeChannelFrecencyWireUsage(
@@ -199,17 +199,17 @@ function isUseTimestamp(value: unknown): value is number {
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);
const {hitCount, recentHits} = value as {readonly hitCount?: unknown; readonly recentHits?: unknown};
if (typeof hitCount !== 'number' || !Number.isFinite(hitCount) || !Array.isArray(recentHits)) return null;
return createEntry(hitCount, recentHits.filter(isUseTimestamp), CHANNEL_FRECENCY_UNCOMPUTED, 0);
}
export function restoreChannelFrecencyHistory(
persisted: Iterable<readonly [unknown, unknown]>,
revived: Iterable<readonly [unknown, unknown]>,
now: number,
): ChannelFrecencyHistory {
const history: ChannelFrecencyHistory = new Map();
for (const [key, value] of persisted) {
for (const [key, value] of revived) {
if (typeof key !== 'string') continue;
const entry = readPersistedEntry(value);
if (entry !== null) {