refactor(app): drop the unread jump anchor now the scroller finds it (#1858)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:32:44 +02:00
1 parent af7469fa1f
commit 52781cfa2d
17 files changed
+148 -152

No files matched your search

@@ -4,7 +4,6 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {Guild} from '@app/features/guild/models/Guild'; import type {Guild} from '@app/features/guild/models/Guild';
import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands'; import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands';
import Messages from '@app/features/messaging/state/MessagingMessages'; import Messages from '@app/features/messaging/state/MessagingMessages';
import ReadStates from '@app/features/read_state/state/ReadStates';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {useCallback, useEffect, useRef} from 'react'; import {useCallback, useEffect, useRef} from 'react';
@@ -57,8 +56,7 @@ export function useChannelHoverPreload({
return; return;
} }
if (preloadMessages) { if (preloadMessages) {
const unreadAnchor = ReadStates.getUnreadJumpAnchor(channel.id); if (!Messages.preloadLatestPage(channel.id, guild?.id ?? channel.guildId ?? null)) {
if (!Messages.preloadLatestPage(channel.id, guild?.id ?? channel.guildId ?? null, unreadAnchor)) {
ensureMembersForCachedChannelMessages(channel.id); ensureMembersForCachedChannelMessages(channel.id);
} }
} }
@@ -349,7 +349,7 @@ export function registerDefaultKeybindHandlers(host: HandlerHost, i18n: I18n): v
if (type !== 'press') return; if (type !== 'press') return;
const inboxTab = Inbox.selectedTab; const inboxTab = Inbox.selectedTab;
if (inboxTab === 'unreadChannels') { if (inboxTab === 'unreadChannels') {
const unreadChannelId = ReadStates.getChannelIds().find((channelId) => ReadStates.hasUnreadOrMentions(channelId)); const unreadChannelId = ReadStates.getChannelIds().find((channelId) => ReadStates.isUnreadOrMentioned(channelId));
if (unreadChannelId) { if (unreadChannelId) {
ReadStateCommands.ack(unreadChannelId, true, true); ReadStateCommands.ack(unreadChannelId, true, true);
} }
@@ -102,7 +102,7 @@ interface MessagesStateSnapshot {
editingMessageId: string | null; editingMessageId: string | null;
currentUser: User | undefined; currentUser: User | undefined;
isEstimated: boolean; isEstimated: boolean;
isManualAck: boolean; ackedManually: boolean;
} }
interface MessagesProps { interface MessagesProps {
@@ -132,7 +132,7 @@ const readFromState = (channelId: string): MessagesStateSnapshot => {
editingMessageId: MessageEdit.getEditingMessageId(channelId), editingMessageId: MessageEdit.getEditingMessageId(channelId),
currentUser: Users.currentUser ?? undefined, currentUser: Users.currentUser ?? undefined,
isEstimated: ReadStates.getIfExists(channelId)?.estimated ?? false, isEstimated: ReadStates.getIfExists(channelId)?.estimated ?? false,
isManualAck: ReadStates.getIfExists(channelId)?.isManualAck ?? false, ackedManually: ReadStates.getIfExists(channelId)?.ackedManually ?? false,
}; };
}; };
@@ -185,7 +185,7 @@ export const Messages = observer(function Messages({
windowFocused: isWindowFocused, windowFocused: isWindowFocused,
atBottom: true, atBottom: true,
textChatVisible: true, textChatVisible: true,
manualAck: state.isManualAck, manualAck: state.ackedManually,
blockingModalOpen: isModalOpen || MediaViewer.isOpen, blockingModalOpen: isModalOpen || MediaViewer.isOpen,
}); });
const jumpHighlightTimeoutRef = useRef<number | null>(null); const jumpHighlightTimeoutRef = useRef<number | null>(null);
@@ -498,7 +498,7 @@ export const Messages = observer(function Messages({
useEffect(() => { useEffect(() => {
return () => { return () => {
const readState = ReadStates.getIfExists(channel.id); const readState = ReadStates.getIfExists(channel.id);
if (readState?.isManualAck) { if (readState?.ackedManually) {
ReadStateCommands.clearManualAck(channel.id); ReadStateCommands.clearManualAck(channel.id);
} }
ReadStateCommands.clearStickyUnread(channel.id); ReadStateCommands.clearStickyUnread(channel.id);
@@ -379,7 +379,7 @@ class GuildReadState {
if (!GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { if (!GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
continue; continue;
} }
if (ReadStates.hasUnreadOrMentions(channel.id)) { if (ReadStates.isUnreadOrMentioned(channel.id)) {
return true; return true;
} }
} }
@@ -3,7 +3,6 @@
import Messages from '@app/features/messaging/state/MessagingMessages'; import Messages from '@app/features/messaging/state/MessagingMessages';
import Navigation from '@app/features/navigation/state/Navigation'; import Navigation from '@app/features/navigation/state/Navigation';
import {Logger} from '@app/features/platform/utils/AppLogger'; import {Logger} from '@app/features/platform/utils/AppLogger';
import ReadStates from '@app/features/read_state/state/ReadStates';
import Notification from '@app/features/ui/state/Notification'; import Notification from '@app/features/ui/state/Notification';
import {reaction} from 'mobx'; import {reaction} from 'mobx';
@@ -41,7 +40,6 @@ class NavigationSideEffects {
guildId: guildId ?? undefined, guildId: guildId ?? undefined,
channelId, channelId,
messageId: messageId ?? undefined, messageId: messageId ?? undefined,
unreadAnchor: ReadStates.getUnreadJumpAnchor(channelId),
}); });
Notification.handleChannelSelect({channelId}); Notification.handleChannelSelect({channelId});
} }
@@ -50,7 +50,7 @@ class AutoAck {
} }
const isAtBottom = Dimension.isAtBottom(channelId) ?? false; const isAtBottom = Dimension.isAtBottom(channelId) ?? false;
const readState = ReadStates.getIfExists(channelId); const readState = ReadStates.getIfExists(channelId);
const isManualAck = readState?.isManualAck ?? false; const ackedManually = readState?.ackedManually ?? false;
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
const isGuildVoiceCallExpanded = const isGuildVoiceCallExpanded =
channel?.type === ChannelTypes.GUILD_VOICE channel?.type === ChannelTypes.GUILD_VOICE
@@ -63,7 +63,7 @@ class AutoAck {
activeVoiceCallFullscreenScopeKey: VoiceCallFullscreen.activeScopeKey, activeVoiceCallFullscreenScopeKey: VoiceCallFullscreen.activeScopeKey,
}); });
const isMediaViewerOpen = MediaViewer.isOpen; const isMediaViewerOpen = MediaViewer.isOpen;
const canAutoAck = !isManualAck && isWindowFocused && isTextChatVisible && !isMediaViewerOpen; const canAutoAck = !ackedManually && isWindowFocused && isTextChatVisible && !isMediaViewerOpen;
return {windowId, channelId, isAtBottom, canAutoAck}; return {windowId, channelId, isAtBottom, canAutoAck};
}, },
(conditions) => { (conditions) => {
@@ -50,10 +50,10 @@ import {observable, runInAction} from 'mobx';
export type {GatewayReadState}; export type {GatewayReadState};
const logger = new Logger('ReadStates');
export type {UnreadJumpAnchor}; export type {UnreadJumpAnchor};
const logger = new Logger('ReadStates');
class ReadStates { class ReadStates {
private readonly states = new Map<ChannelId, ReadStateEntry>(); private readonly states = new Map<ChannelId, ReadStateEntry>();
private readonly archivedStates = new Map<ChannelId, ArchivedReadState>(); private readonly archivedStates = new Map<ChannelId, ArchivedReadState>();
@@ -128,7 +128,7 @@ class ReadStates {
private refreshMentionChannel(channelId: string): void { private refreshMentionChannel(channelId: string): void {
const state = this.states.get(channelId as ChannelId); const state = this.states.get(channelId as ChannelId);
if (state != null && state.mentionCount > 0 && state.canHaveMentions()) { if (state != null && state.mentionCount > 0 && state.supportsMentions()) {
this.mentionChannels.add(channelId as ChannelId); this.mentionChannels.add(channelId as ChannelId);
} else { } else {
this.mentionChannels.delete(channelId as ChannelId); this.mentionChannels.delete(channelId as ChannelId);
@@ -243,7 +243,7 @@ class ReadStates {
} }
this.archivedStates.set(channelId as ChannelId, { this.archivedStates.set(channelId as ChannelId, {
ackMessageId: entry.ackMessageId, ackMessageId: entry.ackMessageId,
ackPinTimestamp: entry.ackPinTimestamp, acknowledgedPinTimestamp: entry.acknowledgedPinTimestamp,
readStateKnown: entry.readStateKnown, readStateKnown: entry.readStateKnown,
}); });
} }
@@ -267,7 +267,7 @@ class ReadStates {
const ids: Array<ChannelId> = []; const ids: Array<ChannelId> = [];
for (const channelId of Array.from(this.mentionChannels)) { for (const channelId of Array.from(this.mentionChannels)) {
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
if (state?.canHaveMentions()) { if (state?.supportsMentions()) {
ids.push(channelId); ids.push(channelId);
} else { } else {
this.mentionChannels.delete(channelId); this.mentionChannels.delete(channelId);
@@ -290,7 +290,7 @@ class ReadStates {
getMentionCount(channelId: string): number { getMentionCount(channelId: string): number {
this.versionBox.get(); this.versionBox.get();
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
if (state == null || !state.canHaveMentions()) return 0; if (state == null || !state.supportsMentions()) return 0;
return state.mentionCount; return state.mentionCount;
} }
@@ -321,14 +321,14 @@ class ReadStates {
getPrivateChannelMentionCount(channelId: string): number { getPrivateChannelMentionCount(channelId: string): number {
this.privateChannelVersionBox.get(); this.privateChannelVersionBox.get();
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
if (state == null || !state.canHaveMentions()) return 0; if (state == null || !state.supportsMentions()) return 0;
return state.mentionCount; return state.mentionCount;
} }
hasUnreadOrMentions(channelId: string): boolean { isUnreadOrMentioned(channelId: string): boolean {
this.versionBox.get(); this.versionBox.get();
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
return !!(state?.canBeUnread() && state.hasUnreadOrMentions()); return !!(state?.canBeUnread() && state.isUnreadOrMentioned());
} }
ackMessageId(channelId: string): string | null { ackMessageId(channelId: string): string | null {
@@ -343,12 +343,12 @@ class ReadStates {
getOldestUnreadMessageId(channelId: string): string | null { getOldestUnreadMessageId(channelId: string): string | null {
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
return state?.canTrackUnreads() ? state.oldestUnreadMessageId : null; return state?.supportsUnreadTracking() ? state.oldestUnreadMessageId : null;
} }
getVisualUnreadMessageId(channelId: string): string | null { getVisualUnreadMessageId(channelId: string): string | null {
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
return state?.canTrackUnreads() ? state.visualUnreadMessageId : null; return state?.supportsUnreadTracking() ? state.visualUnreadMessageId : null;
} }
getUnreadJumpAnchor(channelId: string): UnreadJumpAnchor | null { getUnreadJumpAnchor(channelId: string): UnreadJumpAnchor | null {
@@ -358,7 +358,7 @@ class ReadStates {
} }
return resolveUnreadJumpAnchor({ return resolveUnreadJumpAnchor({
canBeUnread: state.canBeUnread(), canBeUnread: state.canBeUnread(),
canTrackUnreads: state.canTrackUnreads(), canTrackUnreads: state.supportsUnreadTracking(),
hasUnread: state.hasUnread(), hasUnread: state.hasUnread(),
oldestUnreadMessageId: state.oldestUnreadMessageId, oldestUnreadMessageId: state.oldestUnreadMessageId,
ackMessageId: state.ackMessageId, ackMessageId: state.ackMessageId,
@@ -395,7 +395,7 @@ class ReadStates {
hasUnreadPins(channelId: string): boolean { hasUnreadPins(channelId: string): boolean {
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
return !!(state?.canBeUnread() && state.lastPinTimestamp > state.ackPinTimestamp); return !!(state?.canBeUnread() && state.lastPinTimestamp > state.acknowledgedPinTimestamp);
} }
ackPins(channelId: string): void { ackPins(channelId: string): void {
@@ -424,7 +424,7 @@ class ReadStates {
state.readStateKnown = true; state.readStateKnown = true;
this.setMentionCount(state, readState.mention_count ?? 0); this.setMentionCount(state, readState.mention_count ?? 0);
state.ackMessageId = readState.last_message_id ?? null; state.ackMessageId = readState.last_message_id ?? null;
state.ackPinTimestamp = parseTimestamp(readState.last_pin_timestamp); state.acknowledgedPinTimestamp = parseTimestamp(readState.last_pin_timestamp);
state.serverVersion = readState.version ?? null; state.serverVersion = readState.version ?? null;
} }
for (const channel of action.channels) { for (const channel of action.channels) {
@@ -432,7 +432,7 @@ class ReadStates {
const state = this.get(channel.id); const state = this.get(channel.id);
state.lastMessageId = channel.last_message_id ?? null; state.lastMessageId = channel.last_message_id ?? null;
state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp); state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp);
state._guildId = channel.guild_id ?? null; state.storedGuildId = channel.guild_id ?? null;
if (!channelsWithReadState.has(channel.id as ChannelId)) { if (!channelsWithReadState.has(channel.id as ChannelId)) {
this.setMentionCount(state, 0); this.setMentionCount(state, 0);
} }
@@ -455,7 +455,7 @@ class ReadStates {
const state = this.get(channel.id); const state = this.get(channel.id);
state.lastMessageId = channel.last_message_id ?? null; state.lastMessageId = channel.last_message_id ?? null;
state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp); state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp);
state._guildId = action.guild.id; state.storedGuildId = action.guild.id;
this.refreshUnreadEstimate(state); this.refreshUnreadEstimate(state);
this.refreshMentionChannel(channel.id); this.refreshMentionChannel(channel.id);
} }
@@ -466,7 +466,7 @@ class ReadStates {
handleLoadMessages(action: {channelId: string; isAfter?: boolean; messages: Array<WireMessage>}): void { handleLoadMessages(action: {channelId: string; isAfter?: boolean; messages: Array<WireMessage>}): void {
const state = this.get(action.channelId); const state = this.get(action.channelId);
state.loadedMessages = true; state.messagesLoaded = true;
const messages = Messages.getMessages(action.channelId); const messages = Messages.getMessages(action.channelId);
const newestMessage = messages.last(); const newestMessage = messages.last();
if (newestMessage != null && isNewerMessageId(newestMessage.id, state.lastMessageId)) { if (newestMessage != null && isNewerMessageId(newestMessage.id, state.lastMessageId)) {
@@ -484,12 +484,12 @@ class ReadStates {
handleIncomingMessage(action: {channelId: string; message: WireMessage}): void { handleIncomingMessage(action: {channelId: string; message: WireMessage}): void {
const state = this.get(action.channelId); const state = this.get(action.channelId);
if (action.message.guild_id != null) { if (action.message.guild_id != null) {
state._guildId = action.message.guild_id; state.storedGuildId = action.message.guild_id;
} }
const previousLastMessageId = state.lastMessageId; const previousLastMessageId = state.lastMessageId;
const currentUser = Users.getCurrentUser(); const currentUser = Users.getCurrentUser();
const authorBlocked = Relationships.isBlocked(action.message.author.id); const authorBlocked = Relationships.isBlocked(action.message.author.id);
const hadUnreadOrMentions = state.hasUnreadOrMentions(); const hadUnreadOrMentions = state.isUnreadOrMentioned();
if (isNewerMessageId(action.message.id, state.lastMessageId)) { if (isNewerMessageId(action.message.id, state.lastMessageId)) {
state.lastMessageId = action.message.id; state.lastMessageId = action.message.id;
} }
@@ -540,7 +540,7 @@ class ReadStates {
state.ackMessageId = previousLastMessageId; state.ackMessageId = previousLastMessageId;
state.readStateKnown = true; state.readStateKnown = true;
} }
if (state.oldestUnreadMessageId == null || state.oldestUnreadMessageIdStale) { if (state.oldestUnreadMessageId == null || state.oldestUnreadNeedsRecompute) {
state.oldestUnreadMessageId = action.message.id; state.oldestUnreadMessageId = action.message.id;
} }
state.unreadCount++; state.unreadCount++;
@@ -563,11 +563,11 @@ class ReadStates {
const state = this.get(action.channel.id); const state = this.get(action.channel.id);
state.lastMessageId = action.channel.last_message_id ?? null; state.lastMessageId = action.channel.last_message_id ?? null;
state.lastPinTimestamp = parseTimestamp(action.channel.last_pin_timestamp); state.lastPinTimestamp = parseTimestamp(action.channel.last_pin_timestamp);
state._guildId = action.channel.guild_id ?? null; state.storedGuildId = action.channel.guild_id ?? null;
const archivedState = this.archivedStates.get(action.channel.id as ChannelId); const archivedState = this.archivedStates.get(action.channel.id as ChannelId);
if (archivedState != null) { if (archivedState != null) {
state.ackMessageId = archivedState.ackMessageId; state.ackMessageId = archivedState.ackMessageId;
state.ackPinTimestamp = archivedState.ackPinTimestamp; state.acknowledgedPinTimestamp = archivedState.acknowledgedPinTimestamp;
state.readStateKnown = archivedState.readStateKnown; state.readStateKnown = archivedState.readStateKnown;
this.archivedStates.delete(action.channel.id as ChannelId); this.archivedStates.delete(action.channel.id as ChannelId);
} }
@@ -600,7 +600,7 @@ class ReadStates {
let changed = false; let changed = false;
if (guildId != null) { if (guildId != null) {
changed = state.guildId !== guildId; changed = state.guildId !== guildId;
state._guildId = guildId; state.storedGuildId = guildId;
} }
if (isNewerMessageId(lastMessageId, state.lastMessageId)) { if (isNewerMessageId(lastMessageId, state.lastMessageId)) {
state.lastMessageId = lastMessageId; state.lastMessageId = lastMessageId;
@@ -746,7 +746,7 @@ class ReadStates {
case 'applyManualAck': case 'applyManualAck':
state.readStateKnown = true; state.readStateKnown = true;
state.clearStickyUnread(); state.clearStickyUnread();
state.isManualAck = true; state.ackedManually = true;
state.rebuild(action.messageId, {recomputeMentions: true}); state.rebuild(action.messageId, {recomputeMentions: true});
state.serverVersion = action.version ?? state.serverVersion; state.serverVersion = action.version ?? state.serverVersion;
this.cancelPendingAck(action.channelId); this.cancelPendingAck(action.channelId);
@@ -793,15 +793,15 @@ class ReadStates {
handleClearManualAck(action: {channelId: string}): void { handleClearManualAck(action: {channelId: string}): void {
const state = this.get(action.channelId); const state = this.get(action.channelId);
if (state.isManualAck) { if (state.ackedManually) {
state.isManualAck = false; state.ackedManually = false;
this.notifyChange(action.channelId); this.notifyChange(action.channelId);
} }
} }
handleRelationshipUpdate(): void { handleRelationshipUpdate(): void {
for (const state of this.states.values()) { for (const state of this.states.values()) {
if (state.hasUnreadOrMentions()) { if (state.isUnreadOrMentioned()) {
state.rebuild(undefined, {recomputeMentions: true}); state.rebuild(undefined, {recomputeMentions: true});
} }
} }
@@ -832,9 +832,9 @@ class ReadStates {
requestedMessageId: messageId, requestedMessageId: messageId,
lastMessageId: state.lastMessageId, lastMessageId: state.lastMessageId,
ackMessageId: state.ackMessageId, ackMessageId: state.ackMessageId,
isManualAck: state.isManualAck, ackedManually: state.ackedManually,
loadedMessages: state.loadedMessages, messagesLoaded: state.messagesLoaded,
canTrackUnreads: state.canTrackUnreads(), supportsUnreadTracking: state.supportsUnreadTracking(),
hasMentions: state.hasMentions(), hasMentions: state.hasMentions(),
hasOldestUnreadMessage: state.oldestUnreadMessageId != null, hasOldestUnreadMessage: state.oldestUnreadMessageId != null,
hasStickyUnreadMessage: state.stickyUnreadMessageId != null, hasStickyUnreadMessage: state.stickyUnreadMessageId != null,
@@ -856,7 +856,7 @@ class ReadStates {
state.ackMessageId = decision.messageId; state.ackMessageId = decision.messageId;
state.oldestUnreadMessageId = null; state.oldestUnreadMessageId = null;
if (decision.shouldClearManualAck) { if (decision.shouldClearManualAck) {
state.isManualAck = false; state.ackedManually = false;
state.clearStickyUnread(); state.clearStickyUnread();
} }
this.cancelPendingAckIfCovered(state.channelId, decision.messageId); this.cancelPendingAckIfCovered(state.channelId, decision.messageId);
@@ -866,10 +866,10 @@ class ReadStates {
private applyPinAck(state: ReadStateEntry, timestamp?: string | null): boolean { private applyPinAck(state: ReadStateEntry, timestamp?: string | null): boolean {
const newTimestamp = timestamp == null ? state.lastPinTimestamp : parseTimestamp(timestamp); const newTimestamp = timestamp == null ? state.lastPinTimestamp : parseTimestamp(timestamp);
const ackTimestamp = newTimestamp !== 0 ? newTimestamp : state.lastPinTimestamp; const ackTimestamp = newTimestamp !== 0 ? newTimestamp : state.lastPinTimestamp;
if (state.ackPinTimestamp === ackTimestamp) { if (state.acknowledgedPinTimestamp === ackTimestamp) {
return false; return false;
} }
state.ackPinTimestamp = ackTimestamp; state.acknowledgedPinTimestamp = ackTimestamp;
return true; return true;
} }
@@ -895,7 +895,7 @@ class ReadStates {
this.pendingAcks.set(channelId as ChannelId, pending); this.pendingAcks.set(channelId as ChannelId, pending);
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
if (state != null) { if (state != null) {
state.outgoingAck = pending.messageId; state.inFlightAckMessageId = pending.messageId;
} }
this.scheduleAckFlush(); this.scheduleAckFlush();
} }
@@ -938,7 +938,7 @@ class ReadStates {
const deleted = this.pendingAcks.delete(channelId as ChannelId); const deleted = this.pendingAcks.delete(channelId as ChannelId);
const state = this.getIfExists(channelId); const state = this.getIfExists(channelId);
if (state != null) { if (state != null) {
state.outgoingAck = null; state.inFlightAckMessageId = null;
} }
if (deleted) { if (deleted) {
this.scheduleAckFlush(); this.scheduleAckFlush();
@@ -988,8 +988,8 @@ class ReadStates {
if (current?.messageId === entry.messageId) { if (current?.messageId === entry.messageId) {
this.pendingAcks.delete(entry.channelId as ChannelId); this.pendingAcks.delete(entry.channelId as ChannelId);
const state = this.getIfExists(entry.channelId); const state = this.getIfExists(entry.channelId);
if (state?.outgoingAck === entry.messageId) { if (state?.inFlightAckMessageId === entry.messageId) {
state.outgoingAck = null; state.inFlightAckMessageId = null;
} }
} }
} }
@@ -20,9 +20,9 @@ function input(overrides: Partial<ReadStateAckInput> = {}): ReadStateAckInput {
requestedMessageId: NEWER_ID, requestedMessageId: NEWER_ID,
lastMessageId: NEWER_ID, lastMessageId: NEWER_ID,
ackMessageId: ACK_ID, ackMessageId: ACK_ID,
isManualAck: false, ackedManually: false,
loadedMessages: true, messagesLoaded: true,
canTrackUnreads: true, supportsUnreadTracking: true,
hasMentions: false, hasMentions: false,
hasOldestUnreadMessage: false, hasOldestUnreadMessage: false,
hasStickyUnreadMessage: false, hasStickyUnreadMessage: false,
@@ -53,30 +53,30 @@ describe('readStateAckMachine', () => {
}); });
it('blocks automatic ack while manual ack is held, messages are unloaded, or the channel is untracked', () => { it('blocks automatic ack while manual ack is held, messages are unloaded, or the channel is untracked', () => {
expect(resolveReadStateAckDecision(input({isManualAck: true}))).toEqual({ expect(resolveReadStateAckDecision(input({ackedManually: true}))).toEqual({
type: 'ignored', type: 'ignored',
reason: 'manualAck', reason: 'manualAck',
}); });
expect(resolveReadStateAckDecision(input({loadedMessages: false}))).toEqual({ expect(resolveReadStateAckDecision(input({messagesLoaded: false}))).toEqual({
type: 'ignored', type: 'ignored',
reason: 'notLoaded', reason: 'notLoaded',
}); });
expect(resolveReadStateAckDecision(input({canTrackUnreads: false}))).toEqual({ expect(resolveReadStateAckDecision(input({supportsUnreadTracking: false}))).toEqual({
type: 'ignored', type: 'ignored',
reason: 'untracked', reason: 'untracked',
}); });
}); });
it('lets local, forced, and explicit user acknowledgements override automatic ack guards', () => { it('lets local, forced, and explicit user acknowledgements override automatic ack guards', () => {
expect(resolveReadStateAckDecision(input({isManualAck: true, isExplicitUserAction: true}))).toMatchObject({ expect(resolveReadStateAckDecision(input({ackedManually: true, isExplicitUserAction: true}))).toMatchObject({
type: 'ack', type: 'ack',
shouldClearManualAck: true, shouldClearManualAck: true,
}); });
expect(resolveReadStateAckDecision(input({loadedMessages: false, local: true}))).toMatchObject({ expect(resolveReadStateAckDecision(input({messagesLoaded: false, local: true}))).toMatchObject({
type: 'ack', type: 'ack',
shouldClearManualAck: true, shouldClearManualAck: true,
}); });
expect(resolveReadStateAckDecision(input({canTrackUnreads: false, force: true}))).toMatchObject({ expect(resolveReadStateAckDecision(input({supportsUnreadTracking: false, force: true}))).toMatchObject({
type: 'ack', type: 'ack',
shouldClearManualAck: true, shouldClearManualAck: true,
}); });
@@ -125,12 +125,12 @@ describe('readStateAckMachine', () => {
}); });
it('updates the decision from later ack input', () => { it('updates the decision from later ack input', () => {
const ignoredSnapshot = createReadStateAckSnapshot(input({isManualAck: true})); const ignoredSnapshot = createReadStateAckSnapshot(input({ackedManually: true}));
expect(selectReadStateAckDecision(ignoredSnapshot)).toEqual({type: 'ignored', reason: 'manualAck'}); expect(selectReadStateAckDecision(ignoredSnapshot)).toEqual({type: 'ignored', reason: 'manualAck'});
const ackSnapshot = transitionReadStateAckSnapshot(ignoredSnapshot, { const ackSnapshot = transitionReadStateAckSnapshot(ignoredSnapshot, {
type: 'readStateAck.updated', type: 'readStateAck.updated',
input: input({isManualAck: false}), input: input({ackedManually: false}),
}); });
expect(selectReadStateAckDecision(ackSnapshot)).toMatchObject({ expect(selectReadStateAckDecision(ackSnapshot)).toMatchObject({
@@ -7,9 +7,9 @@ export interface ReadStateAckInput {
requestedMessageId?: string | null; requestedMessageId?: string | null;
lastMessageId: string | null; lastMessageId: string | null;
ackMessageId: string | null; ackMessageId: string | null;
isManualAck: boolean; ackedManually: boolean;
loadedMessages: boolean; messagesLoaded: boolean;
canTrackUnreads: boolean; supportsUnreadTracking: boolean;
hasMentions: boolean; hasMentions: boolean;
hasOldestUnreadMessage: boolean; hasOldestUnreadMessage: boolean;
hasStickyUnreadMessage: boolean; hasStickyUnreadMessage: boolean;
@@ -80,9 +80,9 @@ export const readStateAckMachine = setup({
}), }),
}, },
guards: { guards: {
isManualAckHeld: ({context}) => !isOverrideAck(context) && context.isManualAck, isManualAckHeld: ({context}) => !isOverrideAck(context) && context.ackedManually,
isNotLoaded: ({context}) => !isOverrideAck(context) && !context.loadedMessages, isNotLoaded: ({context}) => !isOverrideAck(context) && !context.messagesLoaded,
isUntracked: ({context}) => !isOverrideAck(context) && !context.canTrackUnreads, isUntracked: ({context}) => !isOverrideAck(context) && !context.supportsUnreadTracking,
isMissingMessage: ({context}) => getFinalMessageId(context) == null, isMissingMessage: ({context}) => getFinalMessageId(context) == null,
isOlderThanCurrentAck: ({context}) => { isOlderThanCurrentAck: ({context}) => {
const finalMessageId = getFinalMessageId(context); const finalMessageId = getFinalMessageId(context);
@@ -15,23 +15,23 @@ import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/Me
export class ReadStateEntry { export class ReadStateEntry {
readonly channelId: string; readonly channelId: string;
_guildId: string | null = null; storedGuildId: string | null = null;
loadedMessages = false; messagesLoaded = false;
readStateKnown = false; readStateKnown = false;
private _lastMessageId: string | null = null; private storedLastMessageId: string | null = null;
private _lastMessageTimestamp = 0; private storedLastMessageTimestamp = 0;
private _ackMessageId: string | null = null; private storedAckMessageId: string | null = null;
private _ackMessageTimestamp = 0; private storedAckMessageTimestamp = 0;
ackPinTimestamp = 0; acknowledgedPinTimestamp = 0;
lastPinTimestamp = 0; lastPinTimestamp = 0;
isManualAck = false; ackedManually = false;
private _oldestUnreadMessageId: string | null = null; private storedOldestUnreadMessageId: string | null = null;
oldestUnreadMessageIdStale = false; oldestUnreadNeedsRecompute = false;
private _stickyUnreadMessageId: string | null = null; private storedStickyUnreadMessageId: string | null = null;
estimated = false; estimated = false;
private _unreadCount = 0; private storedUnreadCount = 0;
private _mentionCount = 0; private storedMentionCount = 0;
outgoingAck: string | null = null; inFlightAckMessageId: string | null = null;
serverVersion: string | null = null; serverVersion: string | null = null;
snapshot?: { snapshot?: {
unread: boolean; unread: boolean;
@@ -47,81 +47,81 @@ export class ReadStateEntry {
get guildId(): string | null { get guildId(): string | null {
const channel = Channels.getChannel(this.channelId); const channel = Channels.getChannel(this.channelId);
return channel?.guildId ?? this._guildId ?? null; return channel?.guildId ?? this.storedGuildId ?? null;
} }
get lastMessageId(): string | null { get lastMessageId(): string | null {
return this._lastMessageId; return this.storedLastMessageId;
} }
set lastMessageId(messageId: string | null) { set lastMessageId(messageId: string | null) {
this._lastMessageId = messageId; this.storedLastMessageId = messageId;
this._lastMessageTimestamp = snowflakeTimestamp(messageId); this.storedLastMessageTimestamp = snowflakeTimestamp(messageId);
} }
get lastMessageTimestamp(): number { get lastMessageTimestamp(): number {
return this._lastMessageTimestamp; return this.storedLastMessageTimestamp;
} }
get ackMessageId(): string | null { get ackMessageId(): string | null {
return this._ackMessageId; return this.storedAckMessageId;
} }
set ackMessageId(messageId: string | null) { set ackMessageId(messageId: string | null) {
this._ackMessageId = messageId; this.storedAckMessageId = messageId;
this._ackMessageTimestamp = snowflakeTimestamp(messageId); this.storedAckMessageTimestamp = snowflakeTimestamp(messageId);
} }
get oldestUnreadMessageId(): string | null { get oldestUnreadMessageId(): string | null {
return this._oldestUnreadMessageId; return this.storedOldestUnreadMessageId;
} }
set oldestUnreadMessageId(messageId: string | null) { set oldestUnreadMessageId(messageId: string | null) {
this._oldestUnreadMessageId = messageId; this.storedOldestUnreadMessageId = messageId;
this.oldestUnreadMessageIdStale = false; this.oldestUnreadNeedsRecompute = false;
} }
get stickyUnreadMessageId(): string | null { get stickyUnreadMessageId(): string | null {
return this._stickyUnreadMessageId; return this.storedStickyUnreadMessageId;
} }
set stickyUnreadMessageId(messageId: string | null) { set stickyUnreadMessageId(messageId: string | null) {
this._stickyUnreadMessageId = messageId; this.storedStickyUnreadMessageId = messageId;
} }
get visualUnreadMessageId(): string | null { get visualUnreadMessageId(): string | null {
return this._stickyUnreadMessageId ?? this._oldestUnreadMessageId; return this.storedStickyUnreadMessageId ?? this.storedOldestUnreadMessageId;
} }
clearStickyUnread(): void { clearStickyUnread(): void {
this._stickyUnreadMessageId = null; this.storedStickyUnreadMessageId = null;
} }
get unreadCount(): number { get unreadCount(): number {
return this._unreadCount; return this.storedUnreadCount;
} }
set unreadCount(count: number) { set unreadCount(count: number) {
this._unreadCount = normalizeCount(count); this.storedUnreadCount = normalizeCount(count);
} }
get mentionCount(): number { get mentionCount(): number {
return this._mentionCount; return this.storedMentionCount;
} }
set mentionCount(count: number) { set mentionCount(count: number) {
this._mentionCount = normalizeCount(count); this.storedMentionCount = normalizeCount(count);
} }
get oldestUnreadTimestamp(): number { get oldestUnreadMessageTimestamp(): number {
return snowflakeTimestamp(this.oldestUnreadMessageId); return snowflakeTimestamp(this.oldestUnreadMessageId);
} }
get ackTimestamp(): number { get ackTimestamp(): number {
if (Number.isNaN(this._ackMessageTimestamp)) { if (Number.isNaN(this.storedAckMessageTimestamp)) {
return 0; return 0;
} }
return this._ackMessageTimestamp; return this.storedAckMessageTimestamp;
} }
get isPrivate(): boolean { get isPrivate(): boolean {
@@ -129,17 +129,17 @@ export class ReadStateEntry {
return channel?.isPrivate() ?? false; return channel?.isPrivate() ?? false;
} }
canTrackUnreads(): boolean { supportsUnreadTracking(): boolean {
return Channels.getChannel(this.channelId) != null || this._guildId != null; return Channels.getChannel(this.channelId) != null || this.storedGuildId != null;
} }
private get statusModel() { private get statusModel() {
return resolveReadStateEntryStatus({ return resolveReadStateEntryStatus({
canTrackUnreads: this.canTrackUnreads(), supportsUnreadTracking: this.supportsUnreadTracking(),
hasBlockedDirectMessageRecipient: this.hasBlockedDirectMessageRecipient(), hasBlockedDirectMessageRecipient: this.hasBlockedDirectMessageRecipient(),
readStateKnown: this.readStateKnown, readStateKnown: this.readStateKnown,
lastMessageId: this._lastMessageId, lastMessageId: this.storedLastMessageId,
ackMessageId: this._ackMessageId, ackMessageId: this.storedAckMessageId,
mentionCount: this.mentionCount, mentionCount: this.mentionCount,
}); });
} }
@@ -148,8 +148,8 @@ export class ReadStateEntry {
return this.statusModel.canBeUnread; return this.statusModel.canBeUnread;
} }
canHaveMentions(): boolean { supportsMentions(): boolean {
return this.statusModel.canHaveMentions; return this.statusModel.supportsMentions;
} }
hasUnread(): boolean { hasUnread(): boolean {
@@ -169,11 +169,11 @@ export class ReadStateEntry {
return this.mentionCount > 0; return this.mentionCount > 0;
} }
hasUnreadOrMentions(): boolean { isUnreadOrMentioned(): boolean {
return this.statusModel.hasUnreadOrMentions; return this.statusModel.isUnreadOrMentioned;
} }
getGuildChannelUnreadState( computeGuildChannelBadge(
channel: { channel: {
isPrivate(): boolean; isPrivate(): boolean;
guildId?: string; guildId?: string;
@@ -185,10 +185,10 @@ export class ReadStateEntry {
mentionCount: number; mentionCount: number;
unread: boolean; unread: boolean;
} { } {
if (!channel.isPrivate() && !this.canTrackUnreads()) { if (!channel.isPrivate() && !this.supportsUnreadTracking()) {
return {mentionCount: 0, unread: false}; return {mentionCount: 0, unread: false};
} }
const mentionCount = this.canHaveMentions() ? this.mentionCount : 0; const mentionCount = this.supportsMentions() ? this.mentionCount : 0;
if (isChannelMuted || isGuildMuted) { if (isChannelMuted || isGuildMuted) {
return {mentionCount, unread: false}; return {mentionCount, unread: false};
} }
@@ -206,12 +206,12 @@ export class ReadStateEntry {
recomputeMentions?: boolean; recomputeMentions?: boolean;
} = {}, } = {},
): void { ): void {
const previousUnreadCount = this._unreadCount; const previousUnreadCount = this.storedUnreadCount;
if (ackMessageId !== undefined) { if (ackMessageId !== undefined) {
this.ackMessageId = ackMessageId; this.ackMessageId = ackMessageId;
this.readStateKnown = true; this.readStateKnown = true;
} else { } else {
this.ackMessageId = this._ackMessageId; this.ackMessageId = this.storedAckMessageId;
} }
this.oldestUnreadMessageId = null; this.oldestUnreadMessageId = null;
this.estimated = false; this.estimated = false;
@@ -242,11 +242,11 @@ export class ReadStateEntry {
let loadedUnreadCount = 0; let loadedUnreadCount = 0;
messages.forAll((message) => { messages.forAll((message) => {
if (!foundAckMessage) { if (!foundAckMessage) {
foundAckMessage = message.id === this._ackMessageId; foundAckMessage = message.id === this.storedAckMessageId;
} else if (this._oldestUnreadMessageId == null) { } else if (this.storedOldestUnreadMessageId == null) {
this._oldestUnreadMessageId = message.id; this.storedOldestUnreadMessageId = message.id;
} }
if (compareMessageIds(message.id, this._ackMessageId) > 0) { if (compareMessageIds(message.id, this.storedAckMessageId) > 0) {
loadedUnreadCount++; loadedUnreadCount++;
if (recomputeMentions && !Relationships.isBlocked(message.author.id)) { if (recomputeMentions && !Relationships.isBlocked(message.author.id)) {
const mentions = message.mentions; const mentions = message.mentions;
@@ -280,7 +280,7 @@ export class ReadStateEntry {
} else { } else {
this.unreadCount = loadedUnreadCount; this.unreadCount = loadedUnreadCount;
} }
this.oldestUnreadMessageId = hasUnreadBoundary ? (this._oldestUnreadMessageId ?? oldestUnread) : null; this.oldestUnreadMessageId = hasUnreadBoundary ? (this.storedOldestUnreadMessageId ?? oldestUnread) : null;
} }
shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean { shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean {
@@ -12,7 +12,7 @@ const INPUTS = Object.freeze(
switch (index % 6) { switch (index % 6) {
case 0: case 0:
return { return {
canTrackUnreads: false, supportsUnreadTracking: false,
hasBlockedDirectMessageRecipient: false, hasBlockedDirectMessageRecipient: false,
readStateKnown: true, readStateKnown: true,
lastMessageId: LAST_ID, lastMessageId: LAST_ID,
@@ -21,7 +21,7 @@ const INPUTS = Object.freeze(
}; };
case 1: case 1:
return { return {
canTrackUnreads: true, supportsUnreadTracking: true,
hasBlockedDirectMessageRecipient: true, hasBlockedDirectMessageRecipient: true,
readStateKnown: true, readStateKnown: true,
lastMessageId: LAST_ID, lastMessageId: LAST_ID,
@@ -30,7 +30,7 @@ const INPUTS = Object.freeze(
}; };
case 2: case 2:
return { return {
canTrackUnreads: true, supportsUnreadTracking: true,
hasBlockedDirectMessageRecipient: false, hasBlockedDirectMessageRecipient: false,
readStateKnown: false, readStateKnown: false,
lastMessageId: LAST_ID, lastMessageId: LAST_ID,
@@ -39,7 +39,7 @@ const INPUTS = Object.freeze(
}; };
case 3: case 3:
return { return {
canTrackUnreads: true, supportsUnreadTracking: true,
hasBlockedDirectMessageRecipient: false, hasBlockedDirectMessageRecipient: false,
readStateKnown: true, readStateKnown: true,
lastMessageId: null, lastMessageId: null,
@@ -48,7 +48,7 @@ const INPUTS = Object.freeze(
}; };
case 4: case 4:
return { return {
canTrackUnreads: true, supportsUnreadTracking: true,
hasBlockedDirectMessageRecipient: false, hasBlockedDirectMessageRecipient: false,
readStateKnown: true, readStateKnown: true,
lastMessageId: LAST_ID, lastMessageId: LAST_ID,
@@ -57,7 +57,7 @@ const INPUTS = Object.freeze(
}; };
default: default:
return { return {
canTrackUnreads: true, supportsUnreadTracking: true,
hasBlockedDirectMessageRecipient: false, hasBlockedDirectMessageRecipient: false,
readStateKnown: true, readStateKnown: true,
lastMessageId: LAST_ID, lastMessageId: LAST_ID,
@@ -72,7 +72,7 @@ describe('ReadStateEntryStatusMachine benchmarks', () => {
bench('resolves 100k mixed read-state entry statuses', () => { bench('resolves 100k mixed read-state entry statuses', () => {
let unreadOrMentionCount = 0; let unreadOrMentionCount = 0;
for (const input of INPUTS) { for (const input of INPUTS) {
if (resolveReadStateEntryStatus(input).hasUnreadOrMentions) { if (resolveReadStateEntryStatus(input).isUnreadOrMentioned) {
unreadOrMentionCount++; unreadOrMentionCount++;
} }
} }
@@ -17,7 +17,7 @@ const LAST_ID = fromTimestamp(BASE_TIMESTAMP + 2000);
function input(overrides: Partial<ReadStateEntryStatusInput> = {}): ReadStateEntryStatusInput { function input(overrides: Partial<ReadStateEntryStatusInput> = {}): ReadStateEntryStatusInput {
return { return {
canTrackUnreads: true, supportsUnreadTracking: true,
hasBlockedDirectMessageRecipient: false, hasBlockedDirectMessageRecipient: false,
readStateKnown: true, readStateKnown: true,
lastMessageId: LAST_ID, lastMessageId: LAST_ID,
@@ -33,7 +33,7 @@ function expectResolvedState(overrides: Partial<ReadStateEntryStatusInput>, expe
describe('readStateEntryStatusMachine', () => { describe('readStateEntryStatusMachine', () => {
it('routes the read-state status by priority', () => { it('routes the read-state status by priority', () => {
expectResolvedState({canTrackUnreads: false, mentionCount: 1, ackMessageId: ACK_ID}, 'untracked'); expectResolvedState({supportsUnreadTracking: false, mentionCount: 1, ackMessageId: ACK_ID}, 'untracked');
expectResolvedState({hasBlockedDirectMessageRecipient: true, mentionCount: 1, ackMessageId: ACK_ID}, 'blocked'); expectResolvedState({hasBlockedDirectMessageRecipient: true, mentionCount: 1, ackMessageId: ACK_ID}, 'blocked');
expectResolvedState({readStateKnown: false, ackMessageId: ACK_ID}, 'unknown'); expectResolvedState({readStateKnown: false, ackMessageId: ACK_ID}, 'unknown');
expectResolvedState({lastMessageId: null, ackMessageId: ACK_ID}, 'unknown'); expectResolvedState({lastMessageId: null, ackMessageId: ACK_ID}, 'unknown');
@@ -45,28 +45,28 @@ describe('readStateEntryStatusMachine', () => {
expect(resolveReadStateEntryStatus(input({ackMessageId: ACK_ID, mentionCount: 2}))).toMatchObject({ expect(resolveReadStateEntryStatus(input({ackMessageId: ACK_ID, mentionCount: 2}))).toMatchObject({
state: 'unread', state: 'unread',
canBeUnread: true, canBeUnread: true,
canHaveMentions: true, supportsMentions: true,
hasUnread: true, hasUnread: true,
hasMentions: true, hasMentions: true,
hasUnreadOrMentions: true, isUnreadOrMentioned: true,
}); });
expect(resolveReadStateEntryStatus(input({hasBlockedDirectMessageRecipient: true, mentionCount: 2}))).toMatchObject( expect(resolveReadStateEntryStatus(input({hasBlockedDirectMessageRecipient: true, mentionCount: 2}))).toMatchObject(
{ {
state: 'blocked', state: 'blocked',
canBeUnread: true, canBeUnread: true,
canHaveMentions: false, supportsMentions: false,
hasUnread: false, hasUnread: false,
hasMentions: true, hasMentions: true,
hasUnreadOrMentions: false, isUnreadOrMentioned: false,
}, },
); );
expect(resolveReadStateEntryStatus(input({canTrackUnreads: false, mentionCount: 2}))).toMatchObject({ expect(resolveReadStateEntryStatus(input({supportsUnreadTracking: false, mentionCount: 2}))).toMatchObject({
state: 'untracked', state: 'untracked',
canBeUnread: false, canBeUnread: false,
canHaveMentions: false, supportsMentions: false,
hasUnread: false, hasUnread: false,
hasMentions: true, hasMentions: true,
hasUnreadOrMentions: false, isUnreadOrMentioned: false,
}); });
}); });
@@ -81,7 +81,7 @@ describe('readStateEntryStatusMachine', () => {
expect(selectReadStateEntryStatusModel(unreadSnapshot)).toMatchObject({ expect(selectReadStateEntryStatusModel(unreadSnapshot)).toMatchObject({
state: 'unread', state: 'unread',
hasUnreadOrMentions: true, isUnreadOrMentioned: true,
}); });
}); });
}); });
@@ -4,7 +4,7 @@ import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from '
import {compareMessageIds} from './shared'; import {compareMessageIds} from './shared';
export interface ReadStateEntryStatusInput { export interface ReadStateEntryStatusInput {
canTrackUnreads: boolean; supportsUnreadTracking: boolean;
hasBlockedDirectMessageRecipient: boolean; hasBlockedDirectMessageRecipient: boolean;
readStateKnown: boolean; readStateKnown: boolean;
lastMessageId: string | null; lastMessageId: string | null;
@@ -22,10 +22,10 @@ export type ReadStateEntryStatusValue = 'untracked' | 'blocked' | 'unknown' | 'r
export interface ReadStateEntryStatusModel { export interface ReadStateEntryStatusModel {
state: ReadStateEntryStatusValue; state: ReadStateEntryStatusValue;
canBeUnread: boolean; canBeUnread: boolean;
canHaveMentions: boolean; supportsMentions: boolean;
hasUnread: boolean; hasUnread: boolean;
hasMentions: boolean; hasMentions: boolean;
hasUnreadOrMentions: boolean; isUnreadOrMentioned: boolean;
} }
function getStatusValue(snapshot: ReadStateEntryStatusSnapshot): ReadStateEntryStatusValue { function getStatusValue(snapshot: ReadStateEntryStatusSnapshot): ReadStateEntryStatusValue {
@@ -49,7 +49,7 @@ function isUnread(context: ReadStateEntryStatusInput): boolean {
} }
function getStatusValueFromInput(input: ReadStateEntryStatusInput): ReadStateEntryStatusValue { function getStatusValueFromInput(input: ReadStateEntryStatusInput): ReadStateEntryStatusValue {
if (!input.canTrackUnreads) return 'untracked'; if (!input.supportsUnreadTracking) return 'untracked';
if (input.hasBlockedDirectMessageRecipient) return 'blocked'; if (input.hasBlockedDirectMessageRecipient) return 'blocked';
if (!input.readStateKnown || input.lastMessageId == null) return 'unknown'; if (!input.readStateKnown || input.lastMessageId == null) return 'unknown';
if (isUnread(input)) return 'unread'; if (isUnread(input)) return 'unread';
@@ -62,15 +62,15 @@ function buildStatusModel(
): ReadStateEntryStatusModel { ): ReadStateEntryStatusModel {
const hasMentions = input.mentionCount > 0; const hasMentions = input.mentionCount > 0;
const canBeUnread = state !== 'untracked'; const canBeUnread = state !== 'untracked';
const canHaveMentions = hasMentions && state !== 'untracked' && state !== 'blocked'; const supportsMentions = hasMentions && state !== 'untracked' && state !== 'blocked';
const hasUnread = state === 'unread'; const hasUnread = state === 'unread';
return { return {
state, state,
canBeUnread, canBeUnread,
canHaveMentions, supportsMentions,
hasUnread, hasUnread,
hasMentions, hasMentions,
hasUnreadOrMentions: hasUnread || canHaveMentions, isUnreadOrMentioned: hasUnread || supportsMentions,
}; };
} }
@@ -87,7 +87,7 @@ export const readStateEntryStatusMachine = setup({
}), }),
}, },
guards: { guards: {
isUntracked: ({context}) => !context.canTrackUnreads, isUntracked: ({context}) => !context.supportsUnreadTracking,
isBlocked: ({context}) => context.hasBlockedDirectMessageRecipient, isBlocked: ({context}) => context.hasBlockedDirectMessageRecipient,
isUnknown: ({context}) => !context.readStateKnown || context.lastMessageId == null, isUnknown: ({context}) => !context.readStateKnown || context.lastMessageId == null,
isUnread: ({context}) => isUnread(context), isUnread: ({context}) => isUnread(context),
@@ -68,7 +68,7 @@ export const readStateServerAckMachine = setup({
}, },
guards: { guards: {
hasStaleVersion: ({context}) => hasStaleVersion(context), hasStaleVersion: ({context}) => hasStaleVersion(context),
isManualAck: ({context}) => context.manual, ackedManually: ({context}) => context.manual,
isOlderThanCurrentAck: ({context}) => isOlderThanCurrentAck(context), isOlderThanCurrentAck: ({context}) => isOlderThanCurrentAck(context),
isCurrentAck: ({context}) => isCurrentAck(context), isCurrentAck: ({context}) => isCurrentAck(context),
}, },
@@ -80,7 +80,7 @@ export const readStateServerAckMachine = setup({
routing: { routing: {
always: [ always: [
{guard: 'hasStaleVersion', target: 'staleVersion'}, {guard: 'hasStaleVersion', target: 'staleVersion'},
{guard: 'isManualAck', target: 'manualAck'}, {guard: 'ackedManually', target: 'manualAck'},
{guard: 'isOlderThanCurrentAck', target: 'olderMessage'}, {guard: 'isOlderThanCurrentAck', target: 'olderMessage'},
{guard: 'isCurrentAck', target: 'currentAck'}, {guard: 'isCurrentAck', target: 'currentAck'},
{target: 'newerAck'}, {target: 'newerAck'},
@@ -64,7 +64,7 @@ export interface PendingAck {
export interface ArchivedReadState { export interface ArchivedReadState {
ackMessageId: string | null; ackMessageId: string | null;
ackPinTimestamp: number; acknowledgedPinTimestamp: number;
readStateKnown: boolean; readStateKnown: boolean;
} }
@@ -123,7 +123,7 @@ function generateUnreadResults(
if (excludedIds.has(channelId) || additionalExcludedChannelIds.has(channelId)) { if (excludedIds.has(channelId) || additionalExcludedChannelIds.has(channelId)) {
return false; return false;
} }
return ReadStates.hasUnreadOrMentions(channelId); return ReadStates.isUnreadOrMentioned(channelId);
}) })
.map((channelId) => Channels.getChannel(channelId)) .map((channelId) => Channels.getChannel(channelId))
.filter((channel): channel is Channel => channel != null) .filter((channel): channel is Channel => channel != null)
@@ -59,7 +59,7 @@ export const FluxerButtonContextMenu: React.FC<FluxerButtonContextMenuProps> = o
const unreadGuildChannelIds: Array<string> = []; const unreadGuildChannelIds: Array<string> = [];
for (const guild of guilds) { for (const guild of guilds) {
for (const channel of Channels.getGuildChannels(guild.id)) { for (const channel of Channels.getGuildChannels(guild.id)) {
if (ReadStates.hasUnreadOrMentions(channel.id)) { if (ReadStates.isUnreadOrMentioned(channel.id)) {
unreadGuildChannelIds.push(channel.id); unreadGuildChannelIds.push(channel.id);
} }
} }