fix(api): distinguish a deleted reply target from no reply (#1884)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:39:11 +02:00
1 parent 7b646f891e
commit 735ecc1cca
8 files changed
+250 -181

No files matched your search

@@ -57,7 +57,7 @@ export const ReplyPreview = observer(
messageDisplayCompact: boolean;
}) => {
const {i18n} = useLingui();
const {message: referencedMessage, state: messageState} = MessageReferences.getMessageReference(
const resolution = MessageReferences.getMessageReference(
message.messageReference?.channel_id ?? '',
message.messageReference?.message_id ?? '',
);
@@ -72,12 +72,14 @@ export const ReplyPreview = observer(
}
}, [message.channelId, message.id, message.messageReference]);
if (!message.messageReference) return null;
if (messageState !== MessageReferenceState.LOADED || !referencedMessage) {
if (resolution.state !== MessageReferenceState.LOADED) {
const isDeleted = resolution.state === MessageReferenceState.DELETED;
return (
<div
className={clsx(styles.repliedMessage, messageDisplayCompact && styles.repliedMessageCompact)}
data-message-copy-hidden="true"
data-flx="channel.reply-preview.replied-message"
data-flx-reference-state={resolution.state}
>
<div className={styles.repliedIconContainer} data-flx="channel.reply-preview.replied-icon-container">
<ArrowBendUpLeftIcon
@@ -86,26 +88,40 @@ export const ReplyPreview = observer(
data-flx="channel.reply-preview.replied-icon"
/>
</div>
<button
type="button"
disabled
className={clsx(styles.repliedTextPreview, styles.unstyled)}
tabIndex={-1}
data-flx="channel.reply-preview.replied-text-preview.button"
>
{messageState === MessageReferenceState.DELETED ? (
{isDeleted ? (
<div
className={clsx(styles.repliedTextPreview, styles.unstyled)}
data-flx="channel.reply-preview.replied-text-preview.deleted"
>
<span className={styles.repliedItalic} data-flx="channel.reply-preview.replied-italic">
{i18n._(ORIGINAL_MESSAGE_WAS_DELETED_DESCRIPTOR)}
</span>
) : (
<span className={styles.repliedItalic} data-flx="channel.reply-preview.replied-italic--2">
{i18n._(ORIGINAL_MESSAGE_FAILED_TO_LOAD_DESCRIPTOR)}
</span>
)}
</button>
</div>
) : (
<FocusRing offset={-2} data-flx="channel.reply-preview.focus-ring--not-loaded">
<button
type="button"
className={clsx(styles.repliedTextPreview, styles.unstyled)}
onClick={jumpToRepliedMessage}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
jumpToRepliedMessage();
}
}}
data-flx="channel.reply-preview.replied-text-preview.not-loaded"
>
<span className={styles.repliedItalic} data-flx="channel.reply-preview.replied-italic--2">
{i18n._(ORIGINAL_MESSAGE_FAILED_TO_LOAD_DESCRIPTOR)}
</span>
</button>
</FocusRing>
)}
</div>
);
}
const referencedMessage = resolution.message;
const isSpammerReply =
referencedMessage.author.id !== Authentication.currentUserId &&
LocalUserSpamOverride.isUserMarkedAsSpammer(referencedMessage.author.id, referencedMessage.author.flags);
@@ -1,18 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import Channels from '@app/features/channel/state/Channels';
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
import {Message as MessageRecord} from '@app/features/messaging/models/MessagingMessage';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {makeAutoObservable} from 'mobx';
const logger = new Logger('MessageReferences');
export const MessageReferenceState = {
LOADED: 'LOADED',
NOT_LOADED: 'NOT_LOADED',
@@ -20,6 +14,20 @@ export const MessageReferenceState = {
} as const;
export type MessageReferenceState = ValueOf<typeof MessageReferenceState>;
export type MessageReferenceResolution =
| {readonly state: typeof MessageReferenceState.LOADED; readonly message: MessageRecord}
| {readonly state: typeof MessageReferenceState.NOT_LOADED}
| {readonly state: typeof MessageReferenceState.DELETED};
const NOT_LOADED_RESOLUTION: MessageReferenceResolution = Object.freeze({
state: MessageReferenceState.NOT_LOADED,
});
const DELETED_RESOLUTION: MessageReferenceResolution = Object.freeze({
state: MessageReferenceState.DELETED,
});
type MessageInput = WireMessage | MessageRecord;
const toWireMessage = (message: MessageInput): WireMessage =>
@@ -59,7 +67,9 @@ class MessageReferences {
const key = this.getKey(refChannelId, refMessageId);
const nextMessage = new MessageRecord(toWireMessage(message), {missingReactions: 'preserve'});
const currentMessage = this.cachedMessages.get(key);
if (currentMessage?.equals(nextMessage)) {
if (this.deletedMessageIds.has(key)) {
this.deletedMessageIds.delete(key);
} else if (currentMessage?.equals(nextMessage)) {
return false;
}
this.cachedMessages.set(key, nextMessage);
@@ -67,6 +77,17 @@ class MessageReferences {
return true;
}
private markReferenceDeleted(refChannelId: string, refMessageId: string): boolean {
const key = this.getKey(refChannelId, refMessageId);
if (this.deletedMessageIds.has(key) && !this.cachedMessages.has(key)) {
return false;
}
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
this.bumpReferenceVersion(refChannelId, refMessageId);
return true;
}
private updateCachedMessage(refChannelId: string, refMessageId: string, updates: Partial<WireMessage>): boolean {
const key = this.getKey(refChannelId, refMessageId);
const currentMessage = this.cachedMessages.get(key);
@@ -116,13 +137,26 @@ class MessageReferences {
this.referencingMessages.delete(referencingMessageId);
}
handleMessageCreate(message: WireMessage, _optimistic: boolean): void {
if (message.referenced_message) {
const refChannelId = message.message_reference?.channel_id ?? message.channel_id;
const refMessageId = message.referenced_message.id;
this.setCachedMessage(refChannelId, refMessageId, message.referenced_message);
this.addReference(refChannelId, refMessageId, message.id);
private resolveReferenceTarget(message: WireMessage, fallbackChannelId: string): boolean {
const reference = message.message_reference;
if (!reference || reference.type !== MessageReferenceTypes.DEFAULT) {
return false;
}
const refChannelId = reference.channel_id ?? fallbackChannelId;
const refMessageId = reference.message_id;
this.addReference(refChannelId, refMessageId, message.id);
if (!('referenced_message' in message)) {
return false;
}
const referenced = message.referenced_message;
if (referenced == null) {
return this.markReferenceDeleted(refChannelId, refMessageId);
}
return this.setCachedMessage(refChannelId, refMessageId, referenced);
}
handleMessageCreate(message: WireMessage, _optimistic: boolean): void {
this.resolveReferenceTarget(message, message.channel_id);
}
handleMessageDelete(channelId: string, messageId: string): void {
@@ -139,45 +173,13 @@ class MessageReferences {
handleMessageDeleteBulk(channelId: string, messageIds: Array<string>): void {
for (const messageId of messageIds) {
const key = this.getKey(channelId, messageId);
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
this.referenceVersions.delete(key);
this.referenceCount.delete(key);
const referencedBy = this.referencingMessages.get(messageId);
if (referencedBy) {
this.removeReference(referencedBy.channelId, referencedBy.messageId, messageId);
}
this.handleMessageDelete(channelId, messageId);
}
}
handleMessagesFetchSuccess(channelId: string, messages: Array<WireMessage>): void {
for (const message of messages) {
if (message.referenced_message) {
const refChannelId = message.message_reference?.channel_id ?? channelId;
const refMessageId = message.referenced_message.id;
this.setCachedMessage(refChannelId, refMessageId, message.referenced_message);
this.addReference(refChannelId, refMessageId, message.id);
}
}
const potentiallyMissingMessageIds = messages
.filter((message) => message.message_reference && !message.referenced_message)
.map((message) => ({
channelId: message.message_reference!.channel_id ?? channelId,
messageId: message.message_reference!.message_id,
referencingMessageId: message.id,
}))
.filter(
({channelId: refChannelId, messageId}) =>
!Messages.getMessage(refChannelId, messageId) &&
!this.deletedMessageIds.has(this.getKey(refChannelId, messageId)) &&
!this.cachedMessages.has(this.getKey(refChannelId, messageId)),
);
for (const {channelId: refChannelId, messageId, referencingMessageId} of potentiallyMissingMessageIds) {
this.addReference(refChannelId, messageId, referencingMessageId);
}
if (potentiallyMissingMessageIds.length > 0) {
this.fetchMissingMessages(potentiallyMissingMessageIds.map(({channelId, messageId}) => ({channelId, messageId})));
this.resolveReferenceTarget(message, channelId);
}
}
@@ -198,9 +200,11 @@ class MessageReferences {
if (!('message_reference' in message) && !('referenced_message' in message)) {
return;
}
const reference = message.message_reference;
const isReferenceBearing = reference != null && reference.type === MessageReferenceTypes.DEFAULT;
const previousRef = this.referencingMessages.get(message.id);
const newRefChannelId = message.message_reference?.channel_id ?? message.channel_id;
const newRefMessageId = message.referenced_message?.id ?? message.message_reference?.message_id;
const newRefChannelId = reference?.channel_id ?? message.channel_id;
const newRefMessageId = isReferenceBearing ? reference.message_id : undefined;
if (previousRef) {
const previousKey = this.getKey(previousRef.channelId, previousRef.messageId);
const newKey = newRefMessageId ? this.getKey(newRefChannelId, newRefMessageId) : null;
@@ -209,61 +213,7 @@ class MessageReferences {
}
}
if (newRefMessageId) {
if (message.referenced_message) {
this.setCachedMessage(newRefChannelId, newRefMessageId, message.referenced_message);
}
this.addReference(newRefChannelId, newRefMessageId, message.id);
}
}
private fetchMissingMessages(
refs: Array<{
channelId: string;
messageId: string;
}>,
): void {
const allowedRefs = refs.filter(({channelId}) => {
const channel = Channels.getChannel(channelId);
if (!channel) {
return false;
}
if (channel.isPrivate()) {
return true;
}
return !GuildMatureContentAgree.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null});
});
if (allowedRefs.length === 0) {
return;
}
Promise.allSettled(
allowedRefs.map(({channelId, messageId}) =>
http
.get<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId))
.then((response) => {
if (response.body) {
this.handleMessageFetchSuccess(channelId, messageId, response.body);
}
})
.catch((error) => this.handleMessageFetchError(channelId, messageId, error)),
),
);
}
private handleMessageFetchSuccess(channelId: string, messageId: string, message: WireMessage): void {
const messageRecord = new MessageRecord(message);
const key = this.getKey(channelId, messageId);
this.cachedMessages.set(key, messageRecord);
this.bumpReferenceVersion(channelId, messageId);
}
private handleMessageFetchError(channelId: string, messageId: string, error: unknown): void {
const key = this.getKey(channelId, messageId);
if (error instanceof HttpError && error.status === 404) {
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
this.referenceVersions.delete(key);
} else {
logger.error(`Failed to fetch message ${messageId}`, error);
this.resolveReferenceTarget(message, message.channel_id);
}
}
@@ -296,48 +246,21 @@ class MessageReferences {
}
}
getMessage(channelId: string, messageId: string): MessageRecord | null {
getMessageReference(channelId: string, messageId: string): MessageReferenceResolution {
const key = this.getKey(channelId, messageId);
this.readReferenceVersion(channelId, messageId);
if (this.deletedMessageIds.has(key)) {
return null;
}
return Messages.getMessage(channelId, messageId) || this.cachedMessages.get(key) || null;
}
getMessageReference(
channelId: string,
messageId: string,
): {
message: MessageRecord | null;
state: MessageReferenceState;
} {
const key = this.getKey(channelId, messageId);
this.readReferenceVersion(channelId, messageId);
if (this.deletedMessageIds.has(key)) {
return {
message: null,
state: MessageReferenceState.DELETED,
};
}
const message = Messages.getMessage(channelId, messageId);
if (message) {
return {
message,
state: MessageReferenceState.LOADED,
};
return DELETED_RESOLUTION;
}
const cachedMessage = this.cachedMessages.get(key);
if (cachedMessage) {
return {
message: cachedMessage,
state: MessageReferenceState.LOADED,
};
return {state: MessageReferenceState.LOADED, message: cachedMessage};
}
return {
message: null,
state: MessageReferenceState.NOT_LOADED,
};
const message = Messages.getMessage(channelId, messageId);
if (message) {
return {state: MessageReferenceState.LOADED, message};
}
return NOT_LOADED_RESOLUTION;
}
}
@@ -3,7 +3,7 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import Authentication from '@app/features/auth/state/Authentication';
import Channels from '@app/features/channel/state/Channels';
import MessageReferences from '@app/features/messaging/state/MessageReferences';
import MessageReferences, {MessageReferenceState} from '@app/features/messaging/state/MessageReferences';
import {SystemMessageUtils} from '@app/features/messaging/utils/SystemMessageUtils';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Relationships from '@app/features/relationship/state/Relationships';
@@ -454,7 +454,7 @@ function handleIncomingTtsMessage(message: Message): void {
const refChannelId = message.message_reference.channel_id ?? message.channel_id;
const refMessageId = message.message_reference.message_id;
const ref = MessageReferences.getMessageReference(refChannelId, refMessageId);
if (ref.message) {
if (ref.state === MessageReferenceState.LOADED) {
const replyAuthor = Users.getUser(ref.message.author.id);
if (replyAuthor) {
replyAuthorName = NicknameUtils.getNickname(replyAuthor, channel.guildId ?? null);