mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(app): separate unread state from unread counts
This commit is contained in:
@@ -230,6 +230,7 @@ export const ChannelItem = observer(
|
||||
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
|
||||
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasUnread = ReadStates.hasUnread(channel.id);
|
||||
const connectedVoiceGuildId = channelIsVoice ? MediaEngine.guildId : null;
|
||||
const connectedVoiceChannelId = channelIsVoice ? MediaEngine.channelId : null;
|
||||
const canManageChannels = Permission.can(Permissions.MANAGE_CHANNELS, channel);
|
||||
@@ -265,6 +266,7 @@ export const ChannelItem = observer(
|
||||
type: channel.type,
|
||||
});
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted: isChannelDirectlyMuted,
|
||||
|
||||
@@ -335,6 +335,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
);
|
||||
const hasVisibleUnreadInChannel = (channelId: string): boolean => {
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const hasUnread = ReadStates.hasUnread(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const isMuted =
|
||||
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
|
||||
@@ -349,6 +350,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
})
|
||||
: null;
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -197,6 +197,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
);
|
||||
const refs = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasUnread = ReadStates.hasUnread(channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const isGroupDM = channel.isGroupDM();
|
||||
const isDM = channel.isDM();
|
||||
@@ -220,6 +221,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
})
|
||||
: null;
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -663,6 +663,7 @@ function resolveGuildTargetBounds({
|
||||
function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverity | null {
|
||||
const mentionCount = ReadStates.getPrivateChannelMentionCount(channelId);
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnreadPrivateChannel(channelId),
|
||||
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
|
||||
mentionCount,
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
|
||||
@@ -70,6 +70,7 @@ const ACTIVE_CALL_DESCRIPTOR = msg({
|
||||
|
||||
export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadState {
|
||||
return getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnread(channelId),
|
||||
unreadCount: ReadStates.getUnreadCount(channelId),
|
||||
mentionCount: ReadStates.getMentionCount(channelId),
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {getChannelUnreadState} from './ChannelUnreadState';
|
||||
describe('getChannelUnreadState', () => {
|
||||
it('shows a normal unread indicator for all-messages unread badges', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -19,6 +20,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('shows a muted unread indicator for only-mentions unread badges without highlighting the channel', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -31,6 +33,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('hides unread and mention surfaces when unread badges are disabled', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 1,
|
||||
isMuted: false,
|
||||
@@ -43,6 +46,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('keeps legacy muted-channel fading for channels without an unread-badges level', () => {
|
||||
const hiddenState = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: true,
|
||||
@@ -50,6 +54,7 @@ describe('getChannelUnreadState', () => {
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
const fadedState = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: true,
|
||||
@@ -60,4 +65,27 @@ describe('getChannelUnreadState', () => {
|
||||
expect(fadedState.shouldShowUnreadIndicator).toBe(true);
|
||||
expect(fadedState.isUnreadIndicatorMuted).toBe(true);
|
||||
});
|
||||
it('shows the indicator from the unread flag rather than the message count', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
expect(state.shouldShowUnreadIndicator).toBe(true);
|
||||
});
|
||||
it('hides the indicator for a read channel even if a stale count survives', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: false,
|
||||
unreadCount: 7,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
expect(state.shouldShowUnreadIndicator).toBe(false);
|
||||
expect(state.hasVisibleUnread).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {resolveChannelUnreadState} from './ChannelUnreadStateMachine';
|
||||
|
||||
export interface ChannelUnreadStateInput {
|
||||
hasUnread: boolean;
|
||||
unreadCount: number;
|
||||
mentionCount: number;
|
||||
isMuted: boolean;
|
||||
@@ -20,6 +21,7 @@ export interface ChannelUnreadState {
|
||||
}
|
||||
|
||||
export function getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
@@ -27,6 +29,7 @@ export function getChannelUnreadState({
|
||||
unreadBadgesLevel,
|
||||
}: ChannelUnreadStateInput): ChannelUnreadState {
|
||||
return resolveChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
function input(overrides: Partial<ChannelUnreadStateInput> = {}): ChannelUnreadStateInput {
|
||||
return {
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -38,6 +39,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
const snapshot = createChannelUnreadSnapshot(
|
||||
input({
|
||||
unreadBadgesLevel: MessageNotifications.NO_MESSAGES,
|
||||
hasUnread: true,
|
||||
unreadCount: 1,
|
||||
mentionCount: 1,
|
||||
}),
|
||||
@@ -55,6 +57,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
it('transitions without preserving stale policy output', () => {
|
||||
const legacySnapshot = createChannelUnreadSnapshot(
|
||||
input({
|
||||
hasUnread: true,
|
||||
unreadCount: 2,
|
||||
isMuted: true,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
@@ -66,6 +69,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
type: 'channelUnread.updated',
|
||||
input: input({
|
||||
unreadBadgesLevel: MessageNotifications.ALL_MESSAGES,
|
||||
hasUnread: true,
|
||||
unreadCount: 2,
|
||||
isMuted: true,
|
||||
}),
|
||||
|
||||
@@ -86,7 +86,7 @@ export function transitionChannelUnreadSnapshot(
|
||||
|
||||
export function selectChannelUnreadState(snapshot: ChannelUnreadSnapshot): ChannelUnreadState {
|
||||
const context = snapshot.context;
|
||||
const hasUnreadMessages = context.unreadCount > 0;
|
||||
const hasUnreadMessages = context.hasUnread;
|
||||
const rawHasMentions = context.mentionCount > 0;
|
||||
switch (getUnreadStateValue(snapshot)) {
|
||||
case 'disabled':
|
||||
|
||||
@@ -135,6 +135,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnread(channel.id),
|
||||
unreadCount: ReadStates.getUnreadCount(channel.id),
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const makeChannel = (id: string) => ({
|
||||
id,
|
||||
type: 0,
|
||||
guildId: 'guild-1',
|
||||
isPrivate: () => false,
|
||||
getGuildId: () => 'guild-1',
|
||||
});
|
||||
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
|
||||
let hasMoreBefore = false;
|
||||
let hasNewestMessages = true;
|
||||
|
||||
vi.mock('@app/features/channel/state/Channels', () => ({
|
||||
default: {getChannel: (id: string) => makeChannel(id), getBasicChannel: (id: string) => makeChannel(id)},
|
||||
}));
|
||||
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
||||
default: {
|
||||
getMessages: () => ({
|
||||
get hasMoreBefore() {
|
||||
return hasMoreBefore;
|
||||
},
|
||||
get length() {
|
||||
return loadedMessages.length;
|
||||
},
|
||||
jumpDestinationId: null,
|
||||
hasNewestMessages: () => hasNewestMessages,
|
||||
has: (id: string) => loadedMessages.some((m) => m.id === id),
|
||||
last: () => loadedMessages[loadedMessages.length - 1],
|
||||
forEachBuffered: (cb: (m: unknown) => void) => {
|
||||
for (const m of loadedMessages) cb(m);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({id: 'me'})}}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
|
||||
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
|
||||
default: {
|
||||
isEveryoneMentionSuppressed: () => false,
|
||||
isRoleMentionSuppressed: () => false,
|
||||
isGuildOrChannelMuted: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => false}}));
|
||||
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
|
||||
default: {isAutomaticAckEnabled: () => false},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: vi.fn(), get: vi.fn()}}));
|
||||
|
||||
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
|
||||
|
||||
const ID = {
|
||||
ack: '1519773906704011264',
|
||||
newer: '1519773906708205568',
|
||||
};
|
||||
|
||||
let nextChannelId = 0;
|
||||
|
||||
function seedReadChannel() {
|
||||
const channelId = `channel-${++nextChannelId}`;
|
||||
const state = ReadStates.get(channelId);
|
||||
state.readStateKnown = true;
|
||||
state.ackMessageId = ID.ack;
|
||||
state.lastMessageId = ID.ack;
|
||||
state.unreadCount = 0;
|
||||
state.oldestUnreadMessageId = null;
|
||||
return {channelId, state};
|
||||
}
|
||||
|
||||
describe('ReadStates unread invariant', () => {
|
||||
beforeEach(() => {
|
||||
loadedMessages.length = 0;
|
||||
hasMoreBefore = false;
|
||||
hasNewestMessages = true;
|
||||
});
|
||||
|
||||
it('never reports a positive unread count without an unread anchor after a passive update', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
const count = ReadStates.getUnreadCount(channelId);
|
||||
const anchor = ReadStates.getVisualUnreadMessageId(channelId);
|
||||
expect(count > 0).toBe(anchor != null);
|
||||
});
|
||||
|
||||
it('keeps the channel unread for the sidebar even with no anchor to draw a divider at', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears a stale unread once the server walks the last message id back', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.ack}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(false);
|
||||
expect(ReadStates.getUnreadCount(channelId)).toBe(0);
|
||||
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBeNull();
|
||||
});
|
||||
|
||||
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
state.lastMessageId = ID.newer;
|
||||
hasMoreBefore = true;
|
||||
loadedMessages.push({id: ID.newer, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, messages: []});
|
||||
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBe(ID.newer);
|
||||
expect(ReadStates.getUnreadCount(channelId) > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -141,16 +141,11 @@ class ReadStates {
|
||||
this.refreshMentionChannel(state.channelId);
|
||||
}
|
||||
|
||||
private refreshUnreadEstimate(state: ReadStateEntry): void {
|
||||
private clearUnreadStateIfRead(state: ReadStateEntry): void {
|
||||
if (!state.hasUnread()) {
|
||||
state.estimated = false;
|
||||
state.unreadCount = 0;
|
||||
state.oldestUnreadMessageId = null;
|
||||
return;
|
||||
}
|
||||
if (state.unreadCount === 0) {
|
||||
state.estimated = true;
|
||||
state.unreadCount = Math.max(1, state.mentionCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +411,7 @@ class ReadStates {
|
||||
if (!channelsWithReadState.has(channel.id as ChannelId)) {
|
||||
this.setMentionCount(state, 0);
|
||||
}
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
this.notifyChange(undefined, {global: true});
|
||||
});
|
||||
@@ -436,7 +431,7 @@ class ReadStates {
|
||||
state.lastMessageId = channel.last_message_id ?? null;
|
||||
state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp);
|
||||
state.storedGuildId = action.guild.id;
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
this.refreshMentionChannel(channel.id);
|
||||
}
|
||||
}
|
||||
@@ -452,9 +447,11 @@ class ReadStates {
|
||||
if (newestMessage != null && isNewerMessageId(newestMessage.id, state.lastMessageId)) {
|
||||
state.lastMessageId = newestMessage.id;
|
||||
}
|
||||
if (state.hasUnread()) {
|
||||
const landedOnNewestWindow = messages.hasNewestMessages();
|
||||
const landedOnAck = state.ackMessageId != null && messages.jumpDestinationId === state.ackMessageId;
|
||||
if (state.hasUnread() || landedOnNewestWindow || landedOnAck) {
|
||||
state.rebuild();
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
} else if (action.isAfter && state.ackMessageId != null && messages.has(state.ackMessageId, true)) {
|
||||
state.unreadCount += action.messages.length;
|
||||
if (state.oldestUnreadMessageId == null) {
|
||||
@@ -564,7 +561,7 @@ class ReadStates {
|
||||
state.readStateKnown = true;
|
||||
state.ackMessageId = action.channel.last_message_id;
|
||||
} else if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type) && state.hasUnread()) {
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
this.notifyChange(action.channel.id);
|
||||
}
|
||||
@@ -586,10 +583,10 @@ class ReadStates {
|
||||
changed = state.guildId !== guildId;
|
||||
state.storedGuildId = guildId;
|
||||
}
|
||||
if (isNewerMessageId(lastMessageId, state.lastMessageId)) {
|
||||
if (lastMessageId !== state.lastMessageId) {
|
||||
state.lastMessageId = lastMessageId;
|
||||
changed = true;
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
if (changed) {
|
||||
changedChannels.push(channelId as ChannelId);
|
||||
@@ -751,7 +748,7 @@ class ReadStates {
|
||||
this.setMentionCount(state, mentionCount);
|
||||
}
|
||||
if (decision.shouldRefreshUnreadEstimate) {
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
if (decision.shouldNotify) {
|
||||
this.notifyChange(action.channelId);
|
||||
@@ -1046,7 +1043,7 @@ class ReadStates {
|
||||
state.serverVersion = readState.version ?? state.serverVersion;
|
||||
this.setMentionCount(state, readState.mention_count ?? 0);
|
||||
state.rebuild(null, {recomputeMentions: manual});
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
this.notifyChange(readState.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ export class ReadStateEntry {
|
||||
});
|
||||
const hasUnreadBoundary = foundAckMessage || loadedOlderMessages || !messages.hasMoreBefore;
|
||||
const hasNewestMessages = messages.hasNewestMessages();
|
||||
this.estimated = !hasNewestMessages || !hasUnreadBoundary;
|
||||
this.estimated = !hasNewestMessages || (!hasUnreadBoundary && messages.length === loadedUnreadCount);
|
||||
if (this.estimated) {
|
||||
this.unreadCount = Math.max(previousUnreadCount, loadedUnreadCount);
|
||||
} else {
|
||||
|
||||
@@ -61,10 +61,7 @@ function getChannelRecency(channel: {id: string; lastMessageId: string | null}):
|
||||
}
|
||||
|
||||
function getChannelSortWeight(channelId: string, baseWeight: number): number {
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const hasUnread = unreadCount > 0 || mentionCount > 0;
|
||||
return hasUnread ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
|
||||
return ReadStates.isUnreadOrMentioned(channelId) ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
|
||||
}
|
||||
|
||||
export function buildChannelCandidate(
|
||||
|
||||
@@ -182,7 +182,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
|
||||
() => ({
|
||||
handleMarkAsRead: () => {
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStates.getUnreadCount(channel.id) > 0)
|
||||
.filter((channel) => ReadStates.isUnreadOrMentioned(channel.id))
|
||||
.map((channel) => channel.id);
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateCommands.bulkAckChannels(channelIds);
|
||||
|
||||
@@ -100,7 +100,7 @@ export const MarkAsReadMenuItem: React.FC<GuildMenuItemProps> = observer(({guild
|
||||
}, [channels]);
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStates.getUnreadCount(channel.id) > 0)
|
||||
.filter((channel) => ReadStates.isUnreadOrMentioned(channel.id))
|
||||
.map((channel) => channel.id);
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateCommands.bulkAckChannels(channelIds);
|
||||
|
||||
Reference in New Issue
Block a user