Compare commits

...

7 Commits

Author SHA1 Message Date
Krishan
4ba7b9162d Revert "fix: set m.fully_read marker when marking rooms as read" (#2629)
Revert "Set m.fully_read marker when marking rooms as read (#2587)"

This reverts commit 9d49418a1f.
2026-02-16 06:03:37 +11:00
Andrew Murphy
9d49418a1f Set m.fully_read marker when marking rooms as read (#2587)
Previously markAsRead() only sent m.read receipts via sendReadReceipt().
This meant the read position was not persisted across page refreshes,
especially noticeable in bridged rooms.

Now uses setRoomReadMarkers() which sets both:
- m.fully_read marker (persistent read position)
- m.read receipt

Fixes issue where rooms would still show as unread after refresh.
2026-02-14 17:32:10 +11:00
Ajay Bura
3522751a15 Prevent invalid mxc from getting used (#2609) 2026-02-14 17:12:28 +11:00
Ajay Bura
074c555294 Post session info to service worker instead of asking from sw (#2605)
post session info to service worker instead of asking from sw on each request
2026-02-14 17:11:36 +11:00
renovate[bot]
206a927f30 fix(deps): update dependency react-router-dom to v6.30.3 (#2612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 17:10:43 +11:00
Andrew Murphy
fd37dfe3f9 Fix muted rooms showing unread badges (#2581)
fix: detect muted rooms with empty actions array

The mute detection was checking for `actions[0] === "dont_notify"` but
Cinny sets `actions: []` (empty array) when muting a room, which is
the correct behavior per Matrix spec where empty actions means no
notification.

This caused muted rooms to still show unread badges and contribute to
space badge counts.

Fixes the isMutedRule check to handle both:
- Empty actions array (current Matrix spec)
- "dont_notify" string (deprecated but may exist in older rules)
2026-02-12 21:45:37 +11:00
Gimle Larpes
1ce6ca2b07 Re-add mEvent.getSender() === mx.getUserId() check for deletion of messages (#2607)
* hide "Delete Message" if it is forbidden

* Fix the stuff I broke :/
2026-02-12 21:40:11 +11:00
18 changed files with 130 additions and 75 deletions

29
package-lock.json generated
View File

@@ -56,7 +56,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0", "react-i18next": "15.0.0",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.30.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
@@ -3699,9 +3699,10 @@
} }
}, },
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.13.0", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@@ -9605,11 +9606,12 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.20.0", "version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.13.0" "@remix-run/router": "1.23.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -9619,12 +9621,13 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "6.20.0", "version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.13.0", "@remix-run/router": "1.23.2",
"react-router": "6.20.0" "react-router": "6.30.3"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@@ -67,7 +67,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0", "react-i18next": "15.0.0",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.30.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",

View File

@@ -88,6 +88,8 @@ export function EmoticonAutocomplete({
{autoCompleteEmoticon.map((emoticon) => { {autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon; const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode; const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
return ( return (
<MenuItem <MenuItem
key={emoticon.shortcode + key} key={emoticon.shortcode + key}
@@ -98,11 +100,11 @@ export function EmoticonAutocomplete({
} }
onClick={() => handleAutocomplete(key, emoticon.shortcode)} onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={ before={
isCustomEmoji ? ( isCustomEmoji && customEmojiUrl ? (
<Box <Box
shrink="No" shrink="No"
as="img" as="img"
src={mxcUrlToHttp(mx, key, useAuthentication) || key} src={customEmojiUrl}
alt={emoticon.shortcode} alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }} style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/> />

View File

@@ -202,8 +202,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const url = const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
pack.meta.avatar;
return ( return (
<ImageGroupIcon <ImageGroupIcon
@@ -266,7 +265,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const url = const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar; mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
return ( return (
<ImageGroupIcon <ImageGroupIcon

View File

@@ -68,7 +68,7 @@ export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiIte
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
/> />
</Box> </Box>
); );
@@ -98,7 +98,7 @@ export function StickerItem({ mx, useAuthentication, image }: StickerItemProps)
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
/> />
</Box> </Box>
); );

View File

@@ -27,7 +27,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -54,7 +54,8 @@ export function AudioContent({
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -86,7 +86,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
const [textState, loadText] = useAsyncCallback( const [textState, loadText] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
@@ -176,7 +177,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
const [pdfState, loadPdf] = useAsyncCallback( const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
@@ -253,7 +255,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -87,7 +87,8 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)

View File

@@ -23,7 +23,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
throw new Error('Failed to load thumbnail'); throw new Error('Failed to load thumbnail');
} }
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl; const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)

View File

@@ -81,7 +81,8 @@ export const VideoContent = as<'div', VideoContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo) decryptFile(encBuf, mimeType, encInfo)

View File

@@ -1048,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || canDeleteOwn} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -1130,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || canDeleteOwn} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -1248,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
messageLayout={messageLayout} messageLayout={messageLayout}
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || canDeleteOwn} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}

View File

@@ -68,6 +68,7 @@ import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space'; import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search'; import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions'; import { getFallbackSession } from '../state/sessions';
import { pushSessionToSW } from '../../sw-session';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
@@ -106,7 +107,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route <Route
loader={() => { loader={() => {
if (!getFallbackSession()) { const session = getFallbackSession();
if (!session) {
const afterLoginPath = getAppPathFromHref( const afterLoginPath = getAppPathFromHref(
getOriginBaseUrl(hashRouter), getOriginBaseUrl(hashRouter),
window.location.href window.location.href
@@ -114,6 +116,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath()); return redirect(getLoginPath());
} }
pushSessionToSW(session.baseUrl, session.accessToken);
return null; return null;
}} }}
element={ element={

View File

@@ -160,7 +160,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string):
}; };
export const isMutedRule = (rule: IPushRule) => export const isMutedRule = (rule: IPushRule) =>
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match'; // Check for empty actions (new spec) or dont_notify (deprecated)
(rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match';
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));

View File

@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys'; import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { pushSessionToSW } from '../sw-session';
type Session = { type Session = {
baseUrl: string; baseUrl: string;
@@ -53,6 +54,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
}; };
export const logoutClient = async (mx: MatrixClient) => { export const logoutClient = async (mx: MatrixClient) => {
pushSessionToSW();
mx.stopClient(); mx.stopClient();
try { try {
await mx.logout(); await mx.logout();

View File

@@ -15,6 +15,8 @@ import App from './app/pages/App';
// import i18n (needs to be bundled ;)) // import i18n (needs to be bundled ;))
import './app/i18n'; import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
document.body.classList.add(configClass, varsClass); document.body.classList.add(configClass, varsClass);
@@ -25,16 +27,9 @@ if ('serviceWorker' in navigator) {
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`; : `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl); navigator.serviceWorker.register(swUrl).then(() => {
navigator.serviceWorker.addEventListener('message', (event) => { const session = getFallbackSession();
if (event.data?.type === 'token' && event.data?.responseKey) { pushSessionToSW(session?.baseUrl, session?.accessToken);
// Get the token for SW.
const token = localStorage.getItem('cinny_access_token') ?? undefined;
event.source!.postMessage({
responseKey: event.data.responseKey,
token,
});
}
}); });
} }

10
src/sw-session.ts Normal file
View File

@@ -0,0 +1,10 @@
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
if (!('serviceWorker' in navigator)) return;
if (!navigator.serviceWorker.controller) return;
navigator.serviceWorker.controller.postMessage({
type: 'setSession',
accessToken,
baseUrl,
});
}

View File

@@ -3,22 +3,64 @@
export type {}; export type {};
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
async function askForAccessToken(client: Client): Promise<string | undefined> { self.addEventListener('install', () => {
return new Promise((resolve) => { self.skipWaiting();
const responseKey = Math.random().toString(36); });
const listener = (event: ExtendableMessageEvent) => {
if (event.data.responseKey !== responseKey) return; self.addEventListener('activate', (event: ExtendableEvent) => {
resolve(event.data.token); event.waitUntil(self.clients.claim());
self.removeEventListener('message', listener); });
};
self.addEventListener('message', listener); type SessionInfo = {
client.postMessage({ responseKey, type: 'token' }); accessToken: string;
baseUrl: string;
};
/**
* Store session per client (tab)
*/
const sessions = new Map<string, SessionInfo>();
async function cleanupDeadClients() {
const activeClients = await self.clients.matchAll();
const activeIds = new Set(activeClients.map((c) => c.id));
Array.from(sessions.keys()).forEach((id) => {
if (!activeIds.has(id)) {
sessions.delete(id);
}
}); });
} }
function fetchConfig(token?: string): RequestInit | undefined { /**
if (!token) return undefined; * Receive session updates from clients
*/
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null;
if (!client) return;
const { type, accessToken, baseUrl } = event.data || {};
if (type !== 'setSession') return;
cleanupDeadClients();
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(client.id, { accessToken, baseUrl });
} else {
// Logout or invalid session
sessions.delete(client.id);
}
});
function validMediaRequest(url: string, baseUrl: string): boolean {
const downloadUrl = new URL('/_matrix/client/v1/media/download', baseUrl);
const thumbnailUrl = new URL('/_matrix/client/v1/media/thumbnail', baseUrl);
return url.startsWith(downloadUrl.href) || url.startsWith(thumbnailUrl.href);
}
function fetchConfig(token: string): RequestInit {
return { return {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -27,26 +69,16 @@ function fetchConfig(token?: string): RequestInit | undefined {
}; };
} }
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event: FetchEvent) => { self.addEventListener('fetch', (event: FetchEvent) => {
const { url, method } = event.request; const { url, method } = event.request;
if (method !== 'GET') return;
if (
!url.includes('/_matrix/client/v1/media/download') &&
!url.includes('/_matrix/client/v1/media/thumbnail')
) {
return;
}
event.respondWith(
(async (): Promise<Response> => {
const client = await self.clients.get(event.clientId);
let token: string | undefined;
if (client) token = await askForAccessToken(client);
return fetch(url, fetchConfig(token)); if (method !== 'GET') return;
})() if (!event.clientId) return;
);
const session = sessions.get(event.clientId);
if (!session) return;
if (!validMediaRequest(url, session.baseUrl)) return;
event.respondWith(fetch(url, fetchConfig(session.accessToken)));
}); });