mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(app): rework quick switcher ranking and show origin icons (#2953)
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user