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