Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Fix duplicate reactions and deleting a message that is still sending
8 changes: 4 additions & 4 deletions src/app/components/message/modals/MessageDelete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { modalAtom, ModalType } from '$state/modal';
import * as css from '$features/room/message/styles.css';
import { createDebugLogger } from '$utils/debugLogger';
import { redactEvent } from '$utils/room/redaction';
import * as Sentry from '@sentry/react';

const debugLog = createDebugLogger('MessageDelete');
Expand Down Expand Up @@ -72,9 +73,8 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn

const [deleteState, deleteMessage] = useAsyncCallback(
useCallback(
(eventId: string, reason?: string) =>
mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
[mx, room]
(reason?: string) => redactEvent(mx, room, mEvent, reason ? { reason } : undefined),
[mx, room, mEvent]
)
);

Expand Down Expand Up @@ -106,7 +106,7 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn

debugLog.info('ui', 'Deleting message', { eventId, hasReason: !!reason });
Sentry.metrics.count('sable.message.delete.attempt', 1);
deleteMessage(eventId, reason);
deleteMessage(reason);
};

return (
Expand Down
21 changes: 12 additions & 9 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,8 @@ import type { GifData } from '$components/emoji-board';
import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board';
import { UseStateProvider } from '$components/UseStateProvider';
import type { TUploadContent } from '$utils/matrix';
import {
cancelUploadContent,
encryptFile,
getImageInfo,
mxcUrlToHttp,
toggleReaction,
} from '$utils/matrix';
import { cancelUploadContent, encryptFile, getImageInfo, mxcUrlToHttp } from '$utils/matrix';
import { toggleReaction } from '$utils/room/reactions';
import { useTypingStatusUpdater } from '$hooks/useTypingStatusUpdater';
import { useFilePicker } from '$hooks/useFilePicker';
import { useFilePasteHandler } from '$hooks/useFilePasteHandler';
Expand All @@ -112,6 +107,7 @@ import type { EditorButtonId } from '$state/settings';
import { settingsAtom } from '$state/settings';
import { matchesShortcut } from '../../keyboard/shortcuts';
import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/room/relations';
import { isLocalEventId, waitForRemoteEventId } from '$utils/room/redaction';
import { buildReplacementContent } from './buildReplacementContent';
import { htmlToMarkdown } from '$plugins/markdown';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands';
Expand Down Expand Up @@ -1039,7 +1035,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const lastMessageId = lastMessage?.getId();

if (lastMessageId) {
toggleReaction(mx, room, lastMessageId, key, shortcode);
toggleReaction(mx, room, lastMessageId, key, shortcode).catch((err: unknown) => {
debugLog.error('ui', 'Reaction toggle failed', { eventId: lastMessageId, key, err });
});
}
}

Expand Down Expand Up @@ -1067,9 +1065,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
const oldContent = editingEvent.getContent();
const currentContent = getEditingContent(editingEvent);
const eventId = editingEvent.getId();
let eventId = editingEvent.getId();
if (!eventId) return;

if (isLocalEventId(eventId)) {
eventId = await waitForRemoteEventId(editingEvent);
if (!eventId) return;
}

const rawPmp =
currentContent['com.beeper.per_message_profile'] ??
oldContent['com.beeper.per_message_profile'];
Expand Down
1 change: 1 addition & 0 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,7 @@ export function RoomTimeline({
const processedEvents = useProcessedTimeline({
items: vListIndices,
linkedTimelines: timelineSync.timeline.linkedTimelines,
pendingEvents: timelineSync.pendingEvents,
ignoredUsersSet,
hiddenEvents,
mxUserId: mx.getUserId(),
Expand Down
8 changes: 7 additions & 1 deletion src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { useDismissOnBack } from '$utils/androidBack';
import { nicknamesAtom } from '$state/nicknames';
import { getEditedEvent, getMentionContent } from '$utils/room/relations';
import { isLocalEventId, waitForRemoteEventId } from '$utils/room/redaction';
import { trimReplyFromFormattedBody } from '$utils/room/display';
import { buildReplacementContent } from '../buildReplacementContent';
import { isMobileOrTablet } from '$utils/platform';
Expand Down Expand Up @@ -245,9 +246,14 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();

if (plainText === '') return undefined;
const eventId = mEvent.getId();
let eventId = mEvent.getId();
if (!eventId) return undefined;

if (isLocalEventId(eventId)) {
eventId = await waitForRemoteEventId(mEvent);
if (!eventId) return undefined;
}

if (prevBody) {
if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
return undefined;
Expand Down
5 changes: 3 additions & 2 deletions src/app/features/room/message/Reactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useRelations } from '$hooks/useRelations';
import { stopPropagation } from '$utils/keyboard';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useDismissOnBack } from '$utils/androidBack';
import { dedupeAnnotationsBySender } from '$utils/room/reactions';
import { ReactionViewer } from '$features/room/reaction-viewer';
import * as css from './styles.css';

Expand Down Expand Up @@ -90,7 +91,7 @@ export const Reactions = as<'div', ReactionsProps>(
ref={ref}
>
{reactions.map(([key, events]) => {
const rEvents = Array.from(events);
const rEvents = dedupeAnnotationsBySender(events);
if (rEvents.length === 0 || typeof key !== 'string') return null;
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
const isPressed = !!myREvent?.getRelation();
Expand All @@ -116,7 +117,7 @@ export const Reactions = as<'div', ReactionsProps>(
key={key}
mx={mx}
reaction={key}
count={events.size}
count={rEvents.length}
onClick={canToggle ? () => onReactionToggle(mEventId, key) : undefined}
onContextMenu={handleViewReaction}
onTouchStart={(evt) => evt.stopPropagation()}
Expand Down
3 changes: 2 additions & 1 deletion src/app/features/room/reaction-viewer/ReactionViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile';
import { useSpaceOptionally } from '$hooks/useSpace';
import { getMouseEventCords } from '$utils/dom';
import { dedupeAnnotationsBySender } from '$utils/room/reactions';
import * as css from './ReactionViewer.css';

type ReactionViewerProps = {
Expand Down Expand Up @@ -50,7 +51,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
const getReactionsForKey = (key: string): MatrixEvent[] => {
const reactSet = reactions.find(([k]) => k === key)?.[1];
if (!reactSet) return [];
return Array.from(reactSet);
return dedupeAnnotationsBySender(reactSet);
};

const selectedReactions = getReactionsForKey(selectedKey);
Expand Down
16 changes: 14 additions & 2 deletions src/app/hooks/timeline/useProcessedTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,14 @@ function createTimeline(events: MatrixEvent[]): EventTimeline {

function processTimeline(
events: MatrixEvent[],
readUptoEventId: string | undefined
readUptoEventId: string | undefined,
pendingEvents: MatrixEvent[] = []
): ProcessedEvent[] {
const { result } = renderHook(() =>
useProcessedTimeline({
items: events.map((_, i) => i),
items: [...events, ...pendingEvents].map((_, i) => i),
linkedTimelines: [createTimeline(events)],
pendingEvents,
ignoredUsersSet: new Set(),
hiddenEvents,
mxUserId: MY_USER,
Expand All @@ -163,6 +165,16 @@ const renderedIds = (processed: ProcessedEvent[]) => processed.map((e) => e.id);
const dividerIds = (processed: ProcessedEvent[]) =>
processed.filter((e) => e.willRenderNewDivider).map((e) => e.id);

describe('useProcessedTimeline pending events', () => {
it('appends detached pending events after the live timeline', () => {
const processed = processTimeline([createEvent({ id: '$sent' })], undefined, [
createEvent({ id: '~pending', sender: MY_USER }),
]);

expect(renderedIds(processed)).toEqual(['$sent', '~pending']);
});
});

describe('useProcessedTimeline new-messages divider', () => {
it('renders an event that is still encrypted', () => {
const processed = processTimeline(
Expand Down
12 changes: 10 additions & 2 deletions src/app/hooks/timeline/useProcessedTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { M_POLL_START } from 'matrix-js-sdk';
export interface UseProcessedTimelineOptions {
items: number[];
linkedTimelines: EventTimeline[];
pendingEvents?: MatrixEvent[];
ignoredUsersSet: Set<string>;
hiddenEvents: ResolvedHiddenEventSettings;
mxUserId: string | null;
Expand Down Expand Up @@ -112,12 +113,17 @@ type TimelineEventEntry = {
timelineSet: EventTimelineSet;
};

const flattenTimelineEvents = (linkedTimelines: EventTimeline[]): TimelineEventEntry[] => {
const flattenTimelineEvents = (
linkedTimelines: EventTimeline[],
pendingEvents: MatrixEvent[]
): TimelineEventEntry[] => {
const entries: TimelineEventEntry[] = [];
linkedTimelines.forEach((timeline) => {
const timelineSet = timeline.getTimelineSet();
timeline.getEvents().forEach((mEvent) => entries.push({ mEvent, timelineSet }));
});
const timelineSet = linkedTimelines.at(-1)?.getTimelineSet();
if (timelineSet) pendingEvents.forEach((mEvent) => entries.push({ mEvent, timelineSet }));
return entries;
};

Expand Down Expand Up @@ -569,6 +575,7 @@ type ProcessingCache = {
export function useProcessedTimeline({
items,
linkedTimelines,
pendingEvents = [],
ignoredUsersSet,
hiddenEvents,
mxUserId,
Expand All @@ -592,7 +599,7 @@ export function useProcessedTimeline({
const cacheRef = useRef<ProcessingCache>();

return useMemo(() => {
const timelineEvents = flattenTimelineEvents(linkedTimelines);
const timelineEvents = flattenTimelineEvents(linkedTimelines, pendingEvents);
const processingOptions: TimelineProcessingOptions = {
ignoredUsersSet,
showHiddenEvents,
Expand Down Expand Up @@ -711,6 +718,7 @@ export function useProcessedTimeline({
}, [
items,
linkedTimelines,
pendingEvents,
ignoredUsersSet,
showHiddenEvents,
showTombstoneEvents,
Expand Down
17 changes: 9 additions & 8 deletions src/app/hooks/timeline/useTimelineActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ import { EventStatus, RelationType } from '$types/matrix-sdk';
import type { Editor } from 'slate';
import { ReactEditor } from 'slate-react';

import { getMxIdLocalPart, toggleReaction } from '$utils/matrix';
import { getMxIdLocalPart } from '$utils/matrix';
import { toggleReaction } from '$utils/room/reactions';
import { getMemberDisplayName } from '$utils/room/display';
import { extractReplyDraftBody, resolveReplyDraftTarget } from '$utils/room/relations';
import { createMentionElement, moveCursor } from '$components/editor';
import { createDebugLogger } from '$utils/debugLogger';
import * as prefix from '$unstable/prefixes';

const debugLog = createDebugLogger('TimelineActions');

/**
* The profile popup reads name, avatar and the identity fields off the room
* member, so the cached copies would shadow fresher state event data.
Expand Down Expand Up @@ -205,14 +209,11 @@ export function useTimelineActions({

const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
// Thread reactions live in the thread's own timeline set; without it the
// existing reaction is never found and un-reacting sends a second one.
const threadTimelineSet = threadRootId
? room.getThread(threadRootId)?.timelineSet
: undefined;
toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet);
toggleReaction(mx, room, targetEventId, key, shortcode).catch((err: unknown) => {
debugLog.error('ui', 'Reaction toggle failed', { targetEventId, key, err });
});
},
[mx, room, threadRootId]
[mx, room]
);

const handleResend = useCallback(
Expand Down
7 changes: 6 additions & 1 deletion src/app/hooks/timeline/useTimelineSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,11 @@ export function useTimelineSync({
const resetAutoScrollPendingRef = useRef(false);
const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined);

const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
let pendingEvents: MatrixEvent[] = [];
try {
pendingEvents = room.getPendingEvents();
} catch {}
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines) + pendingEvents.length;
const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room);

const canPaginateBack =
Expand Down Expand Up @@ -672,6 +676,7 @@ export function useTimelineSync({
timeline,
setTimeline,
eventsLength,
pendingEvents,
liveTimelineLinked,
canPaginateBack,
backwardStatus,
Expand Down
42 changes: 0 additions & 42 deletions src/app/utils/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import { decryptAttachment } from 'browser-encrypt-attachment';
import { Channel, invoke, isTauri } from '@tauri-apps/api/core';
import type {
AccountDataEvents,
EventTimelineSet,
MatrixClient,
MatrixEvent,
Room,
RoomMember,
TimelineEvents,
UploadProgress,
UploadResponse,
} from '$types/matrix-sdk';
Expand All @@ -25,9 +23,7 @@ import type { IImageInfo, IThumbnailContent, IVideoInfo } from '$types/matrix/co
import * as Sentry from '@sentry/react';
import { encryptBlobInWorker } from '$utils/mediaWorker';
import { encryptAttachmentStreaming } from '$utils/attachmentCrypto';
import { getEventReactions } from './room/relations';
import { getStateEvent } from './room/hierarchy';
import { getReactionContent } from './messageReaction';
import { matchMxId, validMxId } from './mxIdHelper';

export { mxcUrlToHttp, rewriteAuthenticatedMediaUrl } from './mediaUrl';
Expand Down Expand Up @@ -526,41 +522,3 @@ export const rateLimitedActions = async <T, R = void>(
}
}
};

export const toggleReaction = (
mx: MatrixClient,
room: Room,
targetEventId: string,
key: string,
shortcode?: string,
timelineSet?: EventTimelineSet
) => {
const relations = getEventReactions(
timelineSet ?? room.getUnfilteredTimelineSet(),
targetEventId
);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions: MatrixEvent[] = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));

if (myReaction && myReaction.isRelation?.()) {
const eventId = myReaction.getId();
if (eventId) mx.redactEvent(room.roomId, eventId);
return;
}
const rShortcode =
shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
// send the reaction
mx.sendEvent(
room.roomId,
EventType.Reaction as string as unknown as keyof TimelineEvents,
getReactionContent(
targetEventId,
key,
mx,
room,
rShortcode
) as TimelineEvents[keyof TimelineEvents]
);
};
Loading
Loading