Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
21aa254
fix: suppress useAsync re-throw to prevent unhandled promise rejections
Just-Insane Mar 26, 2026
5e1ddd8
fix: wire OIDC token refresh to service worker and localStorage
Just-Insane Mar 26, 2026
715e9e4
chore: add changeset for media error handling fixes
Just-Insane Mar 26, 2026
c177218
fix: retry media requests once on 401 with refreshed session
Just-Insane Mar 26, 2026
e0bb2e6
Merge branch 'dev' into fix/media-error-handling
Just-Insane Mar 26, 2026
1490e33
fix(sw): retry media 401 by requesting live session from client tab
Just-Insane Mar 27, 2026
e56a8a9
fix: address Copilot review suggestions on PR #548
Just-Insane Mar 27, 2026
8af645e
fix: eliminate SW session race causing initial media 401s
Just-Insane Mar 27, 2026
4bc6680
fix: use event.waitUntil for session cache persistence in SW
Just-Insane Mar 28, 2026
4932d0f
chore: update changeset for SW session persistence fix
Just-Insane Mar 28, 2026
feec04c
chore: shorten changeset to one-line end-user summary
Just-Insane Mar 28, 2026
503889f
fix: pass userId to pushSessionToSW in session-change reload path
Just-Insane Mar 28, 2026
459c7db
Use the server count for thread counts/chips
Just-Insane Mar 28, 2026
5d4dd18
Use server's `count` for thread counts
Just-Insane Mar 28, 2026
462e6a8
fix(sw): avoid ambiguous media auth token fallback
Just-Insane Mar 28, 2026
4b17550
fix(sw): authenticate preview_url and v3 media routes
Just-Insane Mar 28, 2026
3fbe0ae
fix: harden authenticated media and preview URL handling
Just-Insane Mar 28, 2026
74a63b3
fix(sw): eliminate no-continue and no-await-in-loop ESLint errors in …
Just-Insane Mar 28, 2026
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-media-error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix intermittent 401 errors when loading media after a token refresh or service worker restart.
42 changes: 33 additions & 9 deletions src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from '$types/matrix-sdk';
import { IPreviewUrlResponse, MatrixError } from '$types/matrix-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useMatrixClient } from '$hooks/useMatrixClient';
Expand All @@ -14,9 +14,27 @@

const linkStyles = { color: color.Success.Main };

const normalizePreviewUrl = (input: string): string => {
const trimmed = input.trim().replace(/^<+/, '').replace(/>+$/, '');

try {
const parsed = new URL(trimmed);
parsed.pathname = parsed.pathname.replace(/(?:%60|`)+$/gi, '');
return parsed.toString();
} catch {
// Keep the original-ish value; URL preview fetch will fail gracefully.
return trimmed.replace(/(?:%60|`)+$/gi, '');
}
};

const isIgnorablePreviewError = (error: unknown): boolean => {
if (!(error instanceof MatrixError)) return false;
return error.httpStatus === 404 || error.httpStatus === 502;
};

const openMediaInNewTab = async (url: string | undefined) => {
if (!url) {
console.warn('Attempted to open an empty url');

Check warning on line 37 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
const blob = await downloadMedia(url);
Expand All @@ -28,19 +46,25 @@
({ url, ts, mediaType, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const previewUrl = normalizePreviewUrl(url);

const isDirect = !!mediaType;

const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => {
useCallback(async () => {
if (isDirect) return Promise.resolve(null);
return mx.getUrlPreview(url, ts);
}, [url, ts, mx, isDirect])
try {
return await mx.getUrlPreview(previewUrl, ts);
} catch (error) {
if (isIgnorablePreviewError(error)) return null;
throw error;
}
}, [previewUrl, ts, mx, isDirect])
);

useEffect(() => {
loadPreview();
}, [url, loadPreview]);
}, [previewUrl, loadPreview]);

if (previewStatus.status === AsyncStatus.Error) return null;

Expand All @@ -59,14 +83,14 @@
);
const handleAuxClick = (ev: React.MouseEvent) => {
if (!prev['og:image']) {
console.warn('No image');

Check warning on line 86 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
if (ev.button === 1) {
ev.preventDefault();
const mxcUrl = mxcUrlToHttp(mx, prev['og:image'], /* useAuthentication */ true);
if (!mxcUrl) {
console.error('Error converting mxc:// url.');

Check warning on line 93 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
openMediaInNewTab(mxcUrl);
Expand Down Expand Up @@ -94,14 +118,14 @@
style={linkStyles}
truncate
as="a"
href={url}
href={previewUrl}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{typeof siteName === 'string' && `${siteName} | `}
{safeDecodeUrl(url)}
{safeDecodeUrl(previewUrl)}
</Text>
{title && (
<Text truncate priority="400">
Expand Down Expand Up @@ -193,13 +217,13 @@
style={linkStyles}
truncate
as="a"
href={url}
href={previewUrl}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{safeDecodeUrl(url)}
{safeDecodeUrl(previewUrl)}
</Text>
</UrlPreviewContent>
);
Expand Down
81 changes: 80 additions & 1 deletion src/app/features/settings/developer-tools/DevelopTools.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, Spinner, color } from 'folds';
import { KnownMembership } from 'matrix-js-sdk/lib/types';
import { Page, PageContent, PageHeader } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
Expand All @@ -9,6 +10,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor';
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
import { DebugLogViewer } from './DebugLogViewer';
Expand All @@ -23,6 +25,33 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();

const [rotateState, rotateAllSessions] = useAsyncCallback<
{ rotated: number; total: number },
Error,
[]
>(
useCallback(async () => {
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Crypto module not available');

const encryptedRooms = mx
.getRooms()
.filter(
(room) =>
room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId)
);

await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)));
const rotated = encryptedRooms.length;

// Proactively start session creation + key sharing with all devices
// (including bridge bots). fire-and-forget per room.
encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room));

return { rotated, total: encryptedRooms.length };
}, [mx])
);

const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
Expand Down Expand Up @@ -115,6 +144,56 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
)}
</Box>
{developerTools && <SyncDiagnostics />}
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Encryption</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Rotate Encryption Sessions"
description="Discard current Megolm sessions and begin sharing new keys with all room members. Key delivery happens in the background — send a message in each affected room to confirm the bridge has received the new keys."
after={
<Button
onClick={rotateAllSessions}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={rotateState.status === AsyncStatus.Loading}
before={
rotateState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Secondary" />
)
}
>
<Text size="B300">
{rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'}
</Text>
</Button>
}
>
{rotateState.status === AsyncStatus.Success && (
<Text size="T200" style={{ color: color.Success.Main }}>
Sessions discarded for {rotateState.data.rotated} of{' '}
{rotateState.data.total} encrypted rooms. Key sharing is starting in the
background — send a message in an affected room to confirm delivery to
bridges.
</Text>
)}
{rotateState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{rotateState.error.message}
</Text>
)}
</SettingTile>
</SequenceCard>
</Box>
)}
{developerTools && (
<AccountData
expand={expand}
Expand Down
8 changes: 7 additions & 1 deletion src/app/hooks/timeline/useTimelineEventRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PushProcessor,
EventTimelineSet,
IContent,
IThreadBundledRelationship,
} from '$types/matrix-sdk';
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { HTMLReactParserOptions } from 'html-react-parser';
Expand Down Expand Up @@ -101,7 +102,12 @@ function ThreadReplyChip({

if (!thread) return null;

const replyCount = thread.length ?? 0;
// Prefer the server-authoritative bundled count. thread.length only reflects
// events fetched into the local timeline, which can be much lower than the
// true total before the thread drawer is first opened and paginated.
const bundledCount =
thread.rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>('m.thread')?.count;
const replyCount = bundledCount ?? thread.length ?? 0;
if (replyCount === 0) return null;

const uniqueSenders: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion src/app/hooks/useAsyncCallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('useAsyncCallback', () => {
);

await act(async () => {
await result.current[1]().catch(() => {});
await expect(result.current[1]()).rejects.toBe(boom);
});

expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom });
Expand Down
17 changes: 16 additions & 1 deletion src/app/hooks/useAsyncCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export const useAsync = <TData, TError, TArgs extends unknown[]>(
});
});
}
// Re-throw so .then()/.catch() callers see the rejection and success
// handlers are skipped. Fire-and-forget unhandled-rejection warnings are
// suppressed at the useAsyncCallback level via a no-op .catch wrapper.
throw e;
}

Expand Down Expand Up @@ -102,7 +105,19 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
status: AsyncStatus.Idle,
});

const callback = useAsync(asyncCallback, setState);
const innerCallback = useAsync(asyncCallback, setState);

// Re-throw preserves rejection for callers that await/chain; the no-op .catch
// suppresses "Uncaught (in promise)" for fire-and-forget call sites (e.g.
// loadSrc() in a useEffect) without swallowing the error from intentional callers.
const callback = useCallback(
(...args: TArgs): Promise<TData> => {
const p = innerCallback(...args);
p.catch(() => {});
return p;
},
[innerCallback]
) as AsyncCallback<TArgs, TData>;

return [state, callback, setState];
};
Expand Down
2 changes: 2 additions & 0 deletions src/app/pages/auth/login/loginUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
userId: loginRes.user_id,
deviceId: loginRes.device_id,
accessToken: loginRes.access_token,
...(loginRes.refresh_token != null && { refreshToken: loginRes.refresh_token }),
...(loginRes.expires_in_ms != null && { expiresInMs: loginRes.expires_in_ms }),
};
setSessions({ type: 'PUT', session: newSession });
setActiveSessionId(loginRes.user_id);
Expand Down
18 changes: 14 additions & 4 deletions src/app/pages/client/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,21 @@ export function ClientRoot({ children }: ClientRootProps) {
}
await clearMismatchedStores();
log.log('initClient for', activeSession.userId);
const newMx = await initClient(activeSession);
const newMx = await initClient(activeSession, (newAccessToken, newRefreshToken) => {
setSessions({
type: 'PUT',
session: {
...activeSession,
accessToken: newAccessToken,
...(newRefreshToken !== undefined && { refreshToken: newRefreshToken }),
},
});
pushSessionToSW(activeSession.baseUrl, newAccessToken, activeSession.userId);
});
loadedUserIdRef.current = activeSession.userId;
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId);
return newMx;
}, [activeSession, activeSessionId, setActiveSessionId])
}, [activeSession, activeSessionId, setActiveSessionId, setSessions])
);

const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined;
Expand All @@ -232,7 +242,7 @@ export function ClientRoot({ children }: ClientRootProps) {
activeSession.userId,
'— reloading client'
);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId);
if (mx?.clientRunning) {
stopClient(mx);
}
Expand Down
14 changes: 12 additions & 2 deletions src/app/utils/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Direction, EventTimeline, MatrixEvent, Room } from '$types/matrix-sdk';
import {
Direction,
EventTimeline,
IThreadBundledRelationship,
MatrixEvent,
Room,
} from '$types/matrix-sdk';
import { roomHaveNotification, roomHaveUnread, reactionOrEditEvent } from '$utils/room';

export const PAGINATION_LIMIT = 60;
Expand Down Expand Up @@ -155,7 +161,11 @@ export const getRoomUnreadInfo = (room: Room, scrollTo = false) => {

export const getThreadReplyCount = (room: Room, mEventId: string): number => {
const thread = room.getThread(mEventId);
if (thread) return thread.length;
if (thread) {
const bundledCount =
thread.rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>('m.thread')?.count;
return bundledCount ?? thread.length;
}

const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
return linkedTimelines.reduce((acc, tl) => {
Expand Down
Loading
Loading