Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8576cbb1f |
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.23",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
|
||||
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
||||
"version": "0.25.0-lotus.22",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
|
||||
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Setup Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Reset Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -139,11 +139,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.allowFederation,
|
||||
data.additionalCreators,
|
||||
),
|
||||
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||
// voice rooms: the call button is shown in private group rooms too, and with
|
||||
// Synapse's state_default (50) only admins could start or join a call there
|
||||
// ("You don't have permission to join!").
|
||||
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
||||
Create Poll
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
||||
Report Room
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
||||
Report User
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -725,13 +725,7 @@ export const MessageDeleteItem = as<
|
||||
Delete Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -871,13 +865,7 @@ export const MessageReportItem = as<
|
||||
Report Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -69,15 +69,8 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
// [Gitea #305] The width the chips actually get (content box). The border
|
||||
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
|
||||
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
|
||||
const cs = getComputedStyle(container);
|
||||
const rowWidth =
|
||||
container.clientWidth -
|
||||
(parseFloat(cs.paddingLeft) || 0) -
|
||||
(parseFloat(cs.paddingRight) || 0);
|
||||
const gap = parseFloat(cs.columnGap) || 8;
|
||||
const rowWidth = container.getBoundingClientRect().width;
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||
@@ -117,21 +110,8 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
style={{
|
||||
...props.style,
|
||||
...(collapsed && rowHeight
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||
// margins so the timeline's marginTop survives (Gitea #305: the
|
||||
// `margin` shorthand overrode it and, once removed, cleared it).
|
||||
{
|
||||
maxHeight: rowHeight + 4,
|
||||
overflow: 'hidden',
|
||||
padding: 2,
|
||||
marginLeft: -2,
|
||||
marginRight: -2,
|
||||
marginBottom: -2,
|
||||
marginTop:
|
||||
props.style?.marginTop !== undefined
|
||||
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
|
||||
: -2,
|
||||
}
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
||||
: {}),
|
||||
}}
|
||||
ref={(el) => {
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -508,12 +509,16 @@ function ProfileStatus() {
|
||||
if (msg && delayMs > 0) {
|
||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
|
||||
const expiresAt = Date.now() + delayMs;
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt));
|
||||
// [Gitea #300] …and on every other Lotus device, via account data.
|
||||
writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, msg).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[saveStatus, userId],
|
||||
[saveStatus, userId, mx],
|
||||
);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
@@ -553,6 +558,7 @@ function ProfileStatus() {
|
||||
setStatusMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
mx.setPresence({
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
shouldNotifyThreadReply,
|
||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||
} from '../../utils/threadNotifications';
|
||||
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
|
||||
|
||||
// Grace period after the initial sync settles before invite notifications arm, so
|
||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||
@@ -427,12 +428,7 @@ function StatusExpiryMonitor() {
|
||||
// permanently and the status stayed set forever. Retry the write (honouring
|
||||
// Retry-After), and only forget the status once the server has taken it.
|
||||
let clearing = false;
|
||||
const check = () => {
|
||||
if (clearing) return;
|
||||
const stored = localStorage.getItem(expiryKey);
|
||||
if (!stored) return;
|
||||
const ts = parseInt(stored, 10);
|
||||
if (!ts || Date.now() < ts) return;
|
||||
const clearStatus = () => {
|
||||
clearing = true;
|
||||
setPresenceWithRetry(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||
@@ -442,12 +438,31 @@ function StatusExpiryMonitor() {
|
||||
noteLocalStatusWrite();
|
||||
localStorage.removeItem(msgKey);
|
||||
localStorage.removeItem(expiryKey);
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
clearing = false;
|
||||
});
|
||||
};
|
||||
const check = () => {
|
||||
if (clearing) return;
|
||||
const stored = localStorage.getItem(expiryKey);
|
||||
const ts = stored ? parseInt(stored, 10) : 0;
|
||||
if (ts && Date.now() >= ts) {
|
||||
clearStatus();
|
||||
return;
|
||||
}
|
||||
// [Gitea #300] An expiry set on another device (account data): clear the
|
||||
// status if it is still that one, otherwise just forget the expiry.
|
||||
const action = statusExpiryAction(
|
||||
readStatusExpiry(mx),
|
||||
mx.getUser(userId)?.presenceStatusMsg,
|
||||
Date.now(),
|
||||
);
|
||||
if (action === 'clear') clearStatus();
|
||||
else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
};
|
||||
|
||||
check();
|
||||
// Poll often enough that a rate-limited retry lands promptly, but not so
|
||||
@@ -571,8 +586,7 @@ function MessageNotifications() {
|
||||
// NotificationOptions typing.
|
||||
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
||||
}),
|
||||
// userId: [Gitea #299] the SW replies as this account only.
|
||||
data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() },
|
||||
data: { path: roomPath, roomId, threadId: threadId || undefined },
|
||||
},
|
||||
() => {
|
||||
window.focus();
|
||||
@@ -580,7 +594,7 @@ function MessageNotifications() {
|
||||
},
|
||||
);
|
||||
},
|
||||
[navigate, setToast, mDirects, mx],
|
||||
[navigate, setToast, mDirects],
|
||||
);
|
||||
|
||||
// N105: when a service-worker-owned notification is clicked, the SW focuses
|
||||
|
||||
@@ -31,7 +31,6 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { getCallPageUrl } from './callPageUrl';
|
||||
import { restrictWidgetMessages } from '../widgetTransport';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -252,18 +251,8 @@ export class CallEmbed {
|
||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||
}
|
||||
|
||||
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
|
||||
// (DMs, invite-only groups outside a space). In a voice channel or space
|
||||
// room the notification carries an @room mention, so every member got a
|
||||
// highlighted ping (and phone push) whenever a moderator hopped in, and
|
||||
// other clients showed it as a missed call.
|
||||
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
|
||||
if (CallEmbed.startingCall(intent)) {
|
||||
let notify = 'none';
|
||||
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
|
||||
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
|
||||
}
|
||||
params.append('sendNotificationType', notify);
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
}
|
||||
|
||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseStatusExpiry, statusExpiryAction } from './statusExpiry';
|
||||
|
||||
test('parseStatusExpiry (#300)', () => {
|
||||
assert.deepEqual(parseStatusExpiry({ status_msg: '🍔 Lunch', expires_at: 5 }), {
|
||||
statusMsg: '🍔 Lunch',
|
||||
expiresAt: 5,
|
||||
});
|
||||
assert.equal(parseStatusExpiry({}), undefined);
|
||||
assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined);
|
||||
assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined);
|
||||
});
|
||||
|
||||
test('statusExpiryAction: clears only the status that was set to expire (#300)', () => {
|
||||
const e = { statusMsg: '🍔 Lunch', expiresAt: 1000 };
|
||||
assert.equal(statusExpiryAction(e, '🍔 Lunch', 999), 'none');
|
||||
assert.equal(statusExpiryAction(e, '🍔 Lunch', 1000), 'clear');
|
||||
assert.equal(statusExpiryAction(e, '🎮 Gaming', 2000), 'forget');
|
||||
assert.equal(statusExpiryAction(e, '', 2000), 'forget');
|
||||
assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { getAccountData } from './room';
|
||||
|
||||
/**
|
||||
* [Gitea #300] The status message's auto-clear time, in account data so every
|
||||
* Lotus device knows it. Before, only the device that set "clear after 1 hour"
|
||||
* (localStorage) ever cleared it, and only while it was open.
|
||||
*/
|
||||
export type StatusExpiry = { statusMsg: string; expiresAt: number };
|
||||
|
||||
/** Pure: parse `io.lotus.status_expiry` content. */
|
||||
export function parseStatusExpiry(content: unknown): StatusExpiry | undefined {
|
||||
const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined;
|
||||
if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined;
|
||||
if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined;
|
||||
return { statusMsg: c.status_msg, expiresAt: c.expires_at };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: what to do with a due expiry. Clear the server status only if it is
|
||||
* still the one that was set to expire; if it has since changed (e.g. on
|
||||
* another device), just forget the expiry.
|
||||
*/
|
||||
export function statusExpiryAction(
|
||||
entry: StatusExpiry | undefined,
|
||||
currentStatusMsg: string | undefined,
|
||||
now: number,
|
||||
): 'none' | 'clear' | 'forget' {
|
||||
if (!entry || now < entry.expiresAt) return 'none';
|
||||
return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget';
|
||||
}
|
||||
|
||||
export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined =>
|
||||
parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent());
|
||||
|
||||
/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */
|
||||
export async function writeStatusExpiry(
|
||||
mx: MatrixClient,
|
||||
statusMsg: string,
|
||||
expiresAt?: number,
|
||||
): Promise<void> {
|
||||
const content =
|
||||
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
|
||||
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ if ('serviceWorker' in navigator && swProtocolOk) {
|
||||
|
||||
const sendSessionToSW = () => {
|
||||
const session = getFallbackSession();
|
||||
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
|
||||
pushSessionToSW(session?.baseUrl, session?.accessToken);
|
||||
};
|
||||
|
||||
// The dev worker is an ES module (it imports workbox), so it must be
|
||||
|
||||
+1
-2
@@ -1,4 +1,4 @@
|
||||
export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) {
|
||||
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (!navigator.serviceWorker.controller) return;
|
||||
|
||||
@@ -6,6 +6,5 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?:
|
||||
type: 'setSession',
|
||||
accessToken,
|
||||
baseUrl,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||
import { sendNotificationReply } from './swReply';
|
||||
|
||||
export type {};
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
@@ -40,8 +40,6 @@ precacheAndRoute(self.__WB_MANIFEST);
|
||||
type SessionInfo = {
|
||||
accessToken: string;
|
||||
baseUrl: string;
|
||||
/** [Gitea #299] whose session this is, so a notification reply uses its own account */
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -65,13 +63,9 @@ async function cleanupDeadClients() {
|
||||
});
|
||||
}
|
||||
|
||||
function setSession(clientId: string, accessToken: string, baseUrl: string, userId?: unknown) {
|
||||
function setSession(clientId: string, accessToken: string, baseUrl: string) {
|
||||
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
||||
sessions.set(clientId, {
|
||||
accessToken,
|
||||
baseUrl,
|
||||
userId: typeof userId === 'string' ? userId : undefined,
|
||||
});
|
||||
sessions.set(clientId, { accessToken, baseUrl });
|
||||
} else {
|
||||
// Logout or invalid session
|
||||
sessions.delete(clientId);
|
||||
@@ -136,10 +130,10 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
const client = event.source as Client | null;
|
||||
if (!client) return;
|
||||
|
||||
const { type, accessToken, baseUrl, userId } = event.data || {};
|
||||
const { type, accessToken, baseUrl } = event.data || {};
|
||||
|
||||
if (type === 'setSession') {
|
||||
setSession(client.id, accessToken, baseUrl, userId);
|
||||
setSession(client.id, accessToken, baseUrl);
|
||||
cleanupDeadClients();
|
||||
}
|
||||
});
|
||||
@@ -161,19 +155,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
if (text) {
|
||||
event.waitUntil(
|
||||
sendNotificationReply(
|
||||
// [Gitea #299] The notification's own account, not the newest session.
|
||||
pickReplySession(
|
||||
Array.from(sessions.values()),
|
||||
typeof data.userId === 'string' ? data.userId : undefined,
|
||||
),
|
||||
Array.from(sessions.values()).pop(),
|
||||
data.roomId,
|
||||
data.threadId,
|
||||
text,
|
||||
).then((result) => {
|
||||
if (result !== 'sent') {
|
||||
).then((ok) => {
|
||||
if (!ok) {
|
||||
// Let the user know it didn't go out, and keep their text reachable.
|
||||
return self.registration.showNotification('Reply not sent', {
|
||||
body: replyFailureText(result, text),
|
||||
body: `Couldn't send "${text.slice(0, 60)}" — open the app to retry.`,
|
||||
tag: `swreply-fail-${data.roomId}`,
|
||||
data: { path },
|
||||
});
|
||||
|
||||
+30
-60
@@ -1,12 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildReplyContent,
|
||||
pickReplySession,
|
||||
replyFailureText,
|
||||
replySendUrl,
|
||||
sendNotificationReply,
|
||||
} from './swReply';
|
||||
import { buildReplyContent, replySendUrl, sendNotificationReply } from './swReply';
|
||||
|
||||
test('thread replies carry the m.thread relation, room replies do not', () => {
|
||||
assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} });
|
||||
@@ -25,64 +19,40 @@ test('send URL encodes the room id against the session base URL', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('sendNotificationReply checks encryption, then PUTs with the bearer token (#203, #299)', async () => {
|
||||
test('sendNotificationReply PUTs with the bearer token and reports ok / failure', async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchFor = (encStatus: number, sendOk = true) =>
|
||||
(async (url: string, init: RequestInit = {}) => {
|
||||
calls.push({ url, init });
|
||||
if (url.includes('/state/m.room.encryption/')) {
|
||||
return { ok: encStatus === 200, status: encStatus } as Response;
|
||||
}
|
||||
return { ok: sendOk, status: sendOk ? 200 : 500 } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
const session = { accessToken: 'tok', baseUrl: 'https://hs' };
|
||||
|
||||
assert.equal(await sendNotificationReply(session, '!r:hs', '$t', 'yo', fetchFor(404)), 'sent');
|
||||
assert.match(calls[0].url, /\/rooms\/!r%3Ahs\/state\/m\.room\.encryption\/$/);
|
||||
const put = calls[1];
|
||||
assert.equal(put.init.method, 'PUT');
|
||||
assert.equal((put.init.headers as Record<string, string>).Authorization, 'Bearer tok');
|
||||
const fetchOk = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return { ok: true } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
assert.equal(
|
||||
await sendNotificationReply(
|
||||
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
||||
'!r:hs',
|
||||
'$t',
|
||||
'yo',
|
||||
fetchOk,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(calls[0].init.method, 'PUT');
|
||||
assert.equal((calls[0].init.headers as Record<string, string>).Authorization, 'Bearer tok');
|
||||
assert.match(
|
||||
put.url,
|
||||
calls[0].url,
|
||||
/^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/,
|
||||
);
|
||||
assert.equal(JSON.parse(put.init.body as string)['m.relates_to'].event_id, '$t');
|
||||
assert.equal(JSON.parse(calls[0].init.body as string)['m.relates_to'].event_id, '$t');
|
||||
|
||||
// Encryption turned on since the notification was shown: nothing is sent.
|
||||
calls.length = 0;
|
||||
const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch;
|
||||
assert.equal(
|
||||
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(200)),
|
||||
'encrypted',
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
// Can't tell (server error): fail safe, nothing is sent.
|
||||
calls.length = 0;
|
||||
assert.equal(
|
||||
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(500)),
|
||||
'failed',
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(404, false)),
|
||||
'failed',
|
||||
);
|
||||
assert.equal(
|
||||
await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchFor(404)),
|
||||
'no-session',
|
||||
await sendNotificationReply(
|
||||
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
||||
'!r:hs',
|
||||
undefined,
|
||||
'yo',
|
||||
fetchFail,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('pickReplySession: the notification account only (#299)', () => {
|
||||
const a = { accessToken: 'a', baseUrl: 'https://hs', userId: '@a:hs' };
|
||||
const b = { accessToken: 'b', baseUrl: 'https://hs', userId: '@b:hs' };
|
||||
assert.equal(pickReplySession([a, b], '@a:hs'), a);
|
||||
assert.equal(pickReplySession([b], '@a:hs'), undefined, 'logged out of @a here');
|
||||
assert.equal(pickReplySession([a], undefined), a, 'old notification, one session');
|
||||
assert.equal(pickReplySession([a, b], undefined), undefined, 'old notification, ambiguous');
|
||||
});
|
||||
|
||||
test('replyFailureText says why', () => {
|
||||
assert.match(replyFailureText('encrypted', 'hi'), /now encrypted/);
|
||||
assert.match(replyFailureText('no-session', 'hi'), /no longer signed in/);
|
||||
assert.match(replyFailureText('failed', 'hi'), /retry/);
|
||||
assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false);
|
||||
});
|
||||
|
||||
+11
-48
@@ -1,42 +1,9 @@
|
||||
/**
|
||||
* [Gitea #203] Build and send an inline reply typed into a notification's
|
||||
* text action. Lives outside sw.ts so it can be unit-tested; the SW passes in
|
||||
* the notification account's session and the global fetch.
|
||||
* its newest known session and the global fetch.
|
||||
*/
|
||||
export type SwReplySession = { accessToken: string; baseUrl: string; userId?: string };
|
||||
|
||||
export type SwReplyResult = 'sent' | 'failed' | 'encrypted' | 'no-session';
|
||||
|
||||
/**
|
||||
* [Gitea #299] The session to reply with: the notification's own account. A
|
||||
* notification without a user id (shown before this change) may only use the
|
||||
* session if it is the only one, so a reply never posts as another account.
|
||||
*/
|
||||
export const pickReplySession = (
|
||||
sessions: SwReplySession[],
|
||||
userId: string | undefined,
|
||||
): SwReplySession | undefined => {
|
||||
if (userId) return sessions.filter((x) => x.userId === userId).pop();
|
||||
return sessions.length === 1 ? sessions[0] : undefined;
|
||||
};
|
||||
|
||||
/** What the "Reply not sent" notification says for each failure. */
|
||||
export const replyFailureText = (result: SwReplyResult, text: string): string => {
|
||||
const quoted = `"${text.slice(0, 60)}"`;
|
||||
if (result === 'encrypted') {
|
||||
return `This room is now encrypted, so ${quoted} wasn't sent. Open the app to reply.`;
|
||||
}
|
||||
if (result === 'no-session') {
|
||||
return `You're no longer signed in to that account here, so ${quoted} wasn't sent.`;
|
||||
}
|
||||
return `Couldn't send ${quoted}. Open the app to retry.`;
|
||||
};
|
||||
|
||||
export const encryptionStateUrl = (baseUrl: string, roomId: string): string =>
|
||||
new URL(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
|
||||
baseUrl,
|
||||
).href;
|
||||
export type SwReplySession = { accessToken: string; baseUrl: string };
|
||||
|
||||
export const buildReplyContent = (text: string, threadId?: string): Record<string, unknown> => {
|
||||
const content: Record<string, unknown> = { msgtype: 'm.text', body: text, 'm.mentions': {} };
|
||||
@@ -63,24 +30,20 @@ export async function sendNotificationReply(
|
||||
threadId: string | undefined,
|
||||
text: string,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
): Promise<SwReplyResult> {
|
||||
if (!session?.accessToken || !session.baseUrl) return 'no-session';
|
||||
const headers = { Authorization: `Bearer ${session.accessToken}` };
|
||||
): Promise<boolean> {
|
||||
if (!session?.accessToken || !session.baseUrl) return false;
|
||||
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
// [Gitea #299] The SW can't encrypt, and the reply action was offered when
|
||||
// the notification was shown — possibly hours ago. Re-check now: only a
|
||||
// definite "no encryption state" (404) lets the plain reply go out.
|
||||
const enc = await fetchFn(encryptionStateUrl(session.baseUrl, roomId), { headers });
|
||||
if (enc.ok) return 'encrypted';
|
||||
if (enc.status !== 404) return 'failed';
|
||||
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(buildReplyContent(text, threadId)),
|
||||
});
|
||||
return res.ok ? 'sent' : 'failed';
|
||||
return res.ok;
|
||||
} catch {
|
||||
return 'failed';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export enum AccountDataEvent {
|
||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||
LotusSettings = 'io.lotus.settings',
|
||||
|
||||
// [Gitea #300] When the status message auto-clears: { status_msg, expires_at },
|
||||
// so any Lotus device can clear it (not only the one that set it).
|
||||
LotusStatusExpiry = 'io.lotus.status_expiry',
|
||||
|
||||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||
|
||||
CrossSigningMaster = 'm.cross_signing.master',
|
||||
|
||||
Reference in New Issue
Block a user