feat(app): restyle the inbox and message preview surfaces (#1706)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-18 00:43:18 +02:00
1 parent 41c7acdd8f
commit cc105883a5
55 files changed
+10669 -10576

No files matched your search

@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {mergeFrozenUnreadOrder} from './UnreadChannelOrder';
function ids(channels: ReadonlyArray<{id: string}>): Array<string> {
return channels.map((channel) => channel.id);
}
describe('mergeFrozenUnreadOrder', () => {
it('keeps channels in their frozen position while the list is open', () => {
const order = new Map([
['a', 0],
['b', 1],
['c', 2],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]);
expect(ids(merged)).toEqual(['a', 'b', 'c']);
});
it('shows a channel that becomes unread after the order was frozen', () => {
const order = new Map([['a', 0]]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
expect(ids(merged)).toEqual(['a', 'late']);
});
it('appends late arrivals after existing entries rather than reshuffling', () => {
const order = new Map([
['a', 0],
['b', 1],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'b'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'b', 'late']);
});
it('remembers a late arrival position once assigned', () => {
const order = new Map([['a', 0]]);
mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'late']);
});
it('drops nothing when every channel is new', () => {
const order = new Map<string, number>();
const merged = mergeFrozenUnreadOrder(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]);
expect(ids(merged)).toEqual(['x', 'y', 'z']);
});
it('does not resurrect channels that are no longer unread', () => {
const order = new Map([
['a', 0],
['gone', 1],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}]);
expect(ids(merged)).toEqual(['a']);
});
});
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
function resolveUnreadOrderIndex(order: Map<string, number>, channelId: string): number {
const index = order.get(channelId);
if (index == null) {
return Number.POSITIVE_INFINITY;
}
return index;
}
export function mergeFrozenUnreadOrder<T extends {id: string}>(
order: Map<string, number>,
current: ReadonlyArray<T>,
): Array<T> {
for (const channel of current) {
if (!order.has(channel.id)) {
order.set(channel.id, order.size);
}
}
return [...current].sort((a, b) => resolveUnreadOrderIndex(order, a.id) - resolveUnreadOrderIndex(order, b.id));
}
@@ -5,14 +5,6 @@
--inbox-scroller-padding-bottom: 0.75rem;
--inbox-section-gap: 0.25rem;
--inbox-card-gap: 1rem;
--inbox-heading-padding-x: 0.25rem;
--inbox-heading-padding-y: 0.75rem;
--inbox-sticky-bg: var(--form-surface-background);
--inbox-sticky-radius: 0.375rem;
--inbox-sticky-separator: 0 0.0625rem 0 var(--background-modifier-accent);
--inbox-sticky-group-heading-height: 2.25rem;
--inbox-sticky-channel-heading-height: 3rem;
--inbox-sticky-channel-top: var(--inbox-sticky-group-heading-height);
--inbox-icon-button-size: 1.75rem;
--inbox-icon-button-radius: 0.375rem;
--inbox-collapse-button-size: 1.375rem;
@@ -21,64 +13,32 @@
min-height: 0;
min-width: 0;
padding: 0 var(--inbox-scroller-padding-x) var(--inbox-scroller-padding-bottom);
scroll-padding-top: calc(
var(--inbox-sticky-group-heading-height) +
var(--inbox-sticky-channel-heading-height) +
0.5rem
);
}
.guildGroup {
margin-bottom: var(--inbox-section-gap);
min-width: 0;
}
.guildGroup:last-child {
margin-bottom: 0;
.list {
display: block;
min-width: 0;
}
.guildGroupHeading {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: center;
.row {
display: block;
box-sizing: border-box;
min-height: var(--inbox-sticky-group-heading-height);
margin: 0;
padding: var(--inbox-heading-padding-y) var(--inbox-heading-padding-x) 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-primary-muted);
border-radius: var(--inbox-sticky-radius);
background-color: var(--inbox-sticky-bg);
transition: color 0.12s ease;
padding-bottom: var(--inbox-card-gap);
}
.rowGroupEnd {
padding-bottom: calc(var(--inbox-card-gap) + var(--inbox-section-gap));
}
.channelCard {
margin-bottom: var(--inbox-card-gap);
}
.channelCard:focus {
min-width: 0;
outline: none;
}
.channelStickyHeader {
position: sticky;
top: var(--inbox-sticky-channel-top);
z-index: 2;
margin: 0 0 0.5rem;
padding: 0 var(--inbox-heading-padding-x);
border-radius: var(--inbox-sticky-radius);
background-color: var(--inbox-sticky-bg);
box-shadow: var(--inbox-sticky-separator);
transition:
background-color 0.12s ease,
box-shadow 0.12s ease;
}
.srOnly {
position: absolute;
width: 0.0625rem;
@@ -107,16 +67,13 @@
}
.messageStream {
--chat-horizontal-padding: 0.75rem;
padding: 0 0.5rem;
min-width: 0;
overflow: hidden;
}
.previewFooter {
display: flex;
justify-content: flex-start;
padding: 0.5rem 0.75rem 0.625rem;
padding: 0.375rem 0.5rem 0.25rem;
}
.viewAllButton {
@@ -148,8 +105,8 @@
.messageActions {
display: none;
position: absolute;
top: 0.5rem;
right: 0.75rem;
top: 0.375rem;
right: 0.5rem;
gap: 0.25rem;
}
@@ -222,7 +179,7 @@
}
.previewDateDivider :global(.text) {
background-color: var(--inbox-sticky-bg);
background-color: var(--form-surface-background);
border-radius: 0.375rem;
padding: 0.125rem 0.625rem;
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
import {Endpoints} from '@app/features/app/constants/Endpoints';
@@ -38,6 +39,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import UserSettings from '@app/features/user/state/UserSettings';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {compare as compareSnowflakes, extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {msg} from '@lingui/core/macro';
@@ -50,7 +52,7 @@ import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const MENTIONS_DESCRIPTOR = msg({
message: '{channelHeading}, {mentionCount} mentions',
message: '{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}',
comment:
'Short label in the unread channels content. Preserve placeholders {channelHeading}, {mentionCount}; they are inserted by code.',
});
@@ -223,13 +225,13 @@ function buildPreviewDataFromMessages(
oldestUnreadMessageId: plan.oldestUnreadMessageId,
};
}
return buildEmptyPreview(plan.channel);
}
const sorted = [...records].sort((a, b) => compareSnowflakes(a.id, b.id));
const latest = sorted.slice(Math.max(0, sorted.length - UNREAD_PREVIEW_MESSAGE_LIMIT));
return {
channel: plan.channel,
messages: sorted.slice(0, UNREAD_PREVIEW_MESSAGE_LIMIT),
oldestUnreadMessageId: sorted[0]?.id ?? null,
messages: latest,
oldestUnreadMessageId: latest[0]?.id ?? null,
};
}
@@ -239,13 +241,37 @@ async function fetchPreviewBatch(plans: Array<PreviewFetchPlan>): Promise<void>
body: {requests: plans.map((plan) => plan.request)},
});
const responseChannels = new Map((response.body?.channels ?? []).map((entry) => [entry.channel_id, entry.messages]));
const retryPlans: Array<PreviewFetchPlan> = [];
for (const plan of plans) {
const messages = responseChannels.get(plan.channel.id) ?? [];
const isAnchored = plan.request.around != null || plan.request.after != null;
if (messages.length === 0 && isAnchored) {
retryPlans.push({
channel: plan.channel,
cacheKey: plan.cacheKey,
oldestUnreadMessageId: null,
request: {channel_id: plan.channel.id, limit: UNREAD_PREVIEW_MESSAGE_LIMIT},
});
continue;
}
previewCache.set(plan.channel.id, {
cacheKey: plan.cacheKey,
data: buildPreviewDataFromMessages(plan, messages),
});
}
if (retryPlans.length === 0) return;
const retryResponse = await http.post<BulkPreviewResponse>(Endpoints.CHANNEL_MESSAGES_BULK, {
body: {requests: retryPlans.map((plan) => plan.request)},
});
const retryChannels = new Map(
(retryResponse.body?.channels ?? []).map((entry) => [entry.channel_id, entry.messages]),
);
for (const plan of retryPlans) {
previewCache.set(plan.channel.id, {
cacheKey: plan.cacheKey,
data: buildPreviewDataFromMessages(plan, retryChannels.get(plan.channel.id) ?? []),
});
}
}
function chunkPlans(plans: Array<PreviewFetchPlan>): Array<Array<PreviewFetchPlan>> {
@@ -423,7 +449,8 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
messageRowClassName: styles.messageRow,
messageActionsClassName: styles.messageActions,
renderMessageActions,
readonlyPreview: true,
suppressMessageActions: true,
previewContext: MessagePreviewContext.LIST_POPOUT,
dateDividerClassName: styles.previewDateDivider,
suppressUnreadIndicator: true,
getMessageHeadingActivate,
@@ -468,7 +495,6 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
</h2>
<InboxMessageHeader
channel={channel}
className={styles.channelStickyHeader}
onClick={handleHeaderClick}
mentionCount={mentionCount}
leftAdornment={
@@ -627,15 +653,11 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
frozenOrderRef.current = new Map(getUnreadChannels().map((channel, index) => [channel.id, index]));
}
const allUnreadChannels = useMemo(() => {
const order = frozenOrderRef.current!;
const current = getUnreadChannels();
return current
.filter((channel) => order.has(channel.id))
.sort((a, b) => {
const aIdx = order.get(a.id) ?? Number.POSITIVE_INFINITY;
const bIdx = order.get(b.id) ?? Number.POSITIVE_INFINITY;
return aIdx - bIdx;
});
const order = frozenOrderRef.current;
if (order == null) {
return [];
}
return mergeFrozenUnreadOrder(order, getUnreadChannels());
}, [readStateVersion, settingsVersion]);
const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS);
const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]);
@@ -672,6 +694,20 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
}
return groups;
}, [visibleChannels, channelPreviews, i18n.locale]);
const unreadRows = useMemo(() => {
const rows: Array<{channel: Channel; endsGroup: boolean; groupLabel: string; key: string}> = [];
for (const group of groupedChannels) {
for (const [index, channel] of group.channels.entries()) {
rows.push({
channel,
endsGroup: index === group.channels.length - 1,
groupLabel: group.label,
key: channel.id,
});
}
}
return rows;
}, [groupedChannels]);
const hasMore = loadedCount < allUnreadChannels.length;
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
@@ -751,34 +787,31 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
data-message-selection-root="true"
data-flx="app.floating.unread-channels-content.scroller"
>
{groupedChannels.map((group) => {
const groupHeadingId = `inbox-unread-group-${group.key}`;
return (
<section
key={group.key}
className={styles.guildGroup}
aria-labelledby={groupHeadingId}
data-flx="app.floating.unread-channels-content.guild-group"
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
{unreadRows.map((row, rowIndex) => (
<div
key={row.key}
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
role="listitem"
aria-posinset={rowIndex + 1}
aria-setsize={unreadRows.length}
data-flx="app.floating.unread-channels-content.row"
>
<h1
id={groupHeadingId}
className={styles.guildGroupHeading}
data-flx="app.floating.unread-channels-content.guild-group-heading"
<section
className={styles.guildGroup}
aria-label={row.groupLabel}
data-flx="app.floating.unread-channels-content.guild-group"
>
{group.label}
</h1>
{group.channels.map((channel) => (
<UnreadChannelCard
key={channel.id}
channel={channel}
headingId={`inbox-unread-channel-${channel.id}`}
previewData={channelPreviews.get(channel.id) ?? null}
channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null}
data-flx="app.floating.unread-channels-content.unread-channel-card"
/>
))}
</section>
);
})}
</section>
</div>
))}
</div>
</Scroller>
);
});
@@ -9,8 +9,8 @@
-webkit-user-select: none;
overflow: visible;
flex-shrink: 0;
border-radius: 0.375rem;
border: 0.0625rem solid var(--background-header-secondary);
border-radius: 0.5rem;
border: 0.0625rem solid var(--message-preview-card-border, var(--background-header-secondary));
background-color: var(--message-preview-card-background, transparent);
padding-top: 0.5rem;
padding-bottom: 0.5rem;