mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(app): keep unread channel opens anchored to the divider
This commit is contained in:
1 parent
8e17970632
commit
d3976e33f8
8 files changed
+223
-7
No files matched your search
@@ -23,6 +23,7 @@ interface BlockedMessageGroupsProps {
|
||||
messageGroups: Array<ChannelStreamItem>;
|
||||
onReveal: (messageId: string | null) => void;
|
||||
revealed: boolean;
|
||||
hasUnread?: boolean;
|
||||
compact: boolean;
|
||||
messageGroupSpacing: number;
|
||||
variant: 'blocked' | 'spammer';
|
||||
@@ -33,11 +34,13 @@ interface BlockedMessageGroupsProps {
|
||||
messageActionsClassName?: string;
|
||||
renderMessageActions?: (message: Message) => React.ReactNode;
|
||||
renderMessageWrapper?: (props: MessageGroupRenderWrapperProps) => React.ReactNode;
|
||||
suppressUnreadIndicator?: boolean;
|
||||
}
|
||||
|
||||
const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedMessageGroupsProps): boolean => {
|
||||
if (prevProps.channel.id !== nextProps.channel.id) return false;
|
||||
if (prevProps.revealed !== nextProps.revealed) return false;
|
||||
if (prevProps.hasUnread !== nextProps.hasUnread) return false;
|
||||
if (prevProps.compact !== nextProps.compact) return false;
|
||||
if (prevProps.messageGroupSpacing !== nextProps.messageGroupSpacing) return false;
|
||||
if (prevProps.variant !== nextProps.variant) return false;
|
||||
@@ -49,6 +52,7 @@ const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedM
|
||||
if (prevProps.messageActionsClassName !== nextProps.messageActionsClassName) return false;
|
||||
if (prevProps.renderMessageActions !== nextProps.renderMessageActions) return false;
|
||||
if (prevProps.renderMessageWrapper !== nextProps.renderMessageWrapper) return false;
|
||||
if (prevProps.suppressUnreadIndicator !== nextProps.suppressUnreadIndicator) return false;
|
||||
if (prevProps.messageGroups.length !== nextProps.messageGroups.length) return false;
|
||||
for (let i = 0; i < prevProps.messageGroups.length; i++) {
|
||||
const prevGroup = prevProps.messageGroups[i];
|
||||
@@ -69,6 +73,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
channel,
|
||||
compact,
|
||||
revealed,
|
||||
hasUnread = false,
|
||||
messageGroupSpacing,
|
||||
onReveal,
|
||||
variant,
|
||||
@@ -79,6 +84,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
messageActionsClassName,
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
} = props;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottomFrameRef = useRef<number | null>(null);
|
||||
@@ -169,6 +175,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
};
|
||||
messageGroups.forEach((item, itemIndex) => {
|
||||
if (item.type === ChannelStreamType.DIVIDER) {
|
||||
if (item.unreadId && suppressUnreadIndicator) {
|
||||
return;
|
||||
}
|
||||
if (itemIndex === 0 && item.unreadId) {
|
||||
return;
|
||||
}
|
||||
flushGroup();
|
||||
nodes.push(
|
||||
<Divider
|
||||
@@ -205,13 +217,23 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
messageActionsClassName,
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
]);
|
||||
const leadingUnreadDivider = messageGroups[0]?.type === ChannelStreamType.DIVIDER && !!messageGroups[0].unreadId;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.container, className)}
|
||||
data-flx="channel.blocked-message-groups.container"
|
||||
>
|
||||
{hasUnread && (!revealed || leadingUnreadDivider) && (
|
||||
<Divider
|
||||
spacing={messageGroupSpacing}
|
||||
red={true}
|
||||
id="new-messages-bar"
|
||||
data-flx="channel.blocked-message-groups.collapsed-unread-divider"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
|
||||
@@ -189,11 +189,13 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
|
||||
key={item.key}
|
||||
revealed={item.key === unblurredMessageId}
|
||||
messageGroups={item.content as Array<ChannelStreamItem>}
|
||||
hasUnread={item.hasUnread === true && !suppressUnreadIndicator}
|
||||
onReveal={onReveal ?? (() => {})}
|
||||
compact={messageDisplayCompact}
|
||||
channel={channel}
|
||||
messageGroupSpacing={messageGroupSpacing}
|
||||
variant={variant}
|
||||
suppressUnreadIndicator={suppressUnreadIndicator}
|
||||
data-flx="channel.channel-message-stream.render-channel-stream.blocked-message-groups"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -236,7 +236,7 @@ export const Messages = observer(function Messages({
|
||||
messages: safeMessages,
|
||||
channel,
|
||||
compact: state.messageDisplayCompact,
|
||||
hasPendingUnreads: state.unreadCount > 0,
|
||||
hasPendingUnreads: state.visualUnreadMessageId != null,
|
||||
focusAnchorId: null,
|
||||
unloadedSpacerHeight: selectChannelMessagesSpacerHeight(windowStatus, placeholderSpecs.totalHeight),
|
||||
allowHistoryFetch: true,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import {ChannelStreamType, createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
|
||||
vi.mock('@app/features/moderation/state/LocalUserSpamOverride', () => ({
|
||||
default: {isUserMarkedAsSpammer: () => false},
|
||||
}));
|
||||
vi.mock('@app/features/user/utils/DateFormatting', () => ({
|
||||
getFormattedFullDate: (date: Date) => date.toISOString().slice(0, 10),
|
||||
}));
|
||||
|
||||
const AUTHOR = {id: 'author-1', flags: 0} as Message['author'];
|
||||
|
||||
const ID = {
|
||||
first: '1519773906704011264',
|
||||
second: '1519773906708205568',
|
||||
third: '1519773906712399872',
|
||||
fourth: '1519773906716594176',
|
||||
} as const;
|
||||
|
||||
function message(id: string, minute: number, blocked = false): Message {
|
||||
return {
|
||||
id,
|
||||
author: AUTHOR,
|
||||
blocked,
|
||||
ignored: false,
|
||||
timestamp: new Date(Date.UTC(2026, 7, 27, 12, minute)),
|
||||
type: 0,
|
||||
webhookId: null,
|
||||
mentions: [],
|
||||
mentionRoles: [],
|
||||
mentionEveryone: false,
|
||||
isUserMessage: () => true,
|
||||
hasFlag: () => false,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
function channelMessages(messages: Array<Message>): ChannelMessages {
|
||||
return {
|
||||
forEach: (callback: (message: Message) => boolean | undefined) => {
|
||||
for (const entry of messages) {
|
||||
if (callback(entry) === false) return;
|
||||
}
|
||||
},
|
||||
jumpDestinationId: null,
|
||||
jumpHighlight: false,
|
||||
jumpTicket: null,
|
||||
} as unknown as ChannelMessages;
|
||||
}
|
||||
|
||||
const channel = {id: 'channel-1', isPrivate: () => false, getGuildId: () => 'guild-1'} as unknown as Channel;
|
||||
|
||||
function buildStream(messages: Array<Message>, oldestUnreadMessageId: string | null) {
|
||||
return createChannelStream({
|
||||
channel,
|
||||
messages: channelMessages(messages),
|
||||
oldestUnreadMessageId,
|
||||
treatSpam: false,
|
||||
});
|
||||
}
|
||||
|
||||
function countUnreadMarkers(stream: Array<ReturnType<typeof buildStream>[number]>): number {
|
||||
let count = 0;
|
||||
for (const item of stream) {
|
||||
if (item.type === ChannelStreamType.DIVIDER && item.unreadId != null) count++;
|
||||
if (item.type === ChannelStreamType.MESSAGE && item.showUnreadDividerBefore) count++;
|
||||
if (Array.isArray(item.content)) {
|
||||
count += countUnreadMarkers(item.content as Array<ReturnType<typeof buildStream>[number]>);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function findUnreadDivider(stream: Array<ReturnType<typeof buildStream>[number]>) {
|
||||
for (const item of stream) {
|
||||
if (item.type === ChannelStreamType.DIVIDER && item.unreadId != null) return item;
|
||||
if (item.type === ChannelStreamType.MESSAGE && item.showUnreadDividerBefore) return item;
|
||||
if (item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED) {
|
||||
const nested = item.content as Array<ReturnType<typeof buildStream>[number]>;
|
||||
const divider = nested.find((entry) => entry.type === ChannelStreamType.DIVIDER && entry.unreadId != null);
|
||||
if (divider) return divider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('createChannelStream unread divider', () => {
|
||||
it('marks the unread boundary on a plain message', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1), message(ID.third, 2)];
|
||||
expect(findUnreadDivider(buildStream(messages, ID.second))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits a divider inside the collapsed group when the boundary is a blocked message', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2, true)];
|
||||
const stream = buildStream(messages, ID.second);
|
||||
const group = stream.find((item) => item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED);
|
||||
expect(group).toBeDefined();
|
||||
const nested = group!.content as Array<(typeof stream)[number]>;
|
||||
expect(nested.some((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.second)).toBe(true);
|
||||
expect(group!.hasUnread).toBe(true);
|
||||
});
|
||||
|
||||
it('emits a divider inside the collapsed group when the stored boundary is no longer loaded', () => {
|
||||
const messages = [message(ID.third, 0, true), message(ID.fourth, 1, true)];
|
||||
const stream = buildStream(messages, ID.first);
|
||||
const group = stream.find((item) => item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED);
|
||||
expect(group).toBeDefined();
|
||||
const nested = group!.content as Array<(typeof stream)[number]>;
|
||||
expect(nested.some((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.third)).toBe(true);
|
||||
});
|
||||
|
||||
it('never leaves the boundary unrepresented when there is an unread message id', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2)];
|
||||
expect(findUnreadDivider(buildStream(messages, ID.second))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('merges the boundary into the date divider when it is the first message of a day', () => {
|
||||
const dayOne = message(ID.first, 0);
|
||||
const dayTwo = message(ID.third, 1);
|
||||
(dayTwo as {timestamp: Date}).timestamp = new Date(Date.UTC(2026, 7, 28, 9, 0));
|
||||
const stream = buildStream([dayOne, dayTwo], ID.third);
|
||||
const dateDivider = stream.find((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.third);
|
||||
expect(dateDivider).toBeDefined();
|
||||
expect(countUnreadMarkers(stream)).toBe(1);
|
||||
});
|
||||
|
||||
it('marks the boundary exactly once', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2)];
|
||||
expect(countUnreadMarkers(buildStream(messages, ID.second))).toBe(1);
|
||||
expect(countUnreadMarkers(buildStream([message(ID.first, 0), message(ID.second, 1)], ID.second))).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -197,18 +197,22 @@ export function createChannelStream(props: {
|
||||
}
|
||||
}
|
||||
let shouldShowUnreadDividerBefore = false;
|
||||
let shouldOpenCollapsedGroupWithUnreadDivider = false;
|
||||
if (oldestUnreadMessageId === message.id && unreadTimestamp != null) {
|
||||
if (lastItem?.type === ChannelStreamType.DIVIDER) {
|
||||
lastItem.unreadId = message.id;
|
||||
} else if (collapsedGroupItem !== null) {
|
||||
shouldOpenCollapsedGroupWithUnreadDivider = true;
|
||||
} else {
|
||||
shouldShowUnreadDividerBefore = true;
|
||||
if (collapsedGroupItem !== null) {
|
||||
collapsedGroupItem.hasUnread = true;
|
||||
}
|
||||
}
|
||||
unreadTimestamp = null;
|
||||
} else if (unreadTimestamp != null && extractTimestamp(message.id) > unreadTimestamp) {
|
||||
shouldShowUnreadDividerBefore = true;
|
||||
if (collapsedGroupItem !== null) {
|
||||
shouldOpenCollapsedGroupWithUnreadDivider = true;
|
||||
} else {
|
||||
shouldShowUnreadDividerBefore = true;
|
||||
}
|
||||
unreadTimestamp = null;
|
||||
}
|
||||
let prevMessageForGrouping: Message | undefined;
|
||||
@@ -242,7 +246,12 @@ export function createChannelStream(props: {
|
||||
messageItem.jumpTarget = true;
|
||||
}
|
||||
if (collapsedGroupItem !== null) {
|
||||
(collapsedGroupItem.content as Array<ChannelStreamItem>).push(messageItem);
|
||||
const collapsedContentItems = collapsedGroupItem.content as Array<ChannelStreamItem>;
|
||||
if (shouldOpenCollapsedGroupWithUnreadDivider) {
|
||||
collapsedContentItems.push({type: ChannelStreamType.DIVIDER, content: '', unreadId: message.id});
|
||||
collapsedGroupItem.hasUnread = true;
|
||||
}
|
||||
collapsedContentItems.push(messageItem);
|
||||
if (messageItem.jumpTarget) {
|
||||
collapsedGroupItem.hasJumpTarget = true;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
|
||||
import {NEW_MESSAGES_BAR_BUFFER} from '@fluxer/constants/src/LimitConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
CENTRE_ALIGNMENT_LIFT,
|
||||
InitialScrollIntent,
|
||||
MESSAGE_REVEAL_PADDING,
|
||||
resolveContainerResizeShift,
|
||||
resolveInitialScrollIntent,
|
||||
type ScrollerState,
|
||||
shouldAnimateMessageJump,
|
||||
} from './shared';
|
||||
@@ -83,3 +86,42 @@ describe('resolveContainerResizeShift', () => {
|
||||
expect(resolve(10, state(1400, 400, 2000))).toEqual({kind: 'none'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveInitialScrollIntent', () => {
|
||||
it('anchors to the unread boundary only when the divider is expected on screen', () => {
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_TEXT,
|
||||
rememberedScrollTop: 900,
|
||||
hasPendingUnreads: true,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.UNREAD_BOUNDARY);
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_TEXT,
|
||||
rememberedScrollTop: 900,
|
||||
hasPendingUnreads: false,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.SAVED_OFFSET);
|
||||
});
|
||||
|
||||
it('falls back to the bottom rather than the top when there is no unread boundary and no saved offset', () => {
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_TEXT,
|
||||
rememberedScrollTop: null,
|
||||
hasPendingUnreads: false,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.BOTTOM);
|
||||
});
|
||||
|
||||
it('never anchors to the unread boundary in voice channels', () => {
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_VOICE,
|
||||
rememberedScrollTop: null,
|
||||
hasPendingUnreads: true,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.BOTTOM);
|
||||
});
|
||||
});
|
||||
@@ -457,6 +457,9 @@ class ReadStates {
|
||||
this.refreshUnreadEstimate(state);
|
||||
} else if (action.isAfter && state.ackMessageId != null && messages.has(state.ackMessageId, true)) {
|
||||
state.unreadCount += action.messages.length;
|
||||
if (state.oldestUnreadMessageId == null) {
|
||||
state.rebuild();
|
||||
}
|
||||
}
|
||||
this.notifyChange(action.channelId);
|
||||
}
|
||||
@@ -533,6 +536,7 @@ class ReadStates {
|
||||
}
|
||||
|
||||
handleMessageDelete(action: {channelId: string}): void {
|
||||
this.getIfExists(action.channelId)?.rebuild();
|
||||
this.notifyChange(action.channelId);
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ export class ReadStateEntry {
|
||||
} else {
|
||||
this.unreadCount = loadedUnreadCount;
|
||||
}
|
||||
this.oldestUnreadMessageId = hasUnreadBoundary ? (this.storedOldestUnreadMessageId ?? oldestUnread) : null;
|
||||
this.oldestUnreadMessageId = this.storedOldestUnreadMessageId ?? oldestUnread;
|
||||
}
|
||||
|
||||
shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean {
|
||||
|
||||
Reference in new issue
Block a user