Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-inbox-notification-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Fix the notification inbox showing "Empty message" instead of the message
5 changes: 5 additions & 0 deletions .changeset/fix-thread-and-restart-read-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Fix rooms staying unread after a thread reply, and after restarting the app
13 changes: 8 additions & 5 deletions src/app/components/message-preview/MessagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ function renderEncryptedDecrypted(
event: MatrixEvent,
displayName: string,
decryptedEvent: MatrixEvent,
eventTimeline: NonNullable<ReturnType<Room['getTimelineForEvent']>>
eventTimeline: ReturnType<Room['getTimelineForEvent']>
) {
const eventId = event.getId()!;
if (decryptedEvent.isRedacted()) return <RedactedContent />;
Expand All @@ -165,7 +165,9 @@ function renderEncryptedDecrypted(
);
}
if (decryptedType === messageType) {
const editedEvent = getEditedEvent(eventId, decryptedEvent, eventTimeline.getTimelineSet());
const editedEvent = eventTimeline
? getEditedEvent(eventId, decryptedEvent, eventTimeline.getTimelineSet())
: undefined;
const getContent = (() =>
editedEvent?.getContent()?.['m.new_content'] ??
decryptedEvent.getContent()) as GetContentCallback;
Expand Down Expand Up @@ -193,9 +195,10 @@ function renderEncrypted(
) {
const eventId = event.getId()!;
const eventTimeline = ctx.room.getTimelineForEvent(eventId);
const decryptedEvent = eventTimeline?.getEvents().find((item) => item.getId() === eventId);

if (!decryptedEvent || !eventTimeline) return <MessageNotDecryptedContent />;
// Previews are also rendered for events outside any loaded timeline (the
// notification inbox), so fall back to the event we were handed.
const decryptedEvent =
eventTimeline?.getEvents().find((item) => item.getId() === eventId) ?? event;

return (
<EncryptedContent mEvent={decryptedEvent}>
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/nav/NavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const NavMenu = forwardRef<HTMLDivElement, NavMenuProps>(

const handleMarkAsRead = () => {
if (!unread) return;
rooms.forEach((rId) => markAsRead(mx, rId, hideReads));
rooms.forEach((rId) => markAsRead(mx, rId, hideReads, true));
requestClose();
};

Expand Down
2 changes: 1 addition & 1 deletion src/app/features/forum/ForumMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const ForumMenu = forwardRef<HTMLDivElement, ForumMenuProps>(
const [invitePrompt, setInvitePrompt] = useState(false);

const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideReads);
markAsRead(mx, room.roomId, hideReads, true);
requestClose();
};

Expand Down
2 changes: 1 addition & 1 deletion src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1442,7 +1442,7 @@ export function RoomTimeline({
radii="Pill"
outlined
before={chipIcon(Checks)}
onClick={() => markAsRead(mx, room.roomId, hideReads)}
onClick={() => markAsRead(mx, room.roomId, hideReads, true)}
>
<Text size="L400">Mark as Read</Text>
</Chip>
Expand Down
2 changes: 1 addition & 1 deletion src/app/hooks/useRoomMenuActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function useRoomMenuActions(room: Room) {
);

const handleMarkAsRead = useCallback(() => {
markAsRead(mx, room.roomId, hideReads);
markAsRead(mx, room.roomId, hideReads, true);
}, [mx, room.roomId, hideReads]);

const handleInvite = useCallback(() => {
Expand Down
28 changes: 15 additions & 13 deletions src/app/pages/client/inbox/Notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { showToast } from '$state/toast';
import { markAsRead } from '$utils/notifications';
import { fetchNotificationEvent } from '$utils/notificationEvent';
import { getRoomAvatarUrl } from '$utils/room/display';
import { useRoomUnread } from '$state/hooks/unread';
import { roomToUnreadAtom } from '$state/room/roomToUnread';
Expand Down Expand Up @@ -74,24 +75,25 @@ function NotificationItem({
() => room.findEventById(notification.event.event_id),
[room, notification.event.event_id]
);
const event = useMemo(
() => liveEvent ?? new MatrixEvent(notification.event),
[liveEvent, notification.event]
);
const [decryptedEvent, setDecryptedEvent] = useState<MatrixEvent>();
const storedEvent = useMemo(() => new MatrixEvent(notification.event), [notification.event]);
const [remoteEvent, setRemoteEvent] = useState<MatrixEvent>();

useEffect(() => {
setDecryptedEvent(undefined);
if (liveEvent || !event.isEncrypted()) return undefined;
setRemoteEvent(undefined);
if (liveEvent) return undefined;

let mounted = true;
void mx
.decryptEventIfNeeded(event)
.then(() => mounted && setDecryptedEvent(event))
fetchNotificationEvent(mx, room.roomId, notification.event.event_id)
.then((event) => mounted && setRemoteEvent(event))
// Offline, or the event is gone: storedEvent stays as the fallback.
.catch(() => undefined);

return () => {
mounted = false;
};
}, [event, liveEvent, mx]);
}, [mx, room.roomId, notification.event.event_id, liveEvent]);

const event = liveEvent ?? remoteEvent ?? storedEvent;

const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.stopPropagation();
Expand All @@ -107,7 +109,7 @@ function NotificationItem({
>
<MessagePreview
room={room}
event={decryptedEvent ?? event}
event={event}
renderContent={renderContent}
actions={
<Box shrink="No" gap="200" alignItems="Center">
Expand Down Expand Up @@ -182,7 +184,7 @@ function NotificationRowItem({
variant="Primary"
radii="Pill"
onClick={() => {
void markAsRead(mx, room.roomId, hideReads)
void markAsRead(mx, room.roomId, hideReads, true)
.then(onMarkRead)
.catch(() => showToast('Unable to mark this room as read.'));
}}
Expand Down
2 changes: 1 addition & 1 deletion src/app/pages/client/sidebar/SpaceTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const unread = useRoomsUnread(allChild, roomToUnreadAtom);

const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideReads));
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideReads, true));
requestClose();
};

Expand Down
2 changes: 1 addition & 1 deletion src/app/pages/client/space/Space.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const unread = useRoomsUnread(allChild, roomToUnreadAtom);

const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideReads));
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideReads, true));
requestClose();
};

Expand Down
115 changes: 115 additions & 0 deletions src/app/utils/notificationEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest';
import type { IEvent, MatrixClient } from '$types/matrix-sdk';
import { fetchNotificationEvent } from './notificationEvent';

const ROOM = '!room:example.com';

const rawEvent = (eventId: string, overrides: Partial<IEvent> = {}): Partial<IEvent> => ({
event_id: eventId,
room_id: ROOM,
type: 'm.room.message',
sender: '@other:example.com',
origin_server_ts: 1,
content: { msgtype: 'm.text', body: 'the real body' },
...overrides,
});

const makeMx = (fetchImpl: (roomId: string, eventId: string) => Promise<Partial<IEvent>>) => {
const fetchRoomEvent = vi
.fn<(roomId: string, eventId: string) => Promise<Partial<IEvent>>>()
.mockImplementation(fetchImpl);
const decryptEventIfNeeded = vi.fn<() => Promise<void>>().mockResolvedValue();

return {
mx: { fetchRoomEvent, decryptEventIfNeeded } as unknown as MatrixClient,
fetchRoomEvent,
decryptEventIfNeeded,
};
};

describe('fetchNotificationEvent', () => {
it('returns the event body the stored notification does not keep', async () => {
const { mx } = makeMx((_roomId, eventId) => Promise.resolve(rawEvent(eventId)));

const event = await fetchNotificationEvent(mx, ROOM, '$body');

expect(event.getId()).toBe('$body');
expect(event.getContent().body).toBe('the real body');
});

it('fetches an event only once across remounts', async () => {
const { mx, fetchRoomEvent } = makeMx((_roomId, eventId) => Promise.resolve(rawEvent(eventId)));

await fetchNotificationEvent(mx, ROOM, '$cached');
await fetchNotificationEvent(mx, ROOM, '$cached');

expect(fetchRoomEvent).toHaveBeenCalledTimes(1);
});

it('shares one request between concurrent callers', async () => {
const { mx, fetchRoomEvent } = makeMx((_roomId, eventId) => Promise.resolve(rawEvent(eventId)));

const [first, second] = await Promise.all([
fetchNotificationEvent(mx, ROOM, '$concurrent'),
fetchNotificationEvent(mx, ROOM, '$concurrent'),
]);

expect(fetchRoomEvent).toHaveBeenCalledTimes(1);
expect(first).toBe(second);
});

it('retries after a failure instead of caching it', async () => {
const { mx, fetchRoomEvent } = makeMx((_roomId, eventId) =>
fetchRoomEvent.mock.calls.length === 1
? Promise.reject(new Error('offline'))
: Promise.resolve(rawEvent(eventId))
);

await expect(fetchNotificationEvent(mx, ROOM, '$retry')).rejects.toThrow('offline');
const event = await fetchNotificationEvent(mx, ROOM, '$retry');

expect(fetchRoomEvent).toHaveBeenCalledTimes(2);
expect(event.getContent().body).toBe('the real body');
});

it('decrypts an encrypted event before returning it', async () => {
const { mx, decryptEventIfNeeded } = makeMx((_roomId, eventId) =>
Promise.resolve(
rawEvent(eventId, {
type: 'm.room.encrypted',
content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AAAA' },
})
)
);

await fetchNotificationEvent(mx, ROOM, '$encrypted');

expect(decryptEventIfNeeded).toHaveBeenCalledTimes(1);
});

it('still returns the event when decryption fails', async () => {
const { mx, decryptEventIfNeeded } = makeMx((_roomId, eventId) =>
Promise.resolve(
rawEvent(eventId, {
type: 'm.room.encrypted',
content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AAAA' },
})
)
);
decryptEventIfNeeded.mockRejectedValue(new Error('no keys'));

const event = await fetchNotificationEvent(mx, ROOM, '$undecryptable');

expect(event.getId()).toBe('$undecryptable');
});

it('does not decrypt a plaintext event', async () => {
const { mx, decryptEventIfNeeded } = makeMx((_roomId, eventId) =>
Promise.resolve(rawEvent(eventId))
);

await fetchNotificationEvent(mx, ROOM, '$plain');

expect(decryptEventIfNeeded).not.toHaveBeenCalled();
});
});
27 changes: 27 additions & 0 deletions src/app/utils/notificationEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { MatrixClient } from '$types/matrix-sdk';
import { MatrixEvent } from '$types/matrix-sdk';

// Notification records deliberately do not persist message bodies, so previewing
// one outside a loaded timeline means reading the real event. Cached because the
// inbox virtualizer remounts its rows on every scroll.
const inFlight = new Map<string, Promise<MatrixEvent>>();

export const fetchNotificationEvent = (
mx: MatrixClient,
roomId: string,
eventId: string
): Promise<MatrixEvent> => {
const key = `${roomId}/${eventId}`;
const cached = inFlight.get(key);
if (cached) return cached;

const request = mx.fetchRoomEvent(roomId, eventId).then(async (raw) => {
const event = new MatrixEvent(raw);
if (event.isEncrypted()) await mx.decryptEventIfNeeded(event).catch(() => undefined);
return event;
});
// Do not cache a failure; the next render should retry.
request.catch(() => inFlight.delete(key));
inFlight.set(key, request);
return request;
};
Loading
Loading