Compare commits

...
2 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 f528e5e440 feat(links): Copy Lotus Link permalinks, in-app recognition, /home redirect for joined rooms, via= alias, OIDC deep-link redirect (#130)
CI / Build & Quality Checks (push) Successful in 2m4s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m11s
matrix.to cannot target this deployment (Cinny adapter hard-codes
app.cinny.in; web-instance[] is Element-only, allowlisted), so add a
"Copy Lotus Link" next to every Copy Link (message menu, space header
menu, sidebar space tab) producing https://<this origin>/home/<room>/<event>
?viaServers=… via plugins/lotus-permalink.ts. Lotus links in messages are
rewritten to their matrix.to form inside the HTML parser so they render as
room/event mentions and navigate in place.

/home/<room> for a joined room that belongs to a space or Direct now
redirects to its own route (was a preview card with a View button; also
the form matrix.to → Cinny links use). ?via= is accepted as an alias of
?viaServers= (what the matrix.to Cinny adapter emits). A deep link
visited while logged out is now honoured after an OIDC login: the OIDC
callback reloads at the app root, which discarded the stored path — the
index loader consumes it via the shared takeAfterLoginPath().

Verified end-to-end with Playwright on a local Synapse: logged-out cold
link → login → lands on the event under the space route; menu copies the
expected link; a pasted Lotus link renders as a mention and jumps in
place; both space menus copy the space link.

Closes #130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:18:45 -04:00
jaredandClaude Opus 5 d929143f7d fix(gallery): paginate media, activity log and export on detached timeline sets — never the live timeline (#163)
RoomTimeline renders a numeric index window into the live timeline's
event arrays; SDK back-pagination prepends, so any side panel calling
paginateEventTimeline(room.getLiveTimeline()) shifted the visible
messages into the past on the next render and broke at-bottom tracking.

New utils/detachedTimeline.ts builds a timeline set that mirrors the
already-loaded history and paginates independently: a room-registered
filtered set (server-side contains_url / types filter) when the filter
is usable, else a private EventTimelineSet seeded from the live timeline.
useRoomMediaTimeline wraps it for the gallery (live events + redactions
handled); RoomActivityLog uses a type filter (safe in encrypted rooms);
ExportRoomHistory pages a private set so a full export no longer parks
thousands of events in the live timeline.

Verified with Playwright against a local Synapse in a 400-message plain
room and a 200-message encrypted room: timeline stays at the bottom
through gallery pages, activity load-more and a full export; live
messages keep auto-scrolling; all media found in both rooms.

Also adds scripts/dev-homeserver.sh + scripts/dev-seed.py (local
throwaway Synapse for driving the real UI) and documents them.

Closes #163

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:03:54 -04:00
24 changed files with 1042 additions and 96 deletions
+4
View File
@@ -10,3 +10,7 @@ public/decorations/
# Playwright (npm run test:e2e)
playwright-report/
test-results/
# local dev homeserver (scripts/dev-homeserver.sh)
.dev-homeserver/
__pycache__/
+5 -1
View File
@@ -1179,6 +1179,10 @@ Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
### Copy Lotus Link — direct permalinks (Gitea #130)
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
### PWA App-Icon Badge (Gitea #154)
When Lotus Chat is installed as a PWA (Android Chrome, desktop Chrome/Edge), the app icon carries a numeric badge via the Badging API (`navigator.setAppBadge`). The number is the same highlight count (mentions/DMs, leaf rooms only) that the tab title shows, so the two can never disagree; it clears when the count reaches zero. Lives in `FaviconUpdater` (`ClientNonUIFeatures.tsx`) next to the title/favicon logic. No-op in a plain browser tab (the API is absent) and under Tauri, where the native `set_badge_count` already owns the badge.
@@ -1193,7 +1197,7 @@ When Lotus Chat is installed as a PWA (Android Chrome, desktop Chrome/Edge), the
- **Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)
- **Auto-pagination** — an `IntersectionObserver` sentinel pulls older media as you scroll (manual retry on error). Since Gitea #163 this pages through the gallery's **own timeline set** (`useRoomMediaTimeline` → `utils/detachedTimeline.ts`), never the room's live timeline: unencrypted rooms use a server-side `contains_url` filter (a page is 100 media events, not 100 events), encrypted rooms page raw history into a private set and filter after decrypting. The message list behind the drawer no longer jumps into the past; the Activity log and history Export use the same helper.
### Knock-to-Join
+10
View File
@@ -11,6 +11,16 @@ This file keeps what a contributor needs to run and extend the **automated** cov
---
## Local dev environment — drive the real UI against a throwaway homeserver
```
scripts/dev-homeserver.sh start # Synapse in .dev-homeserver/ (venv, SQLite), open registration, no rate limits, :8008
python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an image every 10th
npm start # Vite on :5173
```
Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down.
## Automated coverage map — what the unit tests already pin (2026-07)
**Read this before working a `qa` issue.** Much of the _logic_ the manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Local throwaway Synapse for driving the real UI (Playwright / a browser)
# against a homeserver you control — open registration, no rate limits,
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
#
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
# scripts/dev-homeserver.sh stop
# scripts/dev-homeserver.sh reset # wipe the database and media
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images
#
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
# as alice / password123.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIR="$ROOT/.dev-homeserver"
VENV="$DIR/venv"
CFG="$DIR/homeserver.yaml"
PIDFILE="$DIR/synapse.pid"
install() {
mkdir -p "$DIR"
if [ ! -x "$VENV/bin/python" ]; then
python3 -m venv --without-pip "$VENV"
curl -sS https://bootstrap.pypa.io/get-pip.py -o "$DIR/get-pip.py"
"$VENV/bin/python" "$DIR/get-pip.py" -q
"$VENV/bin/pip" install -q matrix-synapse
fi
if [ ! -f "$CFG" ]; then
(cd "$DIR" && "$VENV/bin/python" -m synapse.app.homeserver \
--server-name localhost --config-path homeserver.yaml --generate-config --report-stats=no >/dev/null)
# the generated listener only serves `client`; add media + open the door
python3 - "$CFG" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
s = s.replace(" - client\n", " - client\n - media\n", 1)
s += """
enable_registration: true
enable_registration_without_verification: true
rc_message: { per_second: 1000, burst_count: 10000 }
rc_registration: { per_second: 1000, burst_count: 10000 }
rc_login: { address: { per_second: 1000, burst_count: 10000 }, account: { per_second: 1000, burst_count: 10000 }, failed_attempts: { per_second: 1000, burst_count: 10000 } }
rc_joins: { local: { per_second: 1000, burst_count: 10000 }, remote: { per_second: 1000, burst_count: 10000 } }
rc_presence: { per_user: { per_second: 1000, burst_count: 10000 } }
max_upload_size: 50M
suppress_key_server_warning: true
"""
open(p, "w").write(s)
PY
fi
}
start() {
install
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "already running (pid $(cat "$PIDFILE"))"; return
fi
(cd "$DIR" && setsid nohup "$VENV/bin/python" -m synapse.app.homeserver --config-path homeserver.yaml \
> synapse.log 2>&1 < /dev/null & echo $! > "$PIDFILE")
for _ in $(seq 1 40); do
sleep 1
curl -sf http://127.0.0.1:8008/_matrix/client/versions >/dev/null && { echo "synapse up on http://localhost:8008"; return; }
done
echo "synapse did not come up — see $DIR/synapse.log" >&2; exit 1
}
stop() {
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
esac
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users
and a busy unencrypted room: alice + bob, "Busy Room", N messages, one image
every 10th message. Idempotent for the users; every run creates a new room.
python3 scripts/dev-seed.py [N=400]
"""
import json, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
HS = "http://127.0.0.1:8008"
PASSWORD = "password123"
def req(method, path, data=None, token=None, raw=None, ctype="application/json"):
headers = {"Content-Type": ctype}
if token:
headers["Authorization"] = f"Bearer {token}"
body = raw if raw is not None else (json.dumps(data).encode() if data is not None else None)
r = urllib.request.Request(HS + path, data=body, headers=headers, method=method)
return json.load(urllib.request.urlopen(r))
def register_or_login(user):
try:
return req("POST", "/_matrix/client/v3/register",
{"username": user, "password": PASSWORD, "auth": {"type": "m.login.dummy"}})
except urllib.error.HTTPError:
return req("POST", "/_matrix/client/v3/login",
{"type": "m.login.password", "identifier": {"type": "m.id.user", "user": user}, "password": PASSWORD})
def png(w, h, rgb):
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
def chunk(t, d):
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 400
alice, bob = register_or_login("alice"), register_or_login("bob")
ta, tb = alice["access_token"], bob["access_token"]
room = req("POST", "/_matrix/client/v3/createRoom",
{"name": "Busy Room", "preset": "public_chat", "visibility": "public"}, ta)["room_id"]
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(room)}", {}, tb)
txn = int(time.time() * 1000)
for i in range(n):
tok = ta if i % 2 == 0 else tb
if i % 10 == 0:
data = png(64, 48, ((i * 37) % 256, (i * 91) % 256, (i * 17) % 256))
up = req("POST", f"/_matrix/media/v3/upload?filename=img{i}.png", token=tok, raw=data, ctype="image/png")
content = {"msgtype": "m.image", "body": f"img{i}.png", "url": up["content_uri"],
"info": {"mimetype": "image/png", "w": 64, "h": 48, "size": len(data)}}
else:
content = {"msgtype": "m.text", "body": f"message #{i}"}
txn += 1
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
print(json.dumps({"room": room, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
if __name__ == "__main__":
main()
@@ -4,6 +4,7 @@ import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { createDetachedTimelineSet } from '../../utils/detachedTimeline';
import { useRoomName } from '../../hooks/useRoomMeta';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
@@ -73,7 +74,10 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
// so we must deduplicate by eventId to avoid re-adding the same events
// on each pagination step.
const seen = new Set<string>();
const timeline = room.getLiveTimeline();
// [Gitea #163] Paginate a private timeline set, not the live one: the
// export can pull thousands of events and the room behind this modal
// renders the live timeline by index.
const timeline = createDetachedTimelineSet(mx, room).set.getLiveTimeline();
let canLoadMore = true;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
@@ -1,9 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createTypesFilter,
} from '../../utils/detachedTimeline';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -343,15 +348,23 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
// [Gitea #163] Page through a detached, type-filtered timeline set: the
// live timeline (rendered by index behind this modal) must not be mutated.
const detached = useMemo(
() =>
createDetachedTimelineSet(mx, room, {
filter: createTypesFilter(mx.getSafeUserId(), STATE_EVENT_TYPES, 'io.lotus.activity'),
filterSafeWhenEncrypted: true,
}),
[mx, room],
);
const getStateEvents = useCallback((): MatrixEvent[] => {
const typeSet = new Set<string>(STATE_EVENT_TYPES);
return room
.getLiveTimeline()
.getEvents()
return collectTimelineEvents(detached.set.getLiveTimeline())
.filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
.slice()
.reverse();
}, [room]);
}, [detached]);
const [events, setEvents] = useState<MatrixEvent[]>(() => getStateEvents());
@@ -363,7 +376,7 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
if (loading || !canLoadMore) return;
setLoading(true);
try {
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
const hasMore = await mx.paginateEventTimeline(detached.set.getLiveTimeline(), {
backwards: true,
limit: 50,
});
@@ -375,7 +388,7 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
} finally {
setLoading(false);
}
}, [loading, canLoadMore, mx, room, getStateEvents]);
}, [loading, canLoadMore, mx, detached, getStateEvents]);
// Auto-paginate on mount — state events are rarely in the initial sync
// window, so we immediately fetch backwards to populate the log.
+25 -64
View File
@@ -17,7 +17,7 @@ import {
color,
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
@@ -31,6 +31,7 @@ import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
@@ -702,10 +703,17 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
);
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const [loadError, setLoadError] = useState(false);
// [Gitea #163] Media is paginated on its own timeline set — never on
// `room.getLiveTimeline()`, which the message list behind this drawer is
// rendering by index.
const {
events: mediaEvents,
loadMore: handleLoadMore,
loading,
loadError,
canLoadMore,
hasLoadedOnce,
} = useRoomMediaTimeline(mx, room);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -730,51 +738,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const msgtype = TAB_MSGTYPES[tab];
const getFilteredEvents = useCallback(
(): MatrixEvent[] =>
room
.getLiveTimeline()
.getEvents()
.filter((ev) => {
if (ev.isRedacted()) return false;
const c = ev.getContent();
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
})
.slice()
.reverse(),
[room, msgtype],
const events = useMemo(
() => mediaEvents.filter((ev) => ev.getContent().msgtype === msgtype),
[mediaEvents, msgtype],
);
const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());
useEffect(() => {
setEvents(getFilteredEvents());
setCanLoadMore(true);
setHasLoadedOnce(false);
setLoadError(false);
}, [getFilteredEvents]);
const handleLoadMore = useCallback(async () => {
if (loading || !canLoadMore) return;
setLoading(true);
setLoadError(false);
try {
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
backwards: true,
limit: 100,
});
setEvents(getFilteredEvents());
setCanLoadMore(hasMore);
setHasLoadedOnce(true);
} catch {
// Stop auto-retry: the sentinel would keep firing on every render otherwise.
// The user can retry manually via the button shown in the error state.
setLoadError(true);
} finally {
setLoading(false);
}
}, [loading, canLoadMore, mx, room, getFilteredEvents]);
// Auto-load when sentinel scrolls into view
useEffect(() => {
const sentinel = sentinelRef.current;
@@ -814,25 +782,18 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
};
});
// Per-tab counts for the tab labels (single pass over loaded timeline)
// Per-tab counts for the tab labels (single pass over the loaded media)
const tabCounts = useMemo(() => {
const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
room
.getLiveTimeline()
.getEvents()
.forEach((ev) => {
if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return;
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.Audio) counts.audio += 1;
else if (mt === MsgType.File) counts.file += 1;
});
mediaEvents.forEach((ev) => {
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.Audio) counts.audio += 1;
else if (mt === MsgType.File) counts.file += 1;
});
return counts;
// `events` is intentional: it changes when more history is paginated in, so
// the counts stay in sync with the loaded window (it isn't read directly).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, events]);
}, [mediaEvents]);
// Group image/video events by month for the grid
type MonthGroup = { label: string; events: MatrixEvent[] };
+54
View File
@@ -82,6 +82,9 @@ import { UserAvatar } from '../../../components/user-avatar';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
import { getOriginBaseUrl } from '../../../pages/pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
@@ -417,6 +420,47 @@ export const MessageCopyLinkItem = as<
);
});
// [Gitea #130] Same as Copy Link but a direct link into THIS deployment, for
// recipients who use Lotus (matrix.to cannot be pointed here).
export const MessageCopyLotusLinkItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { hashRouter } = useClientConfig();
const handleCopy = () => {
const eventId = mEvent.getId();
if (!eventId) return;
copyToClipboard(
getLotusRoomPermalink(
getOriginBaseUrl(hashRouter),
room.roomId,
eventId,
getViaServers(room),
),
);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Copy Lotus Link
</Text>
</MenuItem>
);
});
// Copies the message's plain-text body (reply fallback stripped) to the
// clipboard. Renders nothing for events without a usable text body (e.g. media
// without a caption), so the caller can list it unconditionally.
@@ -1379,6 +1423,11 @@ export const Message = React.memo(
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLotusLinkItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
@@ -1610,6 +1659,11 @@ export const Event = React.memo(
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLotusLinkItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EventTimelineSetHandlerMap,
EventType,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
MsgType,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createMediaFilter,
DetachedTimelineSet,
} from '../utils/detachedTimeline';
import { decryptAllTimelineEvent } from '../utils/room';
export const MEDIA_MSGTYPES: ReadonlySet<string> = new Set<string>([
MsgType.Image,
MsgType.Video,
MsgType.Audio,
MsgType.File,
]);
export const isMediaMessage = (event: MatrixEvent): boolean =>
event.getType() === EventType.RoomMessage &&
!event.isRedacted() &&
MEDIA_MSGTYPES.has(event.getContent().msgtype as string);
const PAGE_SIZE = 100;
export type RoomMediaTimeline = {
/** Every loaded media message in the room, newest first. */
events: MatrixEvent[];
loadMore: () => Promise<void>;
loading: boolean;
loadError: boolean;
canLoadMore: boolean;
hasLoadedOnce: boolean;
};
/**
* [Gitea #163] Media events for the gallery, paginated on a timeline set of
* their own so the room's live timeline (and the message list rendering it)
* is never mutated. Unencrypted rooms page through a server-side
* `contains_url` filter (100 media per page); encrypted rooms page raw
* history into a private set and filter after decrypting, exactly what the
* gallery used to do on the live timeline.
*/
export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTimeline => {
const detached = useMemo<DetachedTimelineSet>(
() => createDetachedTimelineSet(mx, room, { filter: createMediaFilter(mx.getSafeUserId()) }),
[mx, room],
);
const readEvents = useCallback(
(): MatrixEvent[] =>
collectTimelineEvents(detached.set.getLiveTimeline()).filter(isMediaMessage).reverse(),
[detached],
);
const [events, setEvents] = useState<MatrixEvent[]>(readEvents);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const loadingRef = useRef(false);
useEffect(() => {
setEvents(readEvents());
setCanLoadMore(true);
setHasLoadedOnce(false);
setLoadError(false);
}, [readEvents]);
// Live updates. The server-filtered set is registered with the room and
// receives new events itself; the private set (encrypted rooms) is fed here
// once the event has decrypted. Redactions are applied to both.
useEffect(() => {
const { set, serverFiltered } = detached;
const refresh = () => setEvents(readEvents());
const addIfMedia = (event: MatrixEvent) => {
if (!isMediaMessage(event)) return;
if (set.findEventById(event.getId() ?? '')) return;
set.addLiveEvent(event, { addToState: false });
refresh();
};
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event,
eventRoom,
_toStart,
_removed,
data,
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (serverFiltered) {
// The room already routed it into our set (client-side filtered).
if (data.timeline.getTimelineSet() === set) refresh();
return;
}
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
event.once(MatrixEventEvent.Decrypted, () => addIfMedia(event));
return;
}
addIfMedia(event);
};
const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (event, eventRoom) => {
if (eventRoom?.roomId !== room.roomId) return;
const redactedId = event.event.redacts;
if (!redactedId) return;
if (!serverFiltered) set.removeEvent(redactedId);
refresh();
};
room.on(RoomEvent.Timeline, onTimeline);
room.on(RoomEvent.Redaction, onRedaction);
return () => {
room.removeListener(RoomEvent.Timeline, onTimeline);
room.removeListener(RoomEvent.Redaction, onRedaction);
};
}, [detached, room, readEvents]);
const loadMore = useCallback(async () => {
if (loadingRef.current || !canLoadMore) return;
loadingRef.current = true;
setLoading(true);
setLoadError(false);
try {
const timeline = detached.set.getLiveTimeline();
const hasMore = await mx.paginateEventTimeline(timeline, {
backwards: true,
limit: PAGE_SIZE,
});
if (room.hasEncryptionStateEvent()) {
await decryptAllTimelineEvent(mx, timeline);
}
setEvents(readEvents());
setCanLoadMore(hasMore);
setHasLoadedOnce(true);
} catch {
// Stop auto-retry: the sentinel would keep firing on every render otherwise.
// The user can retry manually via the button shown in the error state.
setLoadError(true);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [mx, room, detached, canLoadMore, readEvents]);
return { events, loadMore, loading, loadError, canLoadMore, hasLoadedOnce };
};
+2 -2
View File
@@ -25,9 +25,9 @@ export const useRoomNavigate = () => {
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback(
(roomId: string) => {
(roomId: string, opts?: NavigateOptions) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
navigate(getSpacePath(roomIdOrAlias));
navigate(getSpacePath(roomIdOrAlias), opts);
},
[mx, navigate],
);
+5 -2
View File
@@ -46,7 +46,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { setAfterLoginRedirectPath, takeAfterLoginPath } from './afterLoginRedirectPath';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -112,7 +112,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route
index
loader={() => {
if (getFallbackSession()) return redirect(getHomePath());
// A stored deep link is normally consumed by the password login;
// the OIDC callback instead reloads at the app root, so consume it
// here too (Gitea #130 — SSO users landing on a shared room link).
if (getFallbackSession()) return redirect(takeAfterLoginPath(getHomePath()));
const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
+11
View File
@@ -10,3 +10,14 @@ export const getAfterLoginRedirectPath = (): string | undefined => {
export const deleteAfterLoginRedirectPath = (): void => {
localStorage.removeItem(AFTER_LOGIN_REDIRECT_PATH_KEY);
};
/**
* The in-app path to land on after a login: the stored deep link when it is a
* plain app path (never a protocol-relative or absolute URL), else `fallback`.
* Consumes the stored value. Shared by the password and OIDC flows.
*/
export const takeAfterLoginPath = (fallback: string): string => {
const stored = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
return stored && /^\/(?!\/)/.test(stored) ? stored : fallback;
};
+2 -9
View File
@@ -5,10 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
import { autoDiscovery, specVersions } from '../../../cs-api';
import { ErrorCode } from '../../../cs-errorcode';
import {
deleteAfterLoginRedirectPath,
getAfterLoginRedirectPath,
} from '../../afterLoginRedirectPath';
import { takeAfterLoginPath } from '../../afterLoginRedirectPath';
import { getHomePath } from '../../pathUtils';
import { setFallbackSession } from '../../../state/sessions';
@@ -115,11 +112,7 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
if (data) {
const { response: loginRes, baseUrl: loginBaseUrl } = data;
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
const _redir = afterLoginRedirectUrl;
const _safePath = _redir && /^\/(?!\/)/.test(_redir) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
navigate(takeAfterLoginPath(getHomePath()), { replace: true });
}
}, [data, navigate]);
};
+15 -1
View File
@@ -20,11 +20,15 @@ import { notificationPermission, setFavicon, showOsNotification } from '../../ut
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
import {
setLotusPermalinkBase,
setStripTrackingOnRender,
} from '../../plugins/react-custom-html-parser';
import { useSettingsSync } from '../../hooks/useSettingsSync';
import { usePwaInstallPrompt } from '../../hooks/usePwaInstallPrompt';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import {
@@ -92,6 +96,15 @@ function SystemEmojiFeature() {
}
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function LotusPermalinkFeature() {
const { hashRouter } = useClientConfig();
useEffect(() => {
setLotusPermalinkBase(getOriginBaseUrl(hashRouter));
return () => setLotusPermalinkBase(undefined);
}, [hashRouter]);
return null;
}
function TrackingParamsFeature() {
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
useEffect(() => {
@@ -928,6 +941,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<SearchCacheInvalidationFeature />
<SystemEmojiFeature />
<PageZoomFeature />
<LotusPermalinkFeature />
<TrackingParamsFeature />
<SettingsSyncFeature />
<PwaInstallFeature />
+17 -1
View File
@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
@@ -6,6 +6,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
@@ -16,6 +17,21 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
// [Gitea #130] `/home/<room>` is the universal permalink form (Lotus links,
// matrix.to → Cinny links). A room we are already in but that lives under a
// space or in Direct goes straight to its own route instead of a preview
// card with a "View" button.
const { navigateRoom, navigateSpace } = useRoomNavigate();
const joinedElsewhere =
!!room && room.getMyMembership() === 'join' && !rooms.includes(room.roomId);
useEffect(() => {
if (!joinedElsewhere || !room) return;
if (room.isSpaceRoom()) navigateSpace(room.roomId, { replace: true });
else navigateRoom(room.roomId, eventId, { replace: true });
}, [joinedElsewhere, room, eventId, navigateRoom, navigateSpace]);
if (joinedElsewhere) return null;
if (!room || !rooms.includes(room.roomId)) {
return (
<JoinBeforeNavigate
+29 -1
View File
@@ -47,7 +47,13 @@ import {
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import {
SidebarAvatar,
SidebarItem,
@@ -85,6 +91,7 @@ import { markAsRead } from '../../../utils/notifications';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getLotusSpacePermalink } from '../../../plugins/lotus-permalink';
import { getViaServers } from '../../../plugins/via-servers';
import { getRoomAvatarUrl } from '../../../utils/room';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -103,6 +110,7 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
@@ -138,6 +146,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
requestClose();
};
// [Gitea #130] direct link into this deployment
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
@@ -209,6 +227,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLotusLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Lotus Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
+29 -1
View File
@@ -39,7 +39,13 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpaceRoomPath,
getSpaceSearchPath,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
@@ -70,6 +76,7 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
import { Membership, StateEvent } from '../../../../types/matrix/room';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getLotusSpacePermalink } from '../../../plugins/lotus-permalink';
import { getViaServers } from '../../../plugins/via-servers';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
@@ -93,6 +100,7 @@ type SpaceMenuProps = {
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
@@ -125,6 +133,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
requestClose();
};
// [Gitea #130] direct link into this deployment
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
@@ -189,6 +207,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLotusLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Lotus Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
+3 -1
View File
@@ -3,7 +3,9 @@ import { _RoomSearchParams, DirectCreateSearchParams } from './paths';
type SearchParamsGetter<T> = (searchParams: URLSearchParams) => T;
export const getRoomSearchParams: SearchParamsGetter<_RoomSearchParams> = (searchParams) => ({
viaServers: searchParams.get('viaServers') ?? undefined,
// `via` is what the matrix.to Cinny adapter emits (`?via=a,b`); accept it as
// an alias so those links keep their routing hints (Gitea #130).
viaServers: searchParams.get('viaServers') ?? searchParams.get('via') ?? undefined,
});
export const getDirectCreateSearchParams: SearchParamsGetter<DirectCreateSearchParams> = (
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getLotusRoomPermalink,
getLotusSpacePermalink,
lotusPermalinkToMatrixTo,
parseLotusPermalink,
} from './lotus-permalink';
const BASE = 'https://chat.lotusguild.org/';
test('builds room / event / space permalinks with encoded ids and viaServers', () => {
assert.equal(
getLotusRoomPermalink(BASE, '!abc:matrix.lotusguild.org', '$ev1', ['matrix.lotusguild.org']),
'https://chat.lotusguild.org/home/!abc%3Amatrix.lotusguild.org/%24ev1?viaServers=matrix.lotusguild.org',
);
assert.equal(
getLotusRoomPermalink(BASE, '#general:matrix.lotusguild.org'),
'https://chat.lotusguild.org/home/%23general%3Amatrix.lotusguild.org',
);
assert.equal(
getLotusSpacePermalink('https://chat.lotusguild.org', '#guild:matrix.lotusguild.org'),
'https://chat.lotusguild.org/%23guild%3Amatrix.lotusguild.org',
);
});
test('parses its own output back (encoded and decoded forms)', () => {
const link = getLotusRoomPermalink(BASE, '!abc:lotus', '$ev1', ['a.org', 'b.org']);
assert.deepEqual(parseLotusPermalink(BASE, link), {
roomIdOrAlias: '!abc:lotus',
eventId: '$ev1',
viaServers: ['a.org', 'b.org'],
});
assert.deepEqual(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/!abc:lotus/$ev1'), {
roomIdOrAlias: '!abc:lotus',
eventId: '$ev1',
viaServers: undefined,
});
assert.deepEqual(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/direct/!dm:lotus/'), {
roomIdOrAlias: '!dm:lotus',
viaServers: undefined,
});
});
test('space routes: lobby → the space, nested room → the room', () => {
assert.deepEqual(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/%23guild%3Alotus/'), {
roomIdOrAlias: '#guild:lotus',
viaServers: undefined,
});
assert.deepEqual(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org/%23guild%3Alotus/!room%3Alotus/%24e'),
{ roomIdOrAlias: '!room:lotus', eventId: '$e', viaServers: undefined },
);
});
test('accepts the matrix.to Cinny adapter form (?via=a,b)', () => {
assert.deepEqual(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/!r:x?via=a.org,b.org'),
{
roomIdOrAlias: '!r:x',
viaServers: ['a.org', 'b.org'],
},
);
});
test('rejects other origins, non-room routes and malformed ids', () => {
assert.equal(parseLotusPermalink(BASE, 'https://app.cinny.in/home/!r:x'), undefined);
assert.equal(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/settings'), undefined);
assert.equal(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/explore/lotus'), undefined);
assert.equal(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/!r:x/notanevent'),
undefined,
);
assert.equal(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org.evil.com/home/!r:x'),
undefined,
);
assert.equal(parseLotusPermalink(BASE, 'https://chat.lotusguild.org'), undefined);
});
test('rewrites to the matrix.to form the existing mention pipeline understands', () => {
assert.equal(
lotusPermalinkToMatrixTo(BASE, 'https://chat.lotusguild.org/home/!r:x/$e?viaServers=a.org'),
'https://matrix.to/#/!r:x/$e?via=a.org',
);
assert.equal(
lotusPermalinkToMatrixTo(BASE, 'https://chat.lotusguild.org/%23guild%3Ax/'),
'https://matrix.to/#/#guild:x',
);
assert.equal(lotusPermalinkToMatrixTo(BASE, 'https://example.org/home/!r:x'), undefined);
});
+109
View File
@@ -0,0 +1,109 @@
import { getHomeRoomPath, getSpacePath, withSearchParam } from '../pages/pathUtils';
import { _RoomSearchParams } from '../pages/paths';
import { MatrixToRoom, MatrixToRoomEvent } from './matrix-to';
/**
* [Gitea #130] Direct Lotus permalinks.
*
* `matrix.to` cannot be pointed at this deployment (its Cinny adapter is
* hard-coded to app.cinny.in and `web-instance[]` only works for Element), so
* next to every "Copy Link" there is a "Copy Lotus Link" that yields
* `https://<this deployment>/home/<room>/<event>?viaServers=…` — the same
* routes the app already serves cold (logged-out → login → back to the room).
*
* `baseUrl` is `getOriginBaseUrl(hashRouter)` so the result is right for any
* deployment and for the hash-router config.
*/
const trimSlash = (s: string): string => s.replace(/\/+$/, '');
const withVia = (path: string, viaServers?: string[]): string =>
viaServers && viaServers.length > 0
? withSearchParam<_RoomSearchParams>(path, { viaServers: viaServers.join(',') })
: path;
export const getLotusRoomPermalink = (
baseUrl: string,
roomIdOrAlias: string,
eventId?: string,
viaServers?: string[],
): string => `${trimSlash(baseUrl)}${withVia(getHomeRoomPath(roomIdOrAlias, eventId), viaServers)}`;
export const getLotusSpacePermalink = (
baseUrl: string,
spaceIdOrAlias: string,
viaServers?: string[],
): string => `${trimSlash(baseUrl)}${withVia(getSpacePath(spaceIdOrAlias), viaServers)}`;
const tryDecode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const isRoomIdOrAlias = (s: string): boolean => /^[!#][^/\s]+:[^/\s]+$/.test(s);
const isEventId = (s: string): boolean => /^\$[^/\s]+$/.test(s);
/**
* Parse a link into the same shape the matrix.to parsers return, when it
* points at THIS deployment's room/space routes:
*
* <base>/home/<room>/<event>?viaServers=a,b
* <base>/direct/<room>/<event>
* <base>/<space>/ (space lobby)
* <base>/<space>/<room>/<event> (room inside a space → the room)
*
* Anything else — settings pages, explore, a different origin — is undefined.
* `via` is accepted as an alias of `viaServers` because the matrix.to Cinny
* adapter emits it.
*/
export const parseLotusPermalink = (
baseUrl: string,
href: string,
): MatrixToRoom | MatrixToRoomEvent | undefined => {
const base = trimSlash(baseUrl);
if (!href.startsWith(`${base}/`)) return undefined;
const rest = href.slice(base.length);
let url: URL;
try {
url = new URL(rest, 'http://lotus.invalid');
} catch {
return undefined;
}
const segments = url.pathname
.split('/')
.filter((s) => s.length > 0)
.map(tryDecode);
const via = [...url.searchParams.getAll('viaServers'), ...url.searchParams.getAll('via')]
.flatMap((v) => v.split(','))
.map((v) => v.trim())
.filter((v) => v.length > 0);
const viaServers = via.length > 0 ? via : undefined;
let roomIdOrAlias: string | undefined;
let eventId: string | undefined;
if (segments[0] === 'home' || segments[0] === 'direct') {
[, roomIdOrAlias, eventId] = segments;
} else if (segments[0] && isRoomIdOrAlias(segments[0])) {
// /<space>/ or /<space>/<room>/<event>
roomIdOrAlias = segments[1] && isRoomIdOrAlias(segments[1]) ? segments[1] : segments[0];
if (segments[1] && isRoomIdOrAlias(segments[1])) [, , eventId] = segments;
}
if (!roomIdOrAlias || !isRoomIdOrAlias(roomIdOrAlias)) return undefined;
if (eventId !== undefined && !isEventId(eventId)) return undefined;
return eventId ? { roomIdOrAlias, eventId, viaServers } : { roomIdOrAlias, viaServers };
};
/** Rewrite a Lotus permalink as the matrix.to link the rest of the app already understands. */
export const lotusPermalinkToMatrixTo = (baseUrl: string, href: string): string | undefined => {
const parsed = parseLotusPermalink(baseUrl, href);
if (!parsed) return undefined;
const fragment =
'eventId' in parsed ? `${parsed.roomIdOrAlias}/${parsed.eventId}` : parsed.roomIdOrAlias;
const query = parsed.viaServers ? `?${parsed.viaServers.map((s) => `via=${s}`).join('&')}` : '';
return `https://matrix.to/#/${fragment}${query}`;
};
+25 -4
View File
@@ -23,6 +23,7 @@ import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import { stripTrackingParams } from '../utils/urlTracking';
import { lotusPermalinkToMatrixTo } from './lotus-permalink';
import * as css from '../styles/CustomHtml.css';
import {
getMxIdLocalPart,
@@ -118,6 +119,22 @@ export const setStripTrackingOnRender = (enabled: boolean): void => {
const cleanHref = (href: string): string =>
stripTrackingOnRender ? stripTrackingParams(href) : href;
// [Gitea #130] Links to THIS deployment's room routes render and click like
// matrix.to links. The base is set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks.
let lotusPermalinkBase: string | undefined;
export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
lotusPermalinkBase = baseUrl;
};
/**
* The matrix.to form of `href` when it is a matrix.to link or a Lotus
* permalink; undefined for every other link.
*/
export const toMatrixToHref = (href: string): string | undefined => {
if (testMatrixTo(href)) return href;
return lotusPermalinkBase ? lotusPermalinkToMatrixTo(lotusPermalinkBase, href) : undefined;
};
export const LINKIFY_OPTS: LinkifyOpts = {
attributes: {
target: '_blank',
@@ -235,8 +252,10 @@ export const factoryRenderLinkifyWithMention = (
attributes,
content,
}) => {
if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) {
const mention = mentionRender(tryDecodeURIComponent(attributes.href));
const matrixHref =
tagName === 'a' ? toMatrixToHref(tryDecodeURIComponent(attributes.href)) : undefined;
if (matrixHref) {
const mention = mentionRender(matrixHref);
if (mention) return mention;
}
@@ -559,7 +578,9 @@ export const getReactCustomHtmlParser = (
}
}
if (name === 'a' && testMatrixTo(tryDecodeURIComponent(String(props.href)))) {
const matrixHref =
name === 'a' ? toMatrixToHref(tryDecodeURIComponent(String(props.href))) : undefined;
if (matrixHref) {
const content = children.find((child) => !(child instanceof DOMText))
? undefined
: children.map((c) => (c instanceof DOMText ? c.data : '')).join();
@@ -567,7 +588,7 @@ export const getReactCustomHtmlParser = (
const mention = renderMatrixMention(
mx,
roomId,
tryDecodeURIComponent(String(props.href)),
matrixHref,
makeMentionCustomProps(params.handleMentionClick, content),
);
+141
View File
@@ -0,0 +1,141 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Direction, EventTimeline, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createMediaFilter,
createTypesFilter,
getEarliestLinkedTimeline,
} from './detachedTimeline';
const ROOM_ID = '!room:localhost';
const makeClient = (): MatrixClient =>
({
getUserId: () => '@alice:localhost',
getSafeUserId: () => '@alice:localhost',
supportsThreads: () => false,
canSupport: new Map(),
isRoomEncrypted: () => false,
getRooms: () => [],
decryptEventIfNeeded: () => Promise.resolve(),
reEmitter: { reEmit: () => undefined },
getCrypto: () => undefined,
}) as unknown as MatrixClient;
const makeRoom = (mx: MatrixClient, encrypted = false): Room => {
const room = new Room(ROOM_ID, mx, '@alice:localhost', { timelineSupport: true });
if (encrypted) {
room.currentState.setStateEvents([
new MatrixEvent({
type: 'm.room.encryption',
state_key: '',
room_id: ROOM_ID,
sender: '@alice:localhost',
content: { algorithm: 'm.megolm.v1.aes-sha2' },
event_id: '$enc',
origin_server_ts: 1,
}),
]);
}
return room;
};
let counter = 0;
const msg = (body: string, extra: Record<string, unknown> = {}): MatrixEvent => {
counter += 1;
return new MatrixEvent({
type: 'm.room.message',
room_id: ROOM_ID,
sender: '@bob:localhost',
content: { msgtype: 'm.text', body, ...extra },
event_id: `$e${counter}`,
origin_server_ts: counter,
});
};
test('collectTimelineEvents walks backward neighbours oldest-first', () => {
const room = makeRoom(makeClient());
const set = room.getUnfilteredTimelineSet();
const live = set.getLiveTimeline();
const older = new EventTimeline(set);
older.setNeighbouringTimeline(live, Direction.Forward);
live.setNeighbouringTimeline(older, Direction.Backward);
const a = msg('a');
const b = msg('b');
const c = msg('c');
older.addEvent(a, { toStartOfTimeline: false, addToState: false });
live.addEvent(b, { toStartOfTimeline: false, addToState: false });
live.addEvent(c, { toStartOfTimeline: false, addToState: false });
assert.deepEqual(
collectTimelineEvents(live).map((e) => e.getContent().body),
['a', 'b', 'c'],
);
assert.equal(getEarliestLinkedTimeline(live), older);
assert.equal(getEarliestLinkedTimeline(older), older);
});
test('createMediaFilter targets m.room.message with a url; types filter is type-only', () => {
const media = createMediaFilter('@alice:localhost').getDefinition();
assert.deepEqual(media.room?.timeline, { types: ['m.room.message'], contains_url: true });
const types = createTypesFilter('@alice:localhost', ['m.room.member'], 'x').getDefinition();
assert.deepEqual(types.room?.timeline, { types: ['m.room.member'] });
});
test('private detached set: seeded from live history, back token copied, live timeline untouched', () => {
const mx = makeClient();
const room = makeRoom(mx);
const live = room.getLiveTimeline();
live.setPaginationToken('tok-back', Direction.Backward);
const events = [msg('1'), msg('2', { msgtype: 'm.image', url: 'mxc://x/y' }), msg('3')];
events.forEach((e) => live.addEvent(e, { toStartOfTimeline: false, addToState: false }));
const { set, serverFiltered } = createDetachedTimelineSet(mx, room); // no filter → private
assert.equal(serverFiltered, false);
assert.notEqual(set, room.getUnfilteredTimelineSet());
assert.equal(room.getTimelineSets().includes(set), false, 'must not be registered on the room');
assert.deepEqual(
set
.getLiveTimeline()
.getEvents()
.map((e) => e.getContent().body),
['1', '2', '3'],
);
assert.equal(set.getLiveTimeline().getPaginationToken(Direction.Backward), 'tok-back');
// Prepending into the detached set (what /messages pagination does) leaves
// the live timeline's arrays — and therefore RoomTimeline's indices — alone.
set.getLiveTimeline().addEvent(msg('0'), { toStartOfTimeline: true, addToState: false });
assert.equal(live.getEvents().length, 3);
assert.equal(set.getLiveTimeline().getEvents().length, 4);
});
test('filtered detached set is used in plain rooms and skipped in encrypted rooms', () => {
const mx = makeClient();
const filter = createMediaFilter('@alice:localhost');
const plain = makeRoom(mx);
const plainLive = plain.getLiveTimeline();
[msg('t'), msg('i', { msgtype: 'm.image', url: 'mxc://x/y' })].forEach((e) =>
plainLive.addEvent(e, { toStartOfTimeline: false, addToState: false }),
);
const a = createDetachedTimelineSet(mx, plain, { filter });
assert.equal(a.serverFiltered, true);
assert.equal(plain.getTimelineSets().includes(a.set), true, 'registered so it gets live events');
assert.deepEqual(
a.set
.getLiveTimeline()
.getEvents()
.map((e) => e.getContent().body),
['i'],
'prepopulated with the client-side filtered subset',
);
assert.equal(createDetachedTimelineSet(mx, plain, { filter }).set, a.set, 'cached per filter');
const enc = makeRoom(mx, true);
const b = createDetachedTimelineSet(mx, enc, { filter });
assert.equal(b.serverFiltered, false, 'contains_url cannot see ciphertext');
const c = createDetachedTimelineSet(mx, enc, { filter, filterSafeWhenEncrypted: true });
assert.equal(c.serverFiltered, true, 'type-only filters are fine in encrypted rooms');
});
+143
View File
@@ -0,0 +1,143 @@
import {
Direction,
EventTimeline,
EventTimelineSet,
Filter,
MatrixClient,
MatrixEvent,
Room,
} from 'matrix-js-sdk';
/**
* [Gitea #163] Timelines that paginate history WITHOUT touching the room's
* live timeline.
*
* `RoomTimeline` renders a numeric window (`range`) of absolute indices into
* the live timeline's event arrays. Backwards pagination in the SDK PREPENDS
* (`events.splice(0, 0, …)`), so anything else that calls
* `paginateEventTimeline(room.getLiveTimeline())` — the media gallery, the
* activity log, history export — silently shifts what those indices point at
* and the visible timeline "jumps into the past" on its next render. The fix
* is for side panels to page through their own `EventTimelineSet`; this file
* is the shared plumbing for that.
*/
/** All events of a timeline and its backward neighbours, oldest first. */
export const collectTimelineEvents = (timeline: EventTimeline): MatrixEvent[] => {
const timelines: EventTimeline[] = [];
let current: EventTimeline | null = timeline;
while (current) {
timelines.unshift(current);
current = current.getNeighbouringTimeline(Direction.Backward);
}
return timelines.flatMap((t) => t.getEvents());
};
/** Earliest timeline linked backwards from `timeline` (where the back token lives). */
export const getEarliestLinkedTimeline = (timeline: EventTimeline): EventTimeline => {
let current = timeline;
let prev = current.getNeighbouringTimeline(Direction.Backward);
while (prev) {
current = prev;
prev = current.getNeighbouringTimeline(Direction.Backward);
}
return current;
};
/**
* Server-side filter for the media gallery in unencrypted rooms: only
* `m.room.message` events whose content carries a `url`, i.e. image / video /
* audio / file. `/messages` then returns 100 media events per page instead of
* 100 events of which a handful are media. Useless in encrypted rooms (the
* server only sees ciphertext), so `createDetachedTimelineSet` skips it there.
*/
export const createMediaFilter = (userId: string): Filter => {
const filter = new Filter(userId);
filter.setDefinition({
room: {
timeline: {
types: ['m.room.message'],
contains_url: true,
},
},
});
// Key for `room.filteredTimelineSets` (the SDK caches per filterId) — only
// ever sent to the server inline on `/messages`, never registered via
// `/filter`, so any stable string will do.
filter.filterId = 'io.lotus.media';
return filter;
};
/**
* Server-side filter on event `type` only — safe in encrypted rooms because
* state events are never encrypted.
*/
export const createTypesFilter = (userId: string, types: readonly string[], id: string): Filter => {
const filter = new Filter(userId);
filter.setDefinition({ room: { timeline: { types: [...types] } } });
filter.filterId = id;
return filter;
};
export type DetachedTimelineSet = {
set: EventTimelineSet;
/** True when the server filters pages for us (the filter was usable). */
serverFiltered: boolean;
};
export type DetachedTimelineOptions = {
/** Server-side filter for `/messages` (and client-side for live events). */
filter?: Filter;
/**
* Set when the filter only looks at unencrypted fields (event `type`,
* `sender`, `state_key`), so it is still correct in an encrypted room. A
* content filter such as `contains_url` must leave this false.
*/
filterSafeWhenEncrypted?: boolean;
};
/**
* Build a timeline set for `room` that mirrors the live timeline's already
* loaded history (no refetch) and can be paginated backwards without ever
* mutating `room.getLiveTimeline()`.
*
* - usable `filter` → `room.getOrCreateFilteredTimelineSet`: registered with
* the room, so it also receives live events (client-side filtered) and
* redactions for free, and it is cached on the room across open/close.
* - otherwise → a private `EventTimelineSet` seeded from the live timeline;
* the caller feeds it live events via `addLiveEvent` (see
* `useRoomMediaTimeline`). Not registered with the room, so a
* `TimelineReset` after a sync gap does not clear it — acceptable for a
* read-only side panel that is rebuilt on every open.
*/
export const createDetachedTimelineSet = (
mx: MatrixClient,
room: Room,
{ filter, filterSafeWhenEncrypted = false }: DetachedTimelineOptions = {},
): DetachedTimelineSet => {
const filterUsable = !!filter && (filterSafeWhenEncrypted || !room.hasEncryptionStateEvent());
if (filter && filterUsable) {
const set = room.getOrCreateFilteredTimelineSet(filter, {
prepopulateTimeline: true,
useSyncEvents: true,
pendingEvents: false,
});
return { set, serverFiltered: true };
}
const set = new EventTimelineSet(room, { timelineSupport: true, pendingEvents: false }, mx);
const live = room.getLiveTimeline();
// Seed with everything already loaded (already decrypted where applicable)
// so opening the panel never refetches what the timeline has.
collectTimelineEvents(live).forEach((event) => {
set.addLiveEvent(event, { addToState: false });
});
set
.getLiveTimeline()
.setPaginationToken(
getEarliestLinkedTimeline(live).getPaginationToken(Direction.Backward),
Direction.Backward,
);
return { set, serverFiltered: false };
};