Merge remote-tracking branch 'origin/lotus' into fix/translate-unavailable-copy
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s

# Conflicts:
#	src/app/utils/translation/langUtils.ts
This commit is contained in:
Lotus CI
2026-10-18 23:37:54 -04:00
89 changed files with 2231 additions and 503 deletions
+25 -1
View File
@@ -1,6 +1,12 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
import {
CALL_REJOIN_MAX_AGE_MS,
decideRejoin,
othersInCall,
rejoinToastBody,
RejoinContext,
} from './callRejoin';
const base: RejoinContext = {
record: {
@@ -54,3 +60,21 @@ describe('decideRejoin', () => {
});
});
});
it('decideRejoin: DM calls are never auto-rejoined, only offered (#302)', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'auto', dm: true }), {
action: 'ask',
roomId: '!r',
});
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('rejoinToastBody and othersInCall (#302)', () => {
assert.equal(
rejoinToastBody('Bob Builder', true),
'You were in a call with Bob Builder before the restart. Tap to rejoin.',
);
assert.match(rejoinToastBody('Voice Lounge', false), /in the call in Voice Lounge/);
assert.equal(othersInCall(['@me:x'], '@me:x'), false);
assert.equal(othersInCall(['@me:x', '@bob:x'], '@me:x'), true);
});
+15
View File
@@ -67,6 +67,8 @@ export type RejoinContext = {
ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */
memberCount: number;
/** A DM call: rejoining one may ring the other person. */
dm?: boolean;
};
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
@@ -83,5 +85,18 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
// Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' };
// [Gitea #302] Never rejoin a DM call on our own: if the other person has
// just left, starting it again would ring them. Ask instead.
if (ctx.dm && mode === 'auto') return { action: 'ask', roomId: record.roomId };
return { action: mode, roomId: record.roomId };
}
/** Pure: whether anyone but us is still in the call (by call membership). */
export const othersInCall = (memberUserIds: string[], myUserId: string): boolean =>
memberUserIds.some((u) => u !== myUserId);
/** The rejoin toast's text; DMs name the person rather than "the call in <room>". */
export const rejoinToastBody = (roomName: string | undefined, dm: boolean): string =>
dm
? `You were in a call with ${roomName ?? 'them'} before the restart. Tap to rejoin.`
: `You were in the call in ${roomName ?? 'a room'} before the restart. Tap to rejoin.`;
+15
View File
@@ -67,3 +67,18 @@ test('declinedRing finds our own decline of that ring only (#312)', () => {
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
});
test('declinedRing matches a decline of the notification or of the membership (#313)', () => {
const decline = (rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => '@me:x',
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('$notification')]);
assert.equal(declinedRing(room, ['$membership', '$notification'], '@me:x'), true);
assert.equal(declinedRing(room, ['$membership'], '@me:x'), false);
assert.equal(
declinedRing(stateRoom([], [decline('$membership')]), ['$membership', undefined], '@me:x'),
true,
);
});
+13 -2
View File
@@ -40,7 +40,18 @@ export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
* the ring in the first sync after a restart, before any dismiss listener.
*/
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
export const declinedRing = (
room: Room,
ringIds: string | Array<string | undefined>,
myUserId: string,
): boolean => {
// [Gitea #313] A decline references the notification event (MSC4310); older
// Lotus builds referenced the call membership instead. Match either.
const ids = new Set((Array.isArray(ringIds) ? ringIds : [ringIds]).filter(Boolean));
return declinedAny(room, ids, myUserId);
};
const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string): boolean =>
room
.getLiveTimeline()
.getEvents()
@@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string):
(ev) =>
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
ev.getSender() === myUserId &&
ev.getRelation()?.event_id === refEventId,
ids.has(ev.getRelation()?.event_id),
);
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { denoiseNotice } from './denoiseNotice';
test('denoiseNotice: says when ML is off or fell back (#260)', () => {
assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined);
assert.match(
denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '',
/^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/,
);
assert.match(
denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '',
/unfiltered audio/,
);
assert.equal(denoiseNotice('rnnoise', undefined), undefined);
assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined);
});
+29
View File
@@ -0,0 +1,29 @@
/**
* [Gitea #260] The fork reports what noise suppression actually runs as
* `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for
* a report, or undefined when what runs is what the user picked.
*/
export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown };
const MODEL_NAMES: Record<string, string> = {
rnnoise: 'RNNoise',
speex: 'Speex',
dtln: 'DTLN',
deepfilternet: 'DeepFilterNet 3',
};
const modelName = (m: unknown): string =>
typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model';
export function denoiseNotice(
requestedModel: string,
report: DenoiseStateReport | undefined,
): string | undefined {
if (!report || typeof report.active !== 'boolean') return undefined;
if (!report.active) {
return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`;
}
if (typeof report.model === 'string' && report.model !== requestedModel) {
return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`;
}
return undefined;
}
+20
View File
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { IRoomTimelineData } from 'matrix-js-sdk';
import { isFromFilteredTimelineSet } from './room';
const data = (filter: unknown): IRoomTimelineData =>
({
liveEvent: true,
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
}) as unknown as IRoomTimelineData;
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
assert.equal(isFromFilteredTimelineSet(undefined), false);
assert.equal(
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
false,
);
});
+31 -2
View File
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatFileSize, isCompressible } from './imageCompression';
import {
formatFileSize,
hasTransparency,
isAnimatedWebp,
isCompressible,
} from './imageCompression';
test('formatFileSize formats bytes below 1KB as B', () => {
assert.equal(formatFileSize(0), '0 B');
@@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => {
test('isCompressible accepts raster image types', () => {
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
});
test('isCompressible rejects svg, empty type, and non-images', () => {
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
// [Gitea #331] usually animated; a canvas keeps one frame
assert.equal(isCompressible({ type: 'image/gif' } as Blob), false);
assert.equal(isCompressible({ type: 'image/apng' } as Blob), false);
assert.equal(isCompressible({ type: '' } as Blob), false);
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
});
const webpHeader = (chunk: string, flags = 0) => {
const b = new Uint8Array(32);
const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at);
put(0, 'RIFF');
put(8, 'WEBP');
put(12, chunk);
b[20] = flags;
return b;
};
test('isAnimatedWebp: VP8X with the animation flag (#331)', () => {
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true);
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only');
assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy');
assert.equal(isAnimatedWebp(new Uint8Array(8)), false);
});
test('hasTransparency (#331)', () => {
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false);
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true);
});
+43 -5
View File
@@ -8,11 +8,32 @@ export type CompressionResult = {
height: number;
};
// Any raster image the browser can render to canvas can be compressed to JPEG.
// SVG is vector and must stay as-is; GIF animation is lost on canvas but static
// frames compress fine. Empty type string (undetected MIME) is excluded.
// Raster images the browser can render to canvas can be compressed to JPEG.
// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually
// animated, and a canvas keeps only the first frame, so they aren't offered.
// Empty type string (undetected MIME) is excluded.
const NOT_COMPRESSIBLE = new Set(['image/svg+xml', 'image/gif', 'image/apng']);
const isCompressibleType = (type: string): boolean =>
type.startsWith('image/') && type !== 'image/svg+xml' && type !== '';
type.startsWith('image/') && !NOT_COMPRESSIBLE.has(type);
/**
* Pure: whether a WebP file is animated. An animated WebP is a VP8X file
* whose header has the animation flag (byte 20, bit 0x02) and an ANIM chunk.
*/
export function isAnimatedWebp(bytes: Uint8Array): boolean {
const ascii = (at: number, len: number) => String.fromCharCode(...bytes.subarray(at, at + len));
if (bytes.length < 21 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WEBP') return false;
// Bit 0x02 of the VP8X flags byte (no-bitwise: test it arithmetically).
return ascii(12, 4) === 'VP8X' && Math.floor(bytes[20] / 2) % 2 === 1;
}
/** Pure: whether RGBA pixel data has any pixel that isn't fully opaque. */
export function hasTransparency(rgba: Uint8ClampedArray): boolean {
for (let i = 3; i < rgba.length; i += 4) {
if (rgba[i] !== 255) return true;
}
return false;
}
/** Returns true if this file can be compressed via canvas (any raster image type). */
export function isCompressible(file: File | Blob): boolean {
@@ -28,7 +49,8 @@ const JPEG_OUTPUT_TYPE = 'image/jpeg';
*
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
* JPEG composites transparency onto an opaque (black) background, corrupting the
* image. Returning null makes callers fall back to uploading the lossless
* image. Any other image with transparent pixels, and animated WebP, are left
* alone for the same reason (#331). Returning null makes callers fall back to uploading the lossless
* original. The image is decoded with `imageOrientation: 'from-image'` so any
* EXIF orientation is baked into the pixels instead of being silently dropped.
*/
@@ -39,6 +61,15 @@ export async function compressImage(
if (!isCompressibleType(file.type)) return null;
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
if (file.type === 'image/png') return null;
// [Gitea #331] An animated WebP would become a single still frame.
if (file.type === 'image/webp') {
try {
const head = new Uint8Array(await file.slice(0, 32).arrayBuffer());
if (isAnimatedWebp(head)) return null;
} catch {
return null;
}
}
let bitmap: ImageBitmap;
try {
@@ -60,6 +91,13 @@ export async function compressImage(
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
// [Gitea #331] JPEG has no alpha: a transparent WebP (sticker, logo) came out
// with a black background. Keep any image with transparency as the original.
try {
if (hasTransparency(ctx.getImageData(0, 0, width, height).data)) return null;
} catch {
return null;
}
return new Promise((resolve) => {
canvas.toBlob(
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupeInFlight } from './inFlight';
test('dedupeInFlight: one request per key while pending, a new one after (#318)', async () => {
const once = dedupeInFlight<number>();
let calls = 0;
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
const run = async () => {
calls += 1;
await gate;
return calls;
};
const a = once('d1', run);
const b = once('d1', run);
const c = once('d2', run);
assert.equal(a, b);
release();
assert.deepEqual(await Promise.all([a, b, c]), [2, 2, 2]);
assert.equal(calls, 2);
await once('d1', run);
assert.equal(calls, 3, 'settled requests are not reused');
});
test('dedupeInFlight: a rejection clears the key', async () => {
const once = dedupeInFlight<void>();
await assert.rejects(once('k', () => Promise.reject(new Error('boom'))));
await once('k', () => Promise.resolve());
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Share one in-flight promise per key: callers that ask again while a request
* for the same key is still pending get that request instead of a new one.
*/
export function dedupeInFlight<T>(): (key: string, run: () => Promise<T>) => Promise<T> {
const pending = new Map<string, Promise<T>>();
return (key, run) => {
const existing = pending.get(key);
if (existing) return existing;
const p = run().finally(() => pending.delete(key));
pending.set(key, p);
return p;
};
}
+25
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
plain,
DEFAULT_GROUPS,
kumaBeatTime,
kumaUrls,
@@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
'server-down',
);
});
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
assert.equal(
plain('join #lotus-general or #announcements:lotusguild.org'),
'join #lotus-general or #announcements:lotusguild.org',
);
assert.equal(
plain('ping @some_user about snake_case and C#'),
'ping @some_user about snake_case and C#',
);
assert.equal(
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
'calls -> fixed, > 5 people, 5 * 3 = 15',
);
assert.equal(
plain('# Heading\n* item one\n- item two\n> quoted'),
'Heading\nitem one\nitem two\nquoted',
);
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
assert.equal(
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
'Read the guide docs',
);
});
+16 -3
View File
@@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => {
};
/** Markdown-ish incident text as plain text for the Details line. */
const plain = (s: string): string =>
/**
* [Gitea #308] Markdown → plain text for the Details line. Strips only the
* syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case",
* "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway).
*/
export const plain = (s: string): string =>
s
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`#>]/g, '')
.replace(/\s+\n/g, '\n')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*[*+-][ \t]+/gm, '')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1')
.replace(/__(?=\S)([^_\n]*?\S)__/g, '$1')
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2')
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
.replace(/[ \t]+\n/g, '\n')
.trim();
/**
+23 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { analyzeLink, hostFromText, registrableDomain } from './linkSafety';
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
test('visible text that names another site is a mismatch', () => {
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
@@ -73,3 +73,25 @@ test('shared hosting suffixes are separate sites (#335)', () => {
);
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
});
test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => {
const urls = ['https://paypal.com'];
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://evil.example/login">https://paypal.com</a>'),
[],
);
// An honest link keeps its preview.
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://www.paypal.com/x">https://paypal.com</a>'),
urls,
);
assert.deepEqual(previewUrlsFor(urls, undefined), urls);
// Other links in the message keep theirs.
assert.deepEqual(
previewUrlsFor(
['https://paypal.com', 'https://lotusguild.org'],
'see <a href="https://evil.example">paypal.com</a> and https://lotusguild.org',
),
['https://lotusguild.org'],
);
});
+34
View File
@@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
const other = shown.find((h) => registrableDomain(h) !== realSite);
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
};
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
const decodeEntities = (s: string): string =>
s
.replace(/<[^>]+>/g, '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
/**
* [Gitea #286] The URLs to build link previews for. Previews come from the
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
* controls both: text reading "https://paypal.com" linked to evil.example got
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
* deceptive link's text.
*/
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
if (!formattedBody || urls.length === 0) return urls;
const claimed = new Set<string>();
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
});
if (claimed.size === 0) return urls;
return urls.filter((u) => {
try {
return !claimed.has(registrableDomain(new URL(u).hostname));
} catch {
return true;
}
});
};
+12
View File
@@ -20,6 +20,7 @@ import {
Room,
RoomMember,
TimelineEvents,
IRoomTimelineData,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
@@ -847,3 +848,14 @@ export const guessPerfectParent = (
return perfectParent;
};
/**
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
* set. The media gallery and activity log register filtered sets on the room
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
* liveEvent: true, so listeners that mean "a new event in the room" saw each
* matching event twice (the timeline window drifted, notifications doubled).
* The room's main and thread sets have no filter.
*/
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
!!data?.timeline?.getTimelineSet()?.getFilter();
+60
View File
@@ -6,6 +6,8 @@ import {
scheduleMessage,
cancelScheduledMessage,
restartScheduledMessage,
parseDelayedEvents,
groupScheduledByRoom,
} from './scheduledMessages';
// Minimal MatrixClient stub: records every authedRequest call and returns a
@@ -119,3 +121,61 @@ test('cancel/restart url-encode the delay id', async () => {
await cancelScheduledMessage(mx, 'a/b c');
assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c');
});
test('parseDelayedEvents: Synapse shape, messages only, sendAt from running_since + delay (#322)', () => {
const res = {
delayed_events: [
{
delay_id: 'syd_a',
room_id: '!r:x',
type: 'm.room.message',
content: { msgtype: 'm.text', body: 'hi' },
delay: 60000,
running_since: 1000,
},
{
delay_id: 'syd_call',
room_id: '!r:x',
type: 'org.matrix.msc3401.call.member',
state_key: '_@a:x_D',
content: {},
delay: 8000,
running_since: 1000,
},
{ delay_id: 'syd_bad', room_id: '!r:x', type: 'm.room.message', content: {} },
null,
],
};
assert.deepEqual(parseDelayedEvents(res), [
{ delayId: 'syd_a', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' }, sendAt: 61000 },
]);
});
test('parseDelayedEvents: MSC `scheduled` shape and garbage', () => {
const item = {
delay_id: 'd',
room_id: '!r:x',
type: 'm.room.message',
content: { body: 'b' },
delay_ms: 5,
delayed_since_ts: 10,
};
assert.equal(parseDelayedEvents({ scheduled: [item] })[0].sendAt, 15);
assert.deepEqual(parseDelayedEvents(undefined), []);
assert.deepEqual(parseDelayedEvents({ delayed_events: 'nope' }), []);
});
test('groupScheduledByRoom: groups and sorts by sendAt', () => {
const m = (delayId: string, roomId: string, sendAt: number) => ({
delayId,
roomId,
content: {},
sendAt,
});
const g = groupScheduledByRoom([m('b', '!1', 20), m('a', '!1', 10), m('c', '!2', 5)]);
assert.deepEqual(
g.get('!1')?.map((x) => x.delayId),
['a', 'b'],
);
assert.equal(g.get('!2')?.length, 1);
});
+59
View File
@@ -1,4 +1,5 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/**
* Schedule a message via MSC4140 (delayed messages).
@@ -76,3 +77,61 @@ export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string)
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
);
}
type DelayedEventItem = {
delay_id?: unknown;
room_id?: unknown;
type?: unknown;
state_key?: unknown;
content?: unknown;
delay?: unknown;
delay_ms?: unknown;
running_since?: unknown;
delayed_since_ts?: unknown;
};
/**
* [Gitea #322] Pure: the user's pending scheduled messages from an MSC4140
* `GET /delayed_events` response. Synapse answers `{ delayed_events: [...] }`;
* the current MSC shape is `{ scheduled: [...] }`. Call-membership delayed
* events share the endpoint, so only non-state `m.room.message`s are kept.
*/
export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
if (!res || typeof res !== 'object') return [];
const r = res as { delayed_events?: unknown; scheduled?: unknown };
const list = Array.isArray(r.delayed_events) ? r.delayed_events : r.scheduled;
if (!Array.isArray(list)) return [];
const out: ScheduledMessage[] = [];
list.forEach((raw: DelayedEventItem) => {
if (!raw || typeof raw !== 'object') return;
if (raw.type !== 'm.room.message' || raw.state_key !== undefined) return;
if (typeof raw.delay_id !== 'string' || typeof raw.room_id !== 'string') return;
if (!raw.content || typeof raw.content !== 'object') return;
const delay = Number(raw.delay ?? raw.delay_ms);
const since = Number(raw.running_since ?? raw.delayed_since_ts);
if (!Number.isFinite(delay) || !Number.isFinite(since)) return;
out.push({
delayId: raw.delay_id,
roomId: raw.room_id,
content: raw.content as IContent,
sendAt: since + delay,
});
});
return out;
}
/** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
});
return parseDelayedEvents(res);
}
/** Group by room, the shape of `scheduledMessagesAtom`. */
export function groupScheduledByRoom(msgs: ScheduledMessage[]): Map<string, ScheduledMessage[]> {
const map = new Map<string, ScheduledMessage[]>();
msgs.forEach((m) => map.set(m.roomId, [...(map.get(m.roomId) ?? []), m]));
map.forEach((list) => list.sort((a, b) => a.sendAt - b.sendAt));
return map;
}
+6
View File
@@ -95,3 +95,9 @@ export const translationUnavailableText = (desktopApp: boolean): string =>
desktopApp
? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.'
: 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.';
/** Pure: why a manual Translate did nothing (#287). */
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
from
? `This message is already in ${languageName(targetLang) || targetLang}.`
: "Couldn't tell what language this message is in, so it wasn't translated.";
@@ -0,0 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { translateSkipMessage } from './langUtils';
test('translateSkipMessage explains a Translate that did nothing (#287)', () => {
assert.equal(translateSkipMessage('en', 'en'), 'This message is already in English.');
assert.match(translateSkipMessage(undefined, 'en'), /^Couldn't tell what language/);
});