Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 1ea6987083 fix(desktop): Lotus links use the public web app, and open in-app (#248)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 9m19s
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.

- useLotusShareBase: in the desktop app, links for other people use config
  `webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
  form; otherwise the origin, as before. Used by "Copy Lotus Link" on
  messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
  desktop app.
- The web app is unchanged.

Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 19:39:48 -04:00
jared be8e49a2bb Merge pull request #247: readable poll card (#246)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 7m57s
2026-09-27 23:28:48 -04:00
7 changed files with 90 additions and 42 deletions
+3 -11
View File
@@ -83,8 +83,6 @@ import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink'; import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
import { getOriginBaseUrl } from '../../../pages/pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
@@ -98,6 +96,7 @@ import { useLongPress } from '../../../hooks/useLongPress';
import { ActionSheet } from '../../../components/action-sheet'; import { ActionSheet } from '../../../components/action-sheet';
import { useBookmarks } from '../../../hooks/useBookmarks'; import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence'; import { PresenceRingAvatar } from '../../../components/presence';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration'; import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
// Delivery status indicator for own messages // Delivery status indicator for own messages
@@ -460,18 +459,11 @@ export const MessageCopyLotusLinkItem = as<
onClose?: () => void; onClose?: () => void;
} }
>(({ room, mEvent, onClose, ...props }, ref) => { >(({ room, mEvent, onClose, ...props }, ref) => {
const { hashRouter } = useClientConfig(); const lotusBase = useLotusShareBase();
const handleCopy = () => { const handleCopy = () => {
const eventId = mEvent.getId(); const eventId = mEvent.getId();
if (!eventId) return; if (!eventId) return;
copyToClipboard( copyToClipboard(getLotusRoomPermalink(lotusBase, room.roomId, eventId, getViaServers(room)));
getLotusRoomPermalink(
getOriginBaseUrl(hashRouter),
room.roomId,
eventId,
getViaServers(room),
),
);
onClose?.(); onClose?.();
}; };
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lotusLinkBases, lotusShareBase } from './useLotusLinkBase';
const LOCAL = 'http://localhost:44548/#/';
const WEB = 'https://chat.lotusguild.org';
test('web: shared links use the page origin', () => {
assert.equal(lotusShareBase(WEB, false, WEB), WEB);
assert.deepEqual(lotusLinkBases(WEB, false, undefined), [WEB]);
});
test('desktop with webAppUrl: shared links use the public web app', () => {
assert.equal(lotusShareBase(LOCAL, true, `${WEB}/`), WEB);
assert.deepEqual(lotusLinkBases(LOCAL, true, WEB), [LOCAL, WEB]);
});
test('desktop without a valid webAppUrl falls back to the origin', () => {
[undefined, '', 'http://chat.lotusguild.org', 'nonsense'].forEach((v) => {
assert.equal(lotusShareBase(LOCAL, true, v), LOCAL, String(v));
assert.deepEqual(lotusLinkBases(LOCAL, true, v), [LOCAL]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { useClientConfig } from './useClientConfig';
import { isTauri } from './useTauri';
import { getOriginBaseUrl } from '../pages/pathUtils';
import { resolveWebAppUrl } from '../utils/callInBrowser';
/**
* [Gitea #248] Base URL for Lotus links meant for other people.
*
* On the web that's this page's origin. In the desktop app the page is served
* from a local address (and hash-routed), which nobody else can open, so use
* the public web app from config `webAppUrl` (web path routing) instead.
* Without a valid `webAppUrl` it falls back to the origin, as before.
*/
export const lotusShareBase = (originBase: string, desktop: boolean, webAppUrl: unknown): string =>
(desktop && resolveWebAppUrl(webAppUrl)) || originBase;
/**
* Every base a Lotus link to this deployment may start with: the origin, plus
* the public web app in the desktop app, so a shared https link opens in-app.
*/
export const lotusLinkBases = (
originBase: string,
desktop: boolean,
webAppUrl: unknown,
): string[] => {
const shared = lotusShareBase(originBase, desktop, webAppUrl);
return shared === originBase ? [originBase] : [originBase, shared];
};
export const useLotusShareBase = (): string => {
const { hashRouter, webAppUrl } = useClientConfig();
return useMemo(
() => lotusShareBase(getOriginBaseUrl(hashRouter), isTauri(), webAppUrl),
[hashRouter, webAppUrl],
);
};
+5 -3
View File
@@ -77,6 +77,7 @@ import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../feat
import { useRoomsListener } from '../../hooks/useRoomsListener'; import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications'; import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { lotusLinkBases } from '../../hooks/useLotusLinkBase';
import { import {
getThreadNotificationMode, getThreadNotificationMode,
shouldNotifyThreadReply, shouldNotifyThreadReply,
@@ -118,11 +119,12 @@ function SystemEmojiFeature() {
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag. // [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function LotusPermalinkFeature() { function LotusPermalinkFeature() {
const { hashRouter } = useClientConfig(); const { hashRouter, webAppUrl } = useClientConfig();
useEffect(() => { useEffect(() => {
setLotusPermalinkBase(getOriginBaseUrl(hashRouter)); // [Gitea #248] In the desktop app, links to the public web app count too.
setLotusPermalinkBase(lotusLinkBases(getOriginBaseUrl(hashRouter), isTauriApp(), webAppUrl));
return () => setLotusPermalinkBase(undefined); return () => setLotusPermalinkBase(undefined);
}, [hashRouter]); }, [hashRouter, webAppUrl]);
return null; return null;
} }
+4 -11
View File
@@ -49,13 +49,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall'; import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall';
import { LiveDot } from './SpaceTabs.css'; import { LiveDot } from './SpaceTabs.css';
import { import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { import {
SidebarAvatar, SidebarAvatar,
SidebarItem, SidebarItem,
@@ -102,6 +96,7 @@ import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { InviteUserPrompt } from '../../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = { type SpaceMenuProps = {
@@ -112,7 +107,7 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>( const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => { ({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { hashRouter } = useClientConfig(); const lotusBase = useLotusShareBase();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
@@ -152,9 +147,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const handleCopyLotusLink = () => { const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard( copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
requestClose(); requestClose();
}; };
+4 -11
View File
@@ -39,13 +39,7 @@ import {
NavItemContent, NavItemContent,
NavLink, NavLink,
} from '../../../components/nav'; } from '../../../components/nav';
import { import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
getOriginBaseUrl,
getSpaceLobbyPath,
getSpaceRoomPath,
getSpaceSearchPath,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { import {
@@ -92,6 +86,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css'; import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { useCallEmbed } from '../../../hooks/useCallEmbed'; import { useCallEmbed } from '../../../hooks/useCallEmbed';
type SpaceMenuProps = { type SpaceMenuProps = {
@@ -100,7 +95,7 @@ type SpaceMenuProps = {
}; };
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => { const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { hashRouter } = useClientConfig(); const lotusBase = useLotusShareBase();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@@ -137,9 +132,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const handleCopyLotusLink = () => { const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard( copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
requestClose(); requestClose();
}; };
+14 -6
View File
@@ -122,11 +122,15 @@ const cleanHref = (href: string): string =>
stripTrackingOnRender ? stripTrackingParams(href) : href; stripTrackingOnRender ? stripTrackingParams(href) : href;
// [Gitea #130] Links to THIS deployment's room routes render and click like // [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 // matrix.to links. The bases are set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks. // (ClientNonUIFeatures) because this module has no access to hooks. [Gitea
let lotusPermalinkBase: string | undefined; // #248] Several: the desktop app knows both its local origin and the public web
export const setLotusPermalinkBase = (baseUrl: string | undefined): void => { // app (`webAppUrl`), and people share the public one.
lotusPermalinkBase = baseUrl; let lotusPermalinkBases: string[] = [];
export const setLotusPermalinkBase = (baseUrls: string | string[] | undefined): void => {
lotusPermalinkBases = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).filter(
(b): b is string => !!b,
);
}; };
/** /**
* The matrix.to form of `href` when it is a matrix.to link or a Lotus * The matrix.to form of `href` when it is a matrix.to link or a Lotus
@@ -134,7 +138,11 @@ export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
*/ */
export const toMatrixToHref = (href: string): string | undefined => { export const toMatrixToHref = (href: string): string | undefined => {
if (testMatrixTo(href)) return href; if (testMatrixTo(href)) return href;
return lotusPermalinkBase ? lotusPermalinkToMatrixTo(lotusPermalinkBase, href) : undefined; for (const base of lotusPermalinkBases) {
const matrixTo = lotusPermalinkToMatrixTo(base, href);
if (matrixTo) return matrixTo;
}
return undefined;
}; };
export const LINKIFY_OPTS: LinkifyOpts = { export const LINKIFY_OPTS: LinkifyOpts = {