mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(app): restyle the inbox and message preview surfaces (#1706)
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user