Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5feb2184be | ||
|
|
906a034e26 | ||
|
|
b855eb505d | ||
|
|
cc8e7444f6 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
f0b7400d1b | ||
|
|
3878a85339 | ||
|
|
9edb76835c | ||
|
|
1784d40ba1 | ||
|
|
76ef8a52c7 | ||
|
|
a3ef2b4098 | ||
|
|
0c5cf4109a | ||
|
|
39f74d51e6 | ||
|
|
36e697613b | ||
|
|
2d31312a73 | ||
|
|
57036bb6a4 | ||
|
|
c6419044cb | ||
|
|
29f6a35bfd | ||
|
|
78691f1fff | ||
|
|
a86947be48 | ||
|
|
37372b215a | ||
|
|
b6f9af3f58 | ||
|
|
8b2322f751 | ||
|
|
02950499ea | ||
|
|
05d7e87d26 | ||
|
|
bbb2034080 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
4650796120 | ||
|
|
d55a8ba8a5 | ||
|
|
4b8c3219e9 | ||
|
|
47328d6fee | ||
|
|
b0eb3da830 | ||
|
|
2700aad209 | ||
|
|
a8a7e639a9 |
+95
-2
@@ -1,7 +1,7 @@
|
|||||||
# Lotus Chat — Feature Reference
|
# Lotus Chat — Feature Reference
|
||||||
|
|
||||||
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
||||||
Last updated: July 2026.
|
Last updated: October 2026.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ Last updated: July 2026.
|
|||||||
19. [Infrastructure](#infrastructure)
|
19. [Infrastructure](#infrastructure)
|
||||||
20. [Localization](#localization)
|
20. [Localization](#localization)
|
||||||
21. [Desktop App Features](#desktop-app-features)
|
21. [Desktop App Features](#desktop-app-features)
|
||||||
22. [Key Custom Files](#key-custom-files)
|
22. [June–October 2026 Additions](#juneoctober-2026-additions)
|
||||||
|
23. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1522,6 +1523,98 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## June–October 2026 Additions
|
||||||
|
|
||||||
|
Features shipped since the sections above were last updated (Gitea #294). Each
|
||||||
|
line names the commit that introduced it.
|
||||||
|
|
||||||
|
### Calls
|
||||||
|
|
||||||
|
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
|
||||||
|
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
|
||||||
|
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
|
||||||
|
- **Mic level meter** on the mute button (`3449338b`, #146).
|
||||||
|
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
|
||||||
|
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
|
||||||
|
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
|
||||||
|
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
|
||||||
|
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
|
||||||
|
- **Live-call dot on the space rail** (`4fe9c870`, #148).
|
||||||
|
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
|
||||||
|
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
|
||||||
|
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
|
||||||
|
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
|
||||||
|
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
|
||||||
|
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
|
||||||
|
|
||||||
|
### Messaging & composer
|
||||||
|
|
||||||
|
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
|
||||||
|
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
|
||||||
|
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
|
||||||
|
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
|
||||||
|
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
|
||||||
|
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
|
||||||
|
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
|
||||||
|
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
|
||||||
|
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
|
||||||
|
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
|
||||||
|
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
|
||||||
|
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
|
||||||
|
|
||||||
|
### Search & navigation
|
||||||
|
|
||||||
|
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
|
||||||
|
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
|
||||||
|
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
|
||||||
|
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
|
||||||
|
|
||||||
|
### Rooms & threads
|
||||||
|
|
||||||
|
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
|
||||||
|
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
|
||||||
|
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
|
||||||
|
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
|
||||||
|
|
||||||
|
### Media viewer
|
||||||
|
|
||||||
|
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
|
||||||
|
- **Two-finger pinch zoom** (`39589b40`, #164).
|
||||||
|
|
||||||
|
### Profile, privacy & safety
|
||||||
|
|
||||||
|
- **Report User** (MSC4260) (`26f90087`).
|
||||||
|
- **Active now / Last active** from presence (`dea1f7af`, #150).
|
||||||
|
- **Delete all my messages** in a room (`edb46247`, #169).
|
||||||
|
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
|
||||||
|
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
|
||||||
|
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
|
||||||
|
|
||||||
|
### Appearance
|
||||||
|
|
||||||
|
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
|
||||||
|
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
|
||||||
|
|
||||||
|
### Mobile & PWA
|
||||||
|
|
||||||
|
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
|
||||||
|
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
|
||||||
|
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
|
||||||
|
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
|
||||||
|
|
||||||
|
### Desktop app
|
||||||
|
|
||||||
|
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
|
||||||
|
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
|
||||||
|
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
|
||||||
|
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
|
|||||||
|
|
||||||
## 🚫 Blocked Features (server / upstream gated)
|
## 🚫 Blocked Features (server / upstream gated)
|
||||||
|
|
||||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
|
||||||
|
|
||||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||||
|
|||||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.22",
|
"version": "0.25.0-lotus.23",
|
||||||
"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",
|
"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-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
|
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import {
|
import {
|
||||||
|
ClientEvent,
|
||||||
EventTimelineSetHandlerMap,
|
EventTimelineSetHandlerMap,
|
||||||
EventType,
|
EventType,
|
||||||
JoinRule,
|
SyncState,
|
||||||
RelationType,
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
@@ -72,7 +73,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
import { getStateEvent, getMemberName } from '../utils/room';
|
||||||
|
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
@@ -581,12 +583,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||||
// restricted rooms, and public rooms must never trigger ringing.
|
// restricted rooms, and public rooms must never trigger ringing.
|
||||||
if (room.isCallRoom()) return;
|
if (!isRingableRoom(room, directs.has(room.roomId))) return;
|
||||||
const isDirect = directs.has(room.roomId);
|
|
||||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
|
||||||
const joinRule = room.getJoinRule();
|
|
||||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
|
||||||
if (!isDirect && !isPrivateInviteGroup) return;
|
|
||||||
|
|
||||||
const info: IncomingCallInfo = {
|
const info: IncomingCallInfo = {
|
||||||
room,
|
room,
|
||||||
@@ -601,11 +598,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
refEventId,
|
refEventId,
|
||||||
};
|
};
|
||||||
|
|
||||||
setCallInfo(info);
|
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||||
|
// a reload the first sync replays the ring together with the caller's
|
||||||
|
// hang-up and our own decline. Check the room STATE (not the possibly
|
||||||
|
// stale RTC session). The caller's membership can arrive just after the
|
||||||
|
// ring, so an absent one gets a short grace period before giving up.
|
||||||
|
const myUserId = mx.getSafeUserId();
|
||||||
|
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasActiveCallMembership(room, sender)) {
|
||||||
|
setCallInfo(info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (Date.now() >= senderTs + lifetime) return;
|
||||||
|
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||||
|
}, 4000);
|
||||||
},
|
},
|
||||||
[mx, directs, callEmbed, setToast],
|
[mx, directs, callEmbed, setToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
|
||||||
|
// call's join and leave in one batch, and the RTC session may keep the join
|
||||||
|
// (stale "1 Live", call screen instead of chat). Once sync is live again,
|
||||||
|
// recompute every active session from the room's current state.
|
||||||
|
useEffect(() => {
|
||||||
|
const onSync = (state: SyncState, prev: SyncState | null) => {
|
||||||
|
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
|
||||||
|
mx.getRooms().forEach((r) => {
|
||||||
|
mx.matrixRTC
|
||||||
|
.getActiveRoomSession(r)
|
||||||
|
?._onRTCSessionMemberUpdate()
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.Sync, onSync);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.Sync, onSync);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -634,7 +670,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const { memberships } = session;
|
const { memberships } = session;
|
||||||
if (memberships.some((m) => m.sender === myUserId))
|
if (memberships.some((m) => m.sender === myUserId))
|
||||||
dismiss(); // answered elsewhere
|
dismiss(); // answered elsewhere
|
||||||
else if (memberships.length === 0) dismiss(); // caller gone
|
else if (memberships.length === 0)
|
||||||
|
dismiss(); // caller gone
|
||||||
|
// [Gitea #312] The session can be stale after a reload; the room state isn't.
|
||||||
|
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
|
||||||
};
|
};
|
||||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
Setup Device Verification
|
Setup Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
Reset Device Verification
|
Reset Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||||||
data.allowFederation,
|
data.allowFederation,
|
||||||
data.additionalCreators,
|
data.additionalCreators,
|
||||||
),
|
),
|
||||||
power_level_content_override:
|
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
// 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(),
|
||||||
initial_state: initialState,
|
initial_state: initialState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
|
|||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
|
import { stateToCarry } from '../../../utils/roomUpgradeState';
|
||||||
|
|
||||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
|||||||
const [upgradeState, upgrade] = useAsyncCallback(
|
const [upgradeState, upgrade] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (version: string, newAdditionalCreators?: string[]) => {
|
async (version: string, newAdditionalCreators?: string[]) => {
|
||||||
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
|
||||||
new_version: version,
|
// policy, soundboard, retention, role tags, emoji packs) into the
|
||||||
additional_creators: newAdditionalCreators,
|
// replacement room: read it first, re-create it after the upgrade.
|
||||||
});
|
const carried = stateToCarry(
|
||||||
|
Array.from(room.currentState.events.values()).flatMap((byKey) =>
|
||||||
|
Array.from(byKey.values()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
|
||||||
|
Method.Post,
|
||||||
|
`/rooms/${room.roomId}/upgrade`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
new_version: version,
|
||||||
|
additional_creators: newAdditionalCreators,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const newRoomId = res?.replacement_room;
|
||||||
|
if (!newRoomId || carried.length === 0) return;
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
carried.map((st) =>
|
||||||
|
mx.http.authedRequest(
|
||||||
|
Method.Put,
|
||||||
|
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
|
||||||
|
undefined,
|
||||||
|
st.content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||||
|
if (failed > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, room],
|
[mx, room],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
|
|||||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const light: CSSProperties = {
|
const light: CSSProperties = {
|
||||||
@@ -75,7 +75,7 @@ const light: CSSProperties = {
|
|||||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const animAurora: ChatBgVariants = { dark, light };
|
export const animAurora: ChatBgVariants = { dark, light };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
|
|||||||
'0 0', // wash A (static)
|
'0 0', // wash A (static)
|
||||||
'0 0', // wash B (static)
|
'0 0', // wash B (static)
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${firefliesDrift} 44s linear infinite`,
|
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||||
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
|
|||||||
'0 0', // wash A (static)
|
'0 0', // wash A (static)
|
||||||
'0 0', // wash B (static)
|
'0 0', // wash B (static)
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${firefliesDrift} 44s linear infinite`,
|
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
|
|||||||
'0 0', // base wash
|
'0 0', // base wash
|
||||||
'0 0', // vignette
|
'0 0', // vignette
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||||
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
|
|||||||
'0 0', // base wash
|
'0 0', // base wash
|
||||||
'0 0', // vignette
|
'0 0', // vignette
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
animation: `${rainFall} 12s linear infinite`,
|
animation: `${rainFall} 12s steps(288) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||||
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
animation: `${rainFall} 12s linear infinite`,
|
animation: `${rainFall} 12s steps(288) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
|
|||||||
'0 0', // nebula A
|
'0 0', // nebula A
|
||||||
'0 0', // nebula B
|
'0 0', // nebula B
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${starDrift} 90s linear infinite`,
|
animation: `${starDrift} 90s steps(1080) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||||
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
|
|||||||
'0 0', // wash A
|
'0 0', // wash A
|
||||||
'0 0', // wash B
|
'0 0', // wash B
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${starDrift} 100s linear infinite`,
|
animation: `${starDrift} 100s steps(1200) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ export const getChatBg = (
|
|||||||
}
|
}
|
||||||
// For animated backgrounds, promote the element to its own compositor layer so
|
// For animated backgrounds, promote the element to its own compositor layer so
|
||||||
// background-position keyframes don't trigger repaints on descendant elements.
|
// background-position keyframes don't trigger repaints on descendant elements.
|
||||||
|
// [Gitea #337] background-position can't run on the compositor, so every
|
||||||
|
// animation frame repaints the whole chat area (45 to 70% main-thread busy
|
||||||
|
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
|
||||||
|
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
|
||||||
|
// same, a fraction of the repaints.
|
||||||
if (style.animation) {
|
if (style.animation) {
|
||||||
return { ...style, willChange: 'background-position', contain: 'paint' };
|
return { ...style, willChange: 'background-position', contain: 'paint' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
|||||||
import { VirtualTile } from '../../components/virtualizer';
|
import { VirtualTile } from '../../components/virtualizer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { MsgTypeFilter } from './useMessageSearch';
|
import { MsgTypeFilter } from './useMessageSearch';
|
||||||
|
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||||
|
|
||||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||||
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
|
|||||||
onChange: (rooms?: string[]) => void;
|
onChange: (rooms?: string[]) => void;
|
||||||
};
|
};
|
||||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||||
|
|
||||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
(rId) => {
|
||||||
[mx],
|
const name = mx.getRoom(rId)?.name ?? rId;
|
||||||
|
// [Gitea #336] match the personal "Rename for me" name too
|
||||||
|
return localNames[rId] ? [localNames[rId], name] : name;
|
||||||
|
},
|
||||||
|
[mx, localNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text truncate size="T300">
|
<Text truncate size="T300">
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
|||||||
pinnedOnly,
|
pinnedOnly,
|
||||||
onTogglePinnedOnly,
|
onTogglePinnedOnly,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
|||||||
}
|
}
|
||||||
after={<Icon size="50" src={Icons.Cross} />}
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
>
|
>
|
||||||
<Text size="T200">{room.name}</Text>
|
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
} from '../../hooks/useMemberPowerTag';
|
} from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
|
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type SearchResultGroupProps = {
|
type SearchResultGroupProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -73,6 +74,7 @@ export function SearchResultGroup({
|
|||||||
hour24Clock,
|
hour24Clock,
|
||||||
dateFormatString,
|
dateFormatString,
|
||||||
}: SearchResultGroupProps) {
|
}: SearchResultGroupProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
|||||||
Create Poll
|
Create Poll
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
|||||||
Report Room
|
Report Room
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
|||||||
Report User
|
Report User
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
|||||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||||
import { getThreadDraftKey } from '../../state/room/thread';
|
import { getThreadDraftKey } from '../../state/room/thread';
|
||||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||||
|
import { plaintextCachesWiped } from '../../state/plaintextCaches';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||||
@@ -562,6 +563,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
// Persist the current editor state to the draft atom + localStorage; an empty
|
// Persist the current editor state to the draft atom + localStorage; an empty
|
||||||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||||||
const persistDraft = useCallback(() => {
|
const persistDraft = useCallback(() => {
|
||||||
|
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
|
||||||
|
if (plaintextCachesWiped()) return;
|
||||||
if (!isEmptyEditor(editor)) {
|
if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
|||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
@@ -493,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||||
// re-renders. `undefined` = no policy.
|
// re-renders. `undefined` = no policy.
|
||||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||||
|
const retentionMs = effectiveRetentionMs(
|
||||||
|
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||||
|
);
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
@@ -2030,6 +2034,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
if (!senderId || senderId === mx.getUserId()) return null;
|
if (!senderId || senderId === mx.getUserId()) return null;
|
||||||
|
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||||
|
// channels, where starting a call is just joining the channel.
|
||||||
|
if (!isRingableRoom(room, direct)) return null;
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
const mention =
|
const mention =
|
||||||
content['m.mentions']?.room ||
|
content['m.mentions']?.room ||
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ import React, {
|
|||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useHover, useFocusWithin } from 'react-aria';
|
import { useHover, useFocusWithin } from 'react-aria';
|
||||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
@@ -114,6 +115,20 @@ function DeliveryStatus({
|
|||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const offline = useAtomValue(sendOfflineAtom);
|
const offline = useAtomValue(sendOfflineAtom);
|
||||||
|
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
|
||||||
|
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
|
||||||
|
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
|
||||||
|
// Re-render on the event's own status updates.
|
||||||
|
const [, setStatusTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const bump = () => setStatusTick((t) => t + 1);
|
||||||
|
mEvent.on(MatrixEventEvent.Status, bump);
|
||||||
|
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||||
|
return () => {
|
||||||
|
mEvent.off(MatrixEventEvent.Status, bump);
|
||||||
|
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||||
|
};
|
||||||
|
}, [mEvent]);
|
||||||
const { status } = mEvent;
|
const { status } = mEvent;
|
||||||
if (status === null) return null; // confirmed by server — read receipts take over
|
if (status === null) return null; // confirmed by server — read receipts take over
|
||||||
let iconSrc: IconSrc;
|
let iconSrc: IconSrc;
|
||||||
@@ -710,7 +725,13 @@ export const MessageDeleteItem = as<
|
|||||||
Delete Message
|
Delete Message
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -850,7 +871,13 @@ export const MessageReportItem = as<
|
|||||||
Report Message
|
Report Message
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
const measure = useCallback(() => {
|
const measure = useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const rowWidth = container.getBoundingClientRect().width;
|
// [Gitea #305] The width the chips actually get (content box). The border
|
||||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
// 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 chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||||
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
style={{
|
style={{
|
||||||
...props.style,
|
...props.style,
|
||||||
...(collapsed && rowHeight
|
...(collapsed && rowHeight
|
||||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
// 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,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
|||||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||||
import { getMemberName } from '../../../utils/room';
|
import { getMemberName } from '../../../utils/room';
|
||||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type ThreadPanelHeaderProps = {
|
type ThreadPanelHeaderProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
|
|||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||||
const { format } = useTimestampFormatter();
|
const { format } = useTimestampFormatter();
|
||||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||||
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
|||||||
Thread
|
Thread
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
|||||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||||
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
|
|||||||
onOpenThread: (threadId: string) => void;
|
onOpenThread: (threadId: string) => void;
|
||||||
};
|
};
|
||||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const handleJump = useCallback(
|
const handleJump = useCallback(
|
||||||
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
|||||||
Threads
|
Threads
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{totalUnread > 0 && (
|
{totalUnread > 0 && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { focusAssistActiveAtom } from '../state/focusAssist';
|
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||||
import { manualDndAtom } from '../state/manualDnd';
|
import { manualDndAtom } from '../state/manualDnd';
|
||||||
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
|
const QUIET_TICK_MS = 30_000;
|
||||||
|
|
||||||
export type NotificationsQuietInputs = {
|
export type NotificationsQuietInputs = {
|
||||||
focusAssistActive: boolean;
|
focusAssistActive: boolean;
|
||||||
manualDnd: boolean;
|
manualDnd: boolean;
|
||||||
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
|
|||||||
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
||||||
// components.
|
// components.
|
||||||
export function useNotificationsQuiet(): boolean {
|
export function useNotificationsQuiet(): boolean {
|
||||||
|
// [Gitea #325] The quiet-hours window and the snooze expiry depend on the
|
||||||
|
// clock, but the long-lived notifiers using this hook rarely re-render, so a
|
||||||
|
// window starting (or a 30-minute pause ending) while the app stayed open was
|
||||||
|
// never noticed. Re-evaluate on a tick, like the night-light overlay.
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const manualDnd = useAtomValue(manualDndAtom);
|
const manualDnd = useAtomValue(manualDndAtom);
|
||||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
||||||
const FONT_MAP: Record<string, string> = {
|
const FONT_MAP: Record<string, string> = {
|
||||||
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
|
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
|
||||||
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
inter: `'Inter Variable', 'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||||
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
|
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
|
||||||
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
|||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
import {
|
||||||
|
getRoomRetentionMs,
|
||||||
|
getRoomRetentionSetAt,
|
||||||
|
isExpired,
|
||||||
|
retentionDeletesAllowed,
|
||||||
|
} from '../../utils/retention';
|
||||||
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
@@ -910,6 +915,9 @@ function RetentionSweeper() {
|
|||||||
mx.getRooms().forEach((room) => {
|
mx.getRooms().forEach((room) => {
|
||||||
const maxLifetime = getRoomRetentionMs(room);
|
const maxLifetime = getRoomRetentionMs(room);
|
||||||
if (!maxLifetime) return;
|
if (!maxLifetime) return;
|
||||||
|
// [Gitea #298] Never delete under a policy that is less than a day old.
|
||||||
|
const setAt = getRoomRetentionSetAt(room);
|
||||||
|
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
|
||||||
room
|
room
|
||||||
.getLiveTimeline()
|
.getLiveTimeline()
|
||||||
.getEvents()
|
.getEvents()
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
|||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { PresenceRingAvatar } from '../../../components/presence';
|
import { PresenceRingAvatar } from '../../../components/presence';
|
||||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||||
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type RoomNotificationsGroup = {
|
type RoomNotificationsGroup = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
|
|||||||
hour24Clock,
|
hour24Clock,
|
||||||
dateFormatString,
|
dateFormatString,
|
||||||
}: RoomNotificationsGroupProps) {
|
}: RoomNotificationsGroupProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
|||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
import { getCallPageUrl } from './callPageUrl';
|
import { getCallPageUrl } from './callPageUrl';
|
||||||
import { restrictWidgetMessages } from '../widgetTransport';
|
import { restrictWidgetMessages } from '../widgetTransport';
|
||||||
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
|
|
||||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
// 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
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
@@ -251,8 +252,18 @@ export class CallEmbed {
|
|||||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
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)) {
|
if (CallEmbed.startingCall(intent)) {
|
||||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
let notify = 'none';
|
||||||
|
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
|
||||||
|
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
|
||||||
|
}
|
||||||
|
params.append('sendNotificationType', notify);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const api = {
|
|||||||
: Object.getOwnPropertyDescriptor(target, key),
|
: Object.getOwnPropertyDescriptor(target, key),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { clearPlaintextCaches } = await import('./plaintextCaches');
|
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||||
|
|
||||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||||
store.clear();
|
store.clear();
|
||||||
@@ -94,3 +94,13 @@ test('clearPlaintextCaches removes the call-volume list and the settings-sync st
|
|||||||
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||||||
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
|
||||||
|
store.clear();
|
||||||
|
store.set('searchCacheEnabled', 'true');
|
||||||
|
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
|
||||||
|
clearPlaintextCaches('@me:server');
|
||||||
|
assert.equal(store.has('searchCacheEnabled'), false);
|
||||||
|
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||||
|
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||||
|
});
|
||||||
|
|||||||
@@ -112,7 +112,34 @@ const clearCallVolumesAndSyncMeta = (userId?: string): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the
|
||||||
|
// page, and the reload's `pagehide` made RoomInput flush the open composer's
|
||||||
|
// draft straight back to localStorage; draft writes check this and stop.
|
||||||
|
// Module state, so it resets with the new page.
|
||||||
|
let wiped = false;
|
||||||
|
export const plaintextCachesWiped = (): boolean => wiped;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #296] Device-wide flags/keys that carry the previous account's choice
|
||||||
|
* or id: the encrypted-search opt-in (the next account would otherwise start
|
||||||
|
* indexing decrypted text without opting in) and the SDK's per-user filter key.
|
||||||
|
*/
|
||||||
|
const clearAccountScopedFlags = (userId?: string): void => {
|
||||||
|
const keys = [
|
||||||
|
'searchCacheEnabled',
|
||||||
|
...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []),
|
||||||
|
];
|
||||||
|
keys.forEach((key) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const clearPlaintextCaches = (userId?: string): void => {
|
export const clearPlaintextCaches = (userId?: string): void => {
|
||||||
|
wiped = true;
|
||||||
clearTranslationCache();
|
clearTranslationCache();
|
||||||
clearScheduledMessages();
|
clearScheduledMessages();
|
||||||
clearRecentSearches();
|
clearRecentSearches();
|
||||||
@@ -124,5 +151,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearCallSession();
|
clearCallSession();
|
||||||
clearStatusMessage();
|
clearStatusMessage();
|
||||||
clearCallVolumesAndSyncMeta(userId);
|
clearCallVolumesAndSyncMeta(userId);
|
||||||
|
clearAccountScopedFlags(userId);
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { Room } from 'matrix-js-sdk';
|
||||||
|
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
|
||||||
|
|
||||||
|
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||||
|
({
|
||||||
|
isCallRoom: () => !!opts.callRoom,
|
||||||
|
getJoinRule: () => opts.joinRule ?? 'invite',
|
||||||
|
getLiveTimeline: () => ({
|
||||||
|
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
|
||||||
|
}),
|
||||||
|
}) as unknown as Room;
|
||||||
|
|
||||||
|
test('voice rooms never ring or show "missed call", even as DMs', () => {
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DMs and invite-only groups outside a space ring', () => {
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('space channels, public and restricted rooms do not ring', () => {
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateRoom = (
|
||||||
|
members: Array<{ sender: string; content: Record<string, unknown> }>,
|
||||||
|
timeline: unknown[] = [],
|
||||||
|
): Room =>
|
||||||
|
({
|
||||||
|
getLiveTimeline: () => ({
|
||||||
|
getState: () => ({
|
||||||
|
getStateEvents: () =>
|
||||||
|
members.map((m) => ({
|
||||||
|
getSender: () => m.sender,
|
||||||
|
getStateKey: () => `_${m.sender}_DEV_m.call`,
|
||||||
|
getContent: () => m.content,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
getEvents: () => timeline,
|
||||||
|
}),
|
||||||
|
}) as unknown as Room;
|
||||||
|
|
||||||
|
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
|
||||||
|
const room = stateRoom([
|
||||||
|
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
|
||||||
|
{ sender: '@carol:x', content: {} },
|
||||||
|
]);
|
||||||
|
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
|
||||||
|
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
|
||||||
|
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declinedRing finds our own decline of that ring only (#312)', () => {
|
||||||
|
const decline = (sender: string, rel: string) => ({
|
||||||
|
getType: () => 'org.matrix.msc4310.rtc.decline',
|
||||||
|
getSender: () => sender,
|
||||||
|
getRelation: () => ({ event_id: rel }),
|
||||||
|
});
|
||||||
|
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
|
||||||
|
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
|
||||||
|
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
||||||
|
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { getStateEvents } from './room';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a call notification in this room may ring — and, by the same rule,
|
||||||
|
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
|
||||||
|
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
|
||||||
|
* call button is shown. Persistent voice rooms, space channels, restricted and
|
||||||
|
* public rooms never do: joining a voice channel alone is normal and isn't a
|
||||||
|
* "call" anyone missed.
|
||||||
|
*/
|
||||||
|
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
||||||
|
if (room.isCallRoom()) return false;
|
||||||
|
if (isDirect) return true;
|
||||||
|
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||||
|
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A call-membership state event that is still "in the call" (left = empty content). */
|
||||||
|
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
|
||||||
|
!!content &&
|
||||||
|
(typeof content.application === 'string' ||
|
||||||
|
(Array.isArray(content.memberships) && content.memberships.length > 0));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
|
||||||
|
* current STATE. After a reload the MatrixRTC session can be stale (a join and
|
||||||
|
* leave replayed in one catch-up batch), but the state is right.
|
||||||
|
*/
|
||||||
|
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
||||||
|
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
|
||||||
|
(ev) =>
|
||||||
|
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
|
||||||
|
isActiveMembership(ev.getContent()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
|
||||||
|
* 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 =>
|
||||||
|
room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getEvents()
|
||||||
|
.some(
|
||||||
|
(ev) =>
|
||||||
|
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
||||||
|
ev.getSender() === myUserId &&
|
||||||
|
ev.getRelation()?.event_id === refEventId,
|
||||||
|
);
|
||||||
@@ -38,3 +38,38 @@ test('hostFromText only accepts URL/host-shaped text; IDN becomes punycode', ()
|
|||||||
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true);
|
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true);
|
||||||
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true);
|
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a trusted name inside a sentence still exposes a different destination (#335)', () => {
|
||||||
|
const a = analyzeLink('Log in at chat.lotusguild.org', 'https://evil.example/');
|
||||||
|
assert.equal(a?.mismatch, true);
|
||||||
|
assert.equal(a?.shownHost, 'chat.lotusguild.org');
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('chat.lotusguild.org (official)', 'https://evil.example/')?.mismatch,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('Open https://paypal.com/login now', 'https://evil.example/')?.mismatch,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// honest sentences are not flagged
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('Log in at chat.lotusguild.org', 'https://chat.lotusguild.org/login')?.mismatch,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(analyzeLink('read the docs', 'https://evil.example/')?.mismatch, false);
|
||||||
|
assert.equal(analyzeLink('see main.py and report.pdf', 'https://github.com/x')?.mismatch, false);
|
||||||
|
assert.equal(analyzeLink('release v1.2.3 notes', 'https://github.com/x')?.mismatch, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared hosting suffixes are separate sites (#335)', () => {
|
||||||
|
assert.equal(registrableDomain('alice.github.io'), 'alice.github.io');
|
||||||
|
assert.equal(registrableDomain('www.alice.github.io'), 'alice.github.io');
|
||||||
|
assert.equal(registrableDomain('bucket.s3.amazonaws.com'), 'bucket.s3.amazonaws.com');
|
||||||
|
assert.equal(analyzeLink('alice.github.io', 'https://evil.github.io/')?.mismatch, true);
|
||||||
|
assert.equal(analyzeLink('lotusguild.pages.dev', 'https://evil.pages.dev/')?.mismatch, true);
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('alice.github.io/blog', 'https://alice.github.io/blog/1')?.mismatch,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
|
||||||
|
});
|
||||||
|
|||||||
+131
-7
@@ -48,11 +48,53 @@ const TWO_LABEL_SUFFIXES = new Set([
|
|||||||
'com.tw',
|
'com.tw',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// [Gitea #335] Shared-hosting suffixes (the Public Suffix List's "private"
|
||||||
|
// section, the ones phishing actually uses): every customer gets their own
|
||||||
|
// subdomain, so `alice.github.io` and `evil.github.io` are different sites.
|
||||||
|
// A curated list rather than the full PSL keeps this out of the bundle budget.
|
||||||
|
const SHARED_HOSTING_SUFFIXES = new Set([
|
||||||
|
'github.io',
|
||||||
|
'gitlab.io',
|
||||||
|
'codeberg.page',
|
||||||
|
'pages.dev',
|
||||||
|
'workers.dev',
|
||||||
|
'trycloudflare.com',
|
||||||
|
'netlify.app',
|
||||||
|
'vercel.app',
|
||||||
|
'web.app',
|
||||||
|
'firebaseapp.com',
|
||||||
|
'appspot.com',
|
||||||
|
'herokuapp.com',
|
||||||
|
'onrender.com',
|
||||||
|
'fly.dev',
|
||||||
|
'glitch.me',
|
||||||
|
'replit.app',
|
||||||
|
'repl.co',
|
||||||
|
'surge.sh',
|
||||||
|
'blogspot.com',
|
||||||
|
'wordpress.com',
|
||||||
|
'wixsite.com',
|
||||||
|
'weebly.com',
|
||||||
|
'azurewebsites.net',
|
||||||
|
'cloudfront.net',
|
||||||
|
'ngrok.io',
|
||||||
|
'ngrok-free.app',
|
||||||
|
'duckdns.org',
|
||||||
|
's3.amazonaws.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isPublicSuffix = (suffix: string): boolean =>
|
||||||
|
TWO_LABEL_SUFFIXES.has(suffix) || SHARED_HOSTING_SUFFIXES.has(suffix);
|
||||||
|
|
||||||
export const registrableDomain = (host: string): string => {
|
export const registrableDomain = (host: string): string => {
|
||||||
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
|
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
|
||||||
if (labels.length <= 2) return labels.join('.');
|
// Longest matching public suffix wins (3 labels, e.g. s3.amazonaws.com, then 2).
|
||||||
const lastTwo = labels.slice(-2).join('.');
|
for (const n of [3, 2]) {
|
||||||
return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo;
|
if (labels.length > n && isPublicSuffix(labels.slice(-n).join('.'))) {
|
||||||
|
return labels.slice(-(n + 1)).join('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels.slice(-2).join('.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const HOST_LIKE =
|
const HOST_LIKE =
|
||||||
@@ -72,6 +114,85 @@ export const hostFromText = (text: string): string | null => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Words that look like hosts but are usually files or versions in chat
|
||||||
|
// ("see main.py", "report.pdf", "v1.2.3"): skipped inside sentences unless
|
||||||
|
// written as a URL (scheme, www., or a path) so ordinary links aren't flagged.
|
||||||
|
const FILE_EXTENSIONS = new Set([
|
||||||
|
'py',
|
||||||
|
'sh',
|
||||||
|
'md',
|
||||||
|
'rs',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'jsx',
|
||||||
|
'json',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'txt',
|
||||||
|
'log',
|
||||||
|
'csv',
|
||||||
|
'pdf',
|
||||||
|
'zip',
|
||||||
|
'gz',
|
||||||
|
'tar',
|
||||||
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'mp3',
|
||||||
|
'mp4',
|
||||||
|
'mov',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'exe',
|
||||||
|
'dll',
|
||||||
|
'lock',
|
||||||
|
'cfg',
|
||||||
|
'conf',
|
||||||
|
'ini',
|
||||||
|
'xml',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'apk',
|
||||||
|
'iso',
|
||||||
|
'jar',
|
||||||
|
'java',
|
||||||
|
'go',
|
||||||
|
'rb',
|
||||||
|
'php',
|
||||||
|
'c',
|
||||||
|
'h',
|
||||||
|
'cpp',
|
||||||
|
]);
|
||||||
|
const TRIM_PUNCT = /^[\s"'“”‘’«»([{<]+|[\s"'“”‘’«»)\]}>.,;:!?]+$/g;
|
||||||
|
|
||||||
|
/** Every host a sentence names (Gitea #335), e.g. "Log in at chat.lotusguild.org". */
|
||||||
|
export const hostsFromText = (text: string): string[] => {
|
||||||
|
const hosts: string[] = [];
|
||||||
|
text.split(/\s+/).forEach((raw) => {
|
||||||
|
const word = raw.replace(TRIM_PUNCT, '');
|
||||||
|
const host = word && hostFromText(word);
|
||||||
|
if (!host) return;
|
||||||
|
const tld = host.split('.').pop() ?? '';
|
||||||
|
if (!/[a-z]/i.test(tld)) return; // version numbers, IPs-as-words
|
||||||
|
const urlShaped =
|
||||||
|
/^[a-z][a-z0-9+.-]*:\/\//i.test(word) || /^www\./i.test(word) || word.includes('/');
|
||||||
|
if (!urlShaped && host.split('.').length === 2 && FILE_EXTENSIONS.has(tld.toLowerCase()))
|
||||||
|
return;
|
||||||
|
if (!hosts.includes(host)) hosts.push(host);
|
||||||
|
});
|
||||||
|
return hosts;
|
||||||
|
};
|
||||||
|
|
||||||
export type LinkSafety = {
|
export type LinkSafety = {
|
||||||
/** The visible text names a different site than the destination. */
|
/** The visible text names a different site than the destination. */
|
||||||
mismatch: boolean;
|
mismatch: boolean;
|
||||||
@@ -92,8 +213,11 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
|
|||||||
if (url.hostname === 'matrix.to') return null;
|
if (url.hostname === 'matrix.to') return null;
|
||||||
const realHost = url.hostname;
|
const realHost = url.hostname;
|
||||||
const punycode = realHost.split('.').some((l) => l.startsWith('xn--'));
|
const punycode = realHost.split('.').some((l) => l.startsWith('xn--'));
|
||||||
const shownHost = hostFromText(text) ?? undefined;
|
// A single URL/host-shaped text, or (Gitea #335) host-shaped words inside a
|
||||||
const mismatch =
|
// sentence: "Log in at chat.lotusguild.org" must not hide a different site.
|
||||||
shownHost !== undefined && registrableDomain(shownHost) !== registrableDomain(realHost);
|
const single = hostFromText(text);
|
||||||
return { mismatch, shownHost, realHost, punycode };
|
const shown = single ? [single] : hostsFromText(text);
|
||||||
|
const realSite = registrableDomain(realHost);
|
||||||
|
const other = shown.find((h) => registrableDomain(h) !== realSite);
|
||||||
|
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
import {
|
||||||
|
effectiveRetentionMs,
|
||||||
|
isExpired,
|
||||||
|
RETENTION_DELETE_GRACE_MS,
|
||||||
|
RETENTION_MIN_MS,
|
||||||
|
RETENTION_PRESETS,
|
||||||
|
retentionDeletesAllowed,
|
||||||
|
} from './retention';
|
||||||
|
|
||||||
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||||
// retention window. It must be strict (> window, not >=) and a disabled policy
|
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||||
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
|
|||||||
'all presets above the floor',
|
'all presets above the floor',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
|
||||||
|
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
|
||||||
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
|
||||||
|
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
|
||||||
|
assert.equal(effectiveRetentionMs(0), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(-5), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs('86400000'), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
|
||||||
|
const now = 1_000 * HOUR;
|
||||||
|
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
|
|||||||
{ label: '1 Month', ms: 30 * DAY_MS },
|
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #298] A usable retention window, or `undefined`. Anything under
|
||||||
|
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
|
||||||
|
* from any client by anyone with state power) used to blank every timeline and
|
||||||
|
* get opted-in users' messages redacted within a minute.
|
||||||
|
*/
|
||||||
|
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
|
||||||
|
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
|
||||||
|
? maxLifetime
|
||||||
|
: undefined;
|
||||||
|
|
||||||
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||||
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||||
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||||
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
||||||
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
|
||||||
|
* policy for this long after it was set, so a mistaken or malicious change can
|
||||||
|
* be reverted before anything is permanently redacted. (Hiding expired
|
||||||
|
* messages locally is not destructive and applies at once.)
|
||||||
|
*/
|
||||||
|
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
|
||||||
|
|
||||||
|
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
|
||||||
|
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
|
||||||
|
|
||||||
|
/** When the room's current retention policy was set (its state event's ts). */
|
||||||
|
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
|
||||||
|
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
|
||||||
|
|
||||||
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||||
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||||
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isCarriedStateType, stateToCarry } from './roomUpgradeState';
|
||||||
|
|
||||||
|
const ev = (type: string, content: Record<string, unknown>, stateKey = '') => ({
|
||||||
|
getType: () => type,
|
||||||
|
getStateKey: () => stateKey,
|
||||||
|
getContent: () => content,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lotus room state is carried; state Synapse already copies is not (#327)', () => {
|
||||||
|
assert.equal(isCarriedStateType('io.lotus.voice_limit'), true);
|
||||||
|
assert.equal(isCarriedStateType('io.lotus.room_quality'), true);
|
||||||
|
assert.equal(isCarriedStateType('io.lotus.soundboard'), true);
|
||||||
|
assert.equal(isCarriedStateType('m.room.retention'), true);
|
||||||
|
assert.equal(isCarriedStateType('in.cinny.room.power_level_tags'), true);
|
||||||
|
assert.equal(isCarriedStateType('im.ponies.room_emotes'), true);
|
||||||
|
assert.equal(isCarriedStateType('m.room.power_levels'), false);
|
||||||
|
assert.equal(isCarriedStateType('m.room.name'), false);
|
||||||
|
assert.equal(isCarriedStateType('m.room.member'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stateToCarry keeps state keys and skips emptied events', () => {
|
||||||
|
const out = stateToCarry([
|
||||||
|
ev('io.lotus.voice_limit', { max_users: 5 }),
|
||||||
|
ev('io.lotus.soundboard', { clips: [1] }, 'pack-a'),
|
||||||
|
ev('io.lotus.room_quality', {}),
|
||||||
|
ev('m.room.name', { name: 'x' }),
|
||||||
|
ev('m.room.retention', { max_lifetime: 604800000 }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((s) => `${s.type}|${s.stateKey}`),
|
||||||
|
['io.lotus.voice_limit|', 'io.lotus.soundboard|pack-a', 'm.room.retention|'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #327] Room state Synapse does NOT copy into the replacement room on
|
||||||
|
* `POST /rooms/{id}/upgrade` (it carries only power levels, join rules,
|
||||||
|
* name/topic/avatar, history visibility, guest access, encryption, server ACL
|
||||||
|
* and space parent/child). Lotus keeps call policy, voice limits, soundboard,
|
||||||
|
* message retention, role tags and room emoji packs in state, so an upgrade
|
||||||
|
* silently dropped all of them.
|
||||||
|
*/
|
||||||
|
const CARRIED_TYPES = new Set([
|
||||||
|
'm.room.retention',
|
||||||
|
'in.cinny.room.power_level_tags',
|
||||||
|
'im.ponies.room_emotes',
|
||||||
|
]);
|
||||||
|
const CARRIED_PREFIXES = ['io.lotus.'];
|
||||||
|
|
||||||
|
export type StateToCarry = { type: string; stateKey: string; content: Record<string, unknown> };
|
||||||
|
|
||||||
|
export const isCarriedStateType = (type: string): boolean =>
|
||||||
|
CARRIED_TYPES.has(type) || CARRIED_PREFIXES.some((p) => type.startsWith(p));
|
||||||
|
|
||||||
|
/** The state events to re-create in the replacement room (non-empty content only). */
|
||||||
|
export const stateToCarry = (
|
||||||
|
events: Array<{
|
||||||
|
getType: () => string;
|
||||||
|
getStateKey: () => string | undefined;
|
||||||
|
getContent: () => Record<string, unknown>;
|
||||||
|
}>,
|
||||||
|
): StateToCarry[] =>
|
||||||
|
events
|
||||||
|
.filter((ev) => isCarriedStateType(ev.getType()))
|
||||||
|
.map((ev) => ({
|
||||||
|
type: ev.getType(),
|
||||||
|
stateKey: ev.getStateKey() ?? '',
|
||||||
|
content: ev.getContent(),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.content && Object.keys(s.content).length > 0);
|
||||||
@@ -20,9 +20,12 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
|||||||
* session. When that happens the device "resurrects" with a blank key store and the
|
* 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
|
* 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,
|
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
||||||
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
|
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
|
||||||
* install and shows no prompt; denial/absence is non-fatal.
|
* (engagement / PWA install); Firefox shows a permission prompt and the promise
|
||||||
|
* stays pending until it's answered. Denial/absence is non-fatal.
|
||||||
*/
|
*/
|
||||||
|
const PERSIST_WAIT_MS = 1500;
|
||||||
|
|
||||||
export const requestPersistentStorage = async (): Promise<boolean> => {
|
export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
if (!navigator.storage?.persist) return false;
|
if (!navigator.storage?.persist) return false;
|
||||||
@@ -39,7 +42,15 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
|
|||||||
|
|
||||||
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.
|
// Protect the crypto store from eviction before we create and write to it.
|
||||||
await requestPersistentStorage();
|
// [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
|
||||||
|
// the promise pending until the user responds, which froze the app on
|
||||||
|
// "Heating up". KE-1 only needs the request made; give it 1.5 s at most.
|
||||||
|
await Promise.race([
|
||||||
|
requestPersistentStorage(),
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const indexedDBStore = new IndexedDBStore({
|
const indexedDBStore = new IndexedDBStore({
|
||||||
indexedDB: globalThis.indexedDB,
|
indexedDB: globalThis.indexedDB,
|
||||||
|
|||||||
+3
-2
@@ -42,7 +42,8 @@
|
|||||||
|
|
||||||
--font-emoji: 'Twemoji_DISABLED';
|
--font-emoji: 'Twemoji_DISABLED';
|
||||||
--font-flags: 'TwemojiFlags_DISABLED';
|
--font-flags: 'TwemojiFlags_DISABLED';
|
||||||
--font-secondary: 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
|
--font-secondary:
|
||||||
|
'Inter Variable', 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-theme,
|
.dark-theme,
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
--mx-uc-7: hsl(243, 100%, 80%);
|
--mx-uc-7: hsl(243, 100%, 80%);
|
||||||
--mx-uc-8: hsl(94, 100%, 80%);
|
--mx-uc-8: hsl(94, 100%, 80%);
|
||||||
|
|
||||||
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
|
--font-secondary: 'Inter Variable', 'InterVariable', var(--font-emoji), sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|||||||
Reference in New Issue
Block a user