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
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)) && (
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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> = (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user