Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2142e6c4 | ||
|
|
4236621c7a | ||
|
|
45afc9ba7e | ||
|
|
fd87a27251 |
+12
-12
@@ -11,58 +11,58 @@
|
|||||||
"theme_color": "#980000",
|
"theme_color": "#980000",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-36x36.png",
|
"src": "./public/res/android/android-chrome-36x36.png",
|
||||||
"sizes": "36x36",
|
"sizes": "36x36",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-48x48.png",
|
"src": "./public/res/android/android-chrome-48x48.png",
|
||||||
"sizes": "48x48",
|
"sizes": "48x48",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-72x72.png",
|
"src": "./public/res/android/android-chrome-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-96x96.png",
|
"src": "./public/res/android/android-chrome-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-144x144.png",
|
"src": "./public/res/android/android-chrome-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-192x192.png",
|
"src": "./public/res/android/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-256x256.png",
|
"src": "./public/res/android/android-chrome-256x256.png",
|
||||||
"sizes": "256x256",
|
"sizes": "256x256",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-384x384.png",
|
"src": "./public/res/android/android-chrome-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/android-chrome-512x512.png",
|
"src": "./public/res/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/maskable-192x192.png",
|
"src": "./public/res/android/maskable-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./res/android/maskable-512x512.png",
|
"src": "./public/res/android/maskable-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"url": "/",
|
"url": "/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "res/android/android-chrome-96x96.png",
|
"src": "public/res/android/android-chrome-96x96.png",
|
||||||
"sizes": "96x96"
|
"sizes": "96x96"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ function fetchDecoration(
|
|||||||
const waiters: Array<(val: string | null) => void> = [];
|
const waiters: Array<(val: string | null) => void> = [];
|
||||||
pending.set(userId, waiters);
|
pending.set(userId, waiters);
|
||||||
|
|
||||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
|
// Fetch the WHOLE profile, not the single `/{field}` sub-resource: an unset
|
||||||
|
// field returns 404, which the browser logs as a failed request — a console
|
||||||
|
// 404 for every user without a decoration. The full profile returns 200 with
|
||||||
|
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
|
||||||
|
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
cache.set(userId, val);
|
cache.set(userId, val);
|
||||||
@@ -37,12 +41,9 @@ function fetchDecoration(
|
|||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
||||||
// Definitive rejections — the field is unset (404) or the server won't
|
// Definitive rejections (404 unknown user / 403 can't view / 400) — cache
|
||||||
// serve it (400/403). This is the common case for FEDERATED users whose
|
// "no decoration" so we never refetch a profile we can't read (otherwise
|
||||||
// homeserver doesn't support extended profiles / rejects the field. Cache
|
// every avatar mount re-floods our HS with failing federated lookups).
|
||||||
// "no decoration" so we never refetch: otherwise every avatar mount
|
|
||||||
// re-requests and floods our homeserver with failing federated profile
|
|
||||||
// lookups (the 403/502 console storm + real HS load).
|
|
||||||
if (status === 404 || status === 403 || status === 400) {
|
if (status === 404 || status === 403 || status === 400) {
|
||||||
cache.set(userId, null);
|
cache.set(userId, null);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -216,15 +216,18 @@ test('Instagram: p / reel / tv → embed path', () => {
|
|||||||
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
|
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Reddit post embed → redditmedia', () => {
|
test('Reddit post embed → embed.reddit.com', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
|
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
|
||||||
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
|
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
|
||||||
);
|
);
|
||||||
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
|
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
|
||||||
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
|
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
|
||||||
// redd.it short link → comments-only embed (no subreddit in the redirect)
|
// redd.it short link / i.redd.it media host can't build the /r/<sub>/comments/<id>
|
||||||
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), 'https://embed.reddit.com/comments/1abc23/?ref_source=embed&ref=share&embed=true&theme=dark');
|
// path embed.reddit.com requires (a bare /comments/<id> 404s) → null so the caller's
|
||||||
|
// og:url fallback resolves the canonical URL first.
|
||||||
|
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), null);
|
||||||
|
assert.equal(getRedditPostEmbed('https://i.redd.it/abcd1234.jpg'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
|
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
|
||||||
|
|||||||
@@ -344,8 +344,9 @@ export function getInstagramEmbed(url: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Reddit (post embed via redditmedia — bypasses the homeserver's blocked
|
// --- Reddit (post embed via embed.reddit.com — reddit's own oEmbed widget host;
|
||||||
// preview fetch, which Reddit serves a bot-check "please wait" page to) ---
|
// www.redditmedia.com now 301s here. Bypasses the homeserver's blocked preview
|
||||||
|
// fetch, which Reddit serves a bot-check "please wait" page to) ---
|
||||||
|
|
||||||
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
|
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
|
||||||
|
|
||||||
@@ -353,15 +354,14 @@ export function getRedditPostEmbed(url: string): string | null {
|
|||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
|
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
|
||||||
// redd.it/<id> short link → reddit.com/comments/<id> (no subreddit needed).
|
// embed.reddit.com ONLY renders the /r/<sub>/comments/<id> path (verified against
|
||||||
if (h === 'redd.it') {
|
// reddit's own embed widgets.js). A bare /comments/<id> — all we could build from a
|
||||||
const id = u.pathname.replace(/^\/+|\/+$/g, '').split('/')[0];
|
// redd.it short link or an i./v.redd.it media host — serves a "not found" page, so
|
||||||
return id ? `https://embed.reddit.com/comments/${id}/${REDDIT_EMBED_QS}` : null;
|
// return null for those and let the caller's og:url fallback resolve the canonical
|
||||||
}
|
// /r/<sub>/comments/<id> URL (from the redirect) before re-parsing into a real embed.
|
||||||
if (h !== 'reddit.com') return null;
|
if (h !== 'reddit.com') return null;
|
||||||
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
|
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
|
|
||||||
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
|
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -12,7 +12,33 @@ import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
|
|||||||
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||||
export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KE-1 mitigation. Ask the browser to make this origin's storage persistent so the
|
||||||
|
* IndexedDB **crypto store** isn't evicted from under a surviving `localStorage`
|
||||||
|
* session. When that happens the device "resurrects" with a blank key store and the
|
||||||
|
* client re-uploads a one-time key at an id Synapse already holds → a permanent
|
||||||
|
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
||||||
|
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
|
||||||
|
* install and shows no prompt; denial/absence is non-fatal.
|
||||||
|
*/
|
||||||
|
export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
if (!navigator.storage?.persist) return false;
|
||||||
|
if (await navigator.storage.persisted()) return true;
|
||||||
|
const granted = await navigator.storage.persist();
|
||||||
|
if (!granted) {
|
||||||
|
console.warn('Persistent storage not granted — the crypto store remains evictable (KE-1).');
|
||||||
|
}
|
||||||
|
return granted;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||||
|
// Protect the crypto store from eviction before we create and write to it.
|
||||||
|
await requestPersistentStorage();
|
||||||
|
|
||||||
const indexedDBStore = new IndexedDBStore({
|
const indexedDBStore = new IndexedDBStore({
|
||||||
indexedDB: globalThis.indexedDB,
|
indexedDB: globalThis.indexedDB,
|
||||||
localStorage: globalThis.localStorage,
|
localStorage: globalThis.localStorage,
|
||||||
|
|||||||
+11
-2
@@ -38,9 +38,18 @@ const copyFiles = {
|
|||||||
rename: { stripBase: true },
|
rename: { stripBase: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'public/res/android',
|
// All of public/res/ (android + apple icons, logos, svg) → dist/public/res/,
|
||||||
|
// matching the /public/res/... paths used by index.html and manifest.json.
|
||||||
|
// (The old android-only target stripped `res/` and landed at /public/android/,
|
||||||
|
// so every icon 404'd — apple/ + favicon weren't copied at all.)
|
||||||
|
src: 'public/res',
|
||||||
dest: 'public/',
|
dest: 'public/',
|
||||||
rename: { stripBase: 2 },
|
rename: { stripBase: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'public/favicon.ico',
|
||||||
|
dest: 'public/',
|
||||||
|
rename: { stripBase: 1 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'public/locales',
|
src: 'public/locales',
|
||||||
|
|||||||
Reference in New Issue
Block a user