From f528e5e440b461bb585c818eb3dd05ad2b161b05 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 00:18:45 -0400 Subject: [PATCH] feat(links): Copy Lotus Link permalinks, in-app recognition, /home redirect for joined rooms, via= alias, OIDC deep-link redirect (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:///home// ?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/ 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- LOTUS_FEATURES.md | 4 + src/app/features/room/message/Message.tsx | 54 +++++++++ src/app/hooks/useRoomNavigate.ts | 4 +- src/app/pages/Router.tsx | 7 +- src/app/pages/afterLoginRedirectPath.ts | 11 ++ src/app/pages/auth/login/loginUtil.ts | 11 +- src/app/pages/client/ClientNonUIFeatures.tsx | 16 ++- src/app/pages/client/home/RoomProvider.tsx | 18 ++- src/app/pages/client/sidebar/SpaceTabs.tsx | 30 ++++- src/app/pages/client/space/Space.tsx | 30 ++++- src/app/pages/pathSearchParam.ts | 4 +- src/app/plugins/lotus-permalink.test.ts | 91 ++++++++++++++++ src/app/plugins/lotus-permalink.ts | 109 +++++++++++++++++++ src/app/plugins/react-custom-html-parser.tsx | 29 ++++- 14 files changed, 396 insertions(+), 22 deletions(-) create mode 100644 src/app/plugins/lotus-permalink.test.ts create mode 100644 src/app/plugins/lotus-permalink.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index fed0ae06c..68b5859be 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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//?viaServers=…` (spaces: `//`). 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/` 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. diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 3b7bc646f..f547e159a 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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 ( + } + radii="300" + onClick={handleCopy} + {...props} + ref={ref} + > + + Copy Lotus Link + + + ); +}); + // 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( + {canPinEvent && ( )} @@ -1610,6 +1659,11 @@ export const Event = React.memo( + {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 0de0fe4f7..42db88f7f 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -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], ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 3e2dbef5a..29e7ebd46 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -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) { - 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()); diff --git a/src/app/pages/afterLoginRedirectPath.ts b/src/app/pages/afterLoginRedirectPath.ts index 60e09da33..acd5e87c0 100644 --- a/src/app/pages/afterLoginRedirectPath.ts +++ b/src/app/pages/afterLoginRedirectPath.ts @@ -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; +}; diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index af95b6fb0..ab9fdf7f0 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -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]); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 7835191ed..9c520f207 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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) { + diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 4e16f7977..f9b075577 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -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/` 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 ( ( ({ 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( 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( Copy Link + } + radii="300" + > + + Copy Lotus Link + + (({ 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(({ 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(({ room, requestClo Copy Link + } + radii="300" + > + + Copy Lotus Link + + = (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 = ( diff --git a/src/app/plugins/lotus-permalink.test.ts b/src/app/plugins/lotus-permalink.test.ts new file mode 100644 index 000000000..fb49f5cab --- /dev/null +++ b/src/app/plugins/lotus-permalink.test.ts @@ -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); +}); diff --git a/src/app/plugins/lotus-permalink.ts b/src/app/plugins/lotus-permalink.ts new file mode 100644 index 000000000..c1dbe776b --- /dev/null +++ b/src/app/plugins/lotus-permalink.ts @@ -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:///home//?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: + * + * /home//?viaServers=a,b + * /direct// + * // (space lobby) + * /// (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])) { + // // or /// + 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}`; +}; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 82c98e361..5368a9073 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -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), );