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
Lotus CIandClaude Opus 5.5 1e217592fc fix(polls): readable poll card: counts, visible bars/indicators, no chip overflow (#246)
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 7m55s
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.

- Every row shows "N votes · P%". Multiple choice looked broken as
  "100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
  success for the winner, neutral otherwise. The old full-row fill was the
  same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
  from the theme's text colour. Primary.ContainerLine was nearly invisible,
  especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
  is now "Poll · Single choice | Pick up to N | Results hidden until the end |
  Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
  that never wrap. Previously the chip label broke onto two lines inside a
  one-line chip and spilled out of it. Reproduced before/after in Lotus
  Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 23:18:25 -04:00
jared ffe1d5a81e Merge pull request #245: desktop without WebRTC offers "Open in browser"
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 9m54s
2026-09-27 17:10:50 -04:00
Lotus CIandClaude Opus 5.5 fff583fca5 feat(call): desktop without WebRTC offers "Open in browser" instead of a dead end
CI / Build & Quality Checks (pull_request) Successful in 2m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m22s
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.

In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
  web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.

The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 16:54:29 -04:00
jared e2b23397bd Merge pull request #244: screenshare stays on the call bar in Firefox/Safari (#43)
CI / Build & Quality Checks (push) Successful in 3m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 11m34s
2026-09-27 13:54:11 -04:00
16 changed files with 455 additions and 174 deletions
+2 -7
View File
@@ -82,6 +82,7 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
@@ -231,13 +232,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
</Text>
)}
{!webRTCSupported() && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
<CallsUnavailableMessage roomId={room.roomId} actionLabel="Answer in browser" />
)}
<Box direction="Column" gap="300">
{willAnswerWithCamera && (
+170 -114
View File
@@ -261,52 +261,69 @@ export function PollContent({
handleVote(answers[idx].id);
};
const headerLabel = isEnded
? 'Poll closed · Final results'
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
// [Gitea #246] Type line under the "Poll" label.
const typeLabel = (() => {
if (isEnded) return 'Final results';
if (isUndisclosed) return 'Results hidden until the end';
return isMultiple ? `Pick up to ${maxSelections}` : 'Single choice';
})();
// Multiple choice counts people, not answers ("1 voter" who picked two).
const unit = isMultiple ? 'voter' : 'vote';
const footerNote = (() => {
const totalPart = total > 0 ? `${total} ${unit}${total === 1 ? '' : 's'}` : `No ${unit}s yet`;
if (isUndisclosed && !isEnded) {
return canVote
? 'Voting open · Results hidden until the poll ends'
: 'Results hidden until the poll ends';
? `${myVotes.size > 0 ? 'You voted' : 'Voting open'} · results shown when the poll ends`
: 'Results shown when the poll ends';
}
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
if (isEnded) return `${votesPart} · Poll closed`;
if (!canVote) return votesPart;
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
if (isEnded) return `${totalPart} · Poll closed`;
if (!canVote) return totalPart;
return `${totalPart} · ${myVotes.size > 0 ? 'Select another answer to change your vote' : 'Select an answer to vote'}`;
})();
// Neutral tones derived from the theme's text colour, so they have contrast
// in every theme (the Container/ContainerLine tokens were near-invisible).
const muted = (pct: number) =>
`color-mix(in srgb, ${color.SurfaceVariant.OnContainer} ${pct}%, transparent)`;
return (
<Box
data-poll-content
direction="Column"
gap="200"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
gap="300"
style={{
// Same width for every poll (the message body shrink-wraps its content).
width: toRem(460),
maxWidth: '100%',
margin: `${config.space.S100} 0`,
padding: config.space.S300,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${muted(18)}`,
background: muted(4),
boxSizing: 'border-box',
}}
>
<Text
as="div"
size="T200"
priority="300"
data-poll-content-label
style={{
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: config.space.S100,
}}
>
<span aria-hidden>◉ </span>
{headerLabel}
</Text>
<Text size="T400" style={{ fontWeight: 600 }}>
{question}
</Text>
<Box direction="Column" gap="100">
<Box alignItems="Center" gap="200" wrap="Wrap" data-poll-content-label>
<Icon size="100" src={Icons.OrderList} style={{ opacity: 0.8 }} />
<Text as="span" size="L400" style={{ fontWeight: 600 }}>
Poll
</Text>
<Text as="span" size="T200" priority="300">
·
</Text>
<Text as="span" size="T200" priority="300">
{typeLabel}
</Text>
</Box>
<Text size="T400" style={{ fontWeight: 600, overflowWrap: 'anywhere' }}>
{question}
</Text>
</Box>
<Box
direction="Column"
gap="100"
style={{ marginTop: '2px' }}
gap="200"
role={isMultiple ? 'group' : 'radiogroup'}
aria-label={question}
onKeyDown={handleRadioKeyDown}
@@ -316,20 +333,32 @@ export function PollContent({
const text = answer.text;
const selected = myVotes.has(id);
const voteCount = counts.get(id) ?? 0;
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
const showBar = showResults && total > 0;
const pct = showBar ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id);
const resultText = `${voteCount} ${voteCount === 1 ? 'vote' : 'votes'} · ${pct}%`;
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
let barColor = muted(40);
if (isWinner) barColor = color.Success.Main;
else if (selected) barColor = color.Primary.Main;
let rowBorder = muted(22);
if (isWinner) rowBorder = color.Success.Main;
else if (selected) rowBorder = color.Primary.Main;
const labelParts = [text];
if (showBar) labelParts.push(resultText);
if (isWinner) labelParts.push('winning answer');
return (
<React.Fragment key={id}>
<button
type="button"
data-poll-answer
data-selected={selected}
data-winner={isWinner || undefined}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined}
aria-label={labelParts.join(', ')}
aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}`
@@ -340,60 +369,36 @@ export function PollContent({
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
background: selected ? muted(8) : 'transparent',
border: `${config.borderWidth.B300} solid ${rowBorder}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
font: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
gap: config.space.S200,
width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{showResults && total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
<span
style={{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
position: 'relative',
width: '100%',
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: toRem(14),
height: toRem(14),
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.Primary.ContainerLine
}`,
boxSizing: 'border-box',
width: toRem(18),
height: toRem(18),
border: `2px solid ${selected ? color.Primary.Main : muted(55)}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
@@ -405,29 +410,72 @@ export function PollContent({
>
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
<Text
as="span"
size="T300"
style={{ flexGrow: 1, minWidth: 0, overflowWrap: 'anywhere' }}
>
{text}
</Text>
{isWinner && (
<Icon
size="50"
src={Icons.Check}
style={{ flexShrink: 0, color: color.Success.Main }}
/>
<Text
as="span"
size="T200"
style={{
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
color: color.Success.Main,
fontWeight: 600,
}}
>
<Icon size="50" src={Icons.Star} filled />
Winner
</Text>
)}
{showResults && total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
{showBar && (
<Text
as="span"
size="T200"
priority="300"
style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}
>
{resultText}
</Text>
)}
</span>
{showBar && (
<span
aria-hidden
style={{
display: 'block',
width: '100%',
height: toRem(6),
borderRadius: config.radii.Pill,
background: muted(12),
overflow: 'hidden',
}}
>
<span
style={{
display: 'block',
height: '100%',
width: `${pct}%`,
borderRadius: config.radii.Pill,
background: barColor,
transition: 'width 0.3s ease',
}}
/>
</span>
)}
</button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text
id={`poll-voters-${eventId}-${id}`}
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
style={{ padding: `0 ${config.space.S300}`, marginTop: `-${config.space.S100}` }}
>
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text>
@@ -436,48 +484,56 @@ export function PollContent({
);
})}
</Box>
{canShowVoters && (
<Box>
<Chip
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
</Chip>
</Box>
)}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
<i>{footerNote}</i>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200" wrap="Wrap">
<Text size="T200" priority="300" style={{ minWidth: 0, flex: '1 1 12rem' }}>
{footerNote}
</Text>
{canEnd &&
(confirmEnd ? (
<Box gap="100" shrink="No" alignItems="Center">
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200">Cancel</Text>
</Chip>
</Box>
) : (
<Box gap="100" shrink="No" alignItems="Center" wrap="Wrap">
{canShowVoters && (
<Chip
variant="SurfaceVariant"
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200">End poll</Text>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{showVoters ? 'Hide voters' : 'Who voted'}
</Text>
</Chip>
))}
)}
{canEnd &&
(confirmEnd ? (
<>
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{ending ? 'Ending…' : 'End poll'}
</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
Cancel
</Text>
</Chip>
</>
) : (
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
End poll
</Text>
</Chip>
))}
</Box>
</Box>
</Box>
);
+5 -9
View File
@@ -23,6 +23,7 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { CallsUnavailableMessage } from './CallsUnavailable';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
@@ -33,25 +34,19 @@ function LivekitServerMissingMessage() {
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
function JoinMessage({
roomId,
hasParticipant,
livekitSupported,
rtcSupported,
}: {
roomId: string;
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <WebRTCMissingError />;
return <CallsUnavailableMessage roomId={roomId} />;
}
if (livekitSupported === false) {
@@ -144,6 +139,7 @@ function CallPrescreen() {
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
roomId={room.roomId}
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
@@ -0,0 +1,76 @@
import React, { useMemo } from 'react';
import { Box, Button, Icon, Icons, Text, color } from 'folds';
import { useClientConfig } from '../../hooks/useClientConfig';
import { isTauri } from '../../hooks/useTauri';
import { webRTCSupported } from '../../utils/rtc';
import { getCallInBrowserUrl, resolveWebAppUrl } from '../../utils/callInBrowser';
/**
* [cinny-desktop: Linux calls] The desktop app on Linux runs on WebKitGTK,
* which ships without WebRTC (disabled in 2.54; a libwebrtc backend is due
* around 2.56). Where calls can't work here, send the user to the room in the
* web app instead of a dead end. Only in the desktop app, and only when
* config.json sets `webAppUrl`.
*/
export const useCallInBrowserUrl = (roomId: string): string | undefined => {
const { webAppUrl } = useClientConfig();
return useMemo(
() =>
isTauri() && !webRTCSupported()
? getCallInBrowserUrl(resolveWebAppUrl(webAppUrl), roomId)
: undefined,
[webAppUrl, roomId],
);
};
/** Opens through the desktop's new-window handler → the system browser. */
export const openCallInBrowser = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer');
};
const desktopUnavailableText = (): string =>
typeof navigator !== 'undefined' && /Linux/i.test(navigator.userAgent)
? 'Calls aren’t available in the desktop app on Linux yet: its web engine has no WebRTC.'
: 'Calls aren’t available in this desktop app: its web engine has no WebRTC.';
type CallsUnavailableMessageProps = {
roomId: string;
/** Button label, e.g. "Answer in browser" for an incoming call. */
actionLabel?: string;
};
export function CallsUnavailableMessage({
roomId,
actionLabel = 'Open in browser',
}: CallsUnavailableMessageProps) {
const url = useCallInBrowserUrl(roomId);
if (!isTauri()) {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
return (
<Box direction="Column" alignItems="Center" gap="200" style={{ margin: 'auto' }}>
<Text style={{ color: color.Warning.Main }} size="L400" align="Center">
{desktopUnavailableText()}
{url ? ' You can join from the web app in your browser.' : ''}
</Text>
{url && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="100" src={Icons.External} />}
onClick={() => openCallInBrowser(url)}
>
<Text size="B300">{actionLabel}</Text>
</Button>
)}
</Box>
);
}
+39 -2
View File
@@ -80,6 +80,7 @@ import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
@@ -465,6 +466,36 @@ function CallRulesChip({ room }: { room: Room }) {
);
}
/**
* [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't
* make calls (desktop on WebKitGTK): opens the room in the web app instead.
*/
function CallInBrowserButton({ url }: { url: string }) {
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Call in your browser (this desktop app can’t make calls here)</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
aria-label="Start call in browser"
onClick={() => openCallInBrowser(url)}
>
<Icon size="400" src={Icons.VideoCamera} />
</IconButton>
)}
</TooltipProvider>
);
}
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
@@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const callInBrowserUrl = useCallInBrowserUrl(room.roomId);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
(rtcSupported || callInBrowserUrl) &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
(rtcSupported ? (
<CallButton />
) : (
callInBrowserUrl && <CallInBrowserButton url={callInBrowserUrl} />
))}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+3 -11
View File
@@ -83,8 +83,6 @@ 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';
@@ -98,6 +96,7 @@ import { useLongPress } from '../../../hooks/useLongPress';
import { ActionSheet } from '../../../components/action-sheet';
import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
// Delivery status indicator for own messages
@@ -460,18 +459,11 @@ export const MessageCopyLotusLinkItem = as<
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { hashRouter } = useClientConfig();
const lotusBase = useLotusShareBase();
const handleCopy = () => {
const eventId = mEvent.getId();
if (!eventId) return;
copyToClipboard(
getLotusRoomPermalink(
getOriginBaseUrl(hashRouter),
room.roomId,
eventId,
getViaServers(room),
),
);
copyToClipboard(getLotusRoomPermalink(lotusBase, room.roomId, eventId, getViaServers(room)));
onClose?.();
};
+6
View File
@@ -25,6 +25,12 @@ export type ClientConfig = {
* Unset: the bundled copy on this origin. Ignored in the desktop app.
*/
elementCallUrl?: string;
/**
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
* The desktop app sets it so it can hand calls it can't make to the browser.
*/
webAppUrl?: string;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
+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 { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { lotusLinkBases } from '../../hooks/useLotusLinkBase';
import {
getThreadNotificationMode,
shouldNotifyThreadReply,
@@ -118,11 +119,12 @@ function SystemEmojiFeature() {
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function LotusPermalinkFeature() {
const { hashRouter } = useClientConfig();
const { hashRouter, webAppUrl } = useClientConfig();
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);
}, [hashRouter]);
}, [hashRouter, webAppUrl]);
return null;
}
+4 -11
View File
@@ -49,13 +49,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall';
import { LiveDot } from './SpaceTabs.css';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import {
SidebarAvatar,
SidebarItem,
@@ -102,6 +96,7 @@ import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = {
@@ -112,7 +107,7 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const lotusBase = useLotusShareBase();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
@@ -152,9 +147,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
requestClose();
};
+4 -11
View File
@@ -39,13 +39,7 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpaceRoomPath,
getSpaceSearchPath,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
@@ -92,6 +86,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { useCallEmbed } from '../../../hooks/useCallEmbed';
type SpaceMenuProps = {
@@ -100,7 +95,7 @@ type SpaceMenuProps = {
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const lotusBase = useLotusShareBase();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
@@ -137,9 +132,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
requestClose();
};
+14 -6
View File
@@ -122,11 +122,15 @@ 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;
// matrix.to links. The bases are set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks. [Gitea
// #248] Several: the desktop app knows both its local origin and the public web
// app (`webAppUrl`), and people share the public one.
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
@@ -134,7 +138,11 @@ export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
*/
export const toMatrixToHref = (href: string): string | undefined => {
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 = {
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getCallInBrowserUrl, resolveWebAppUrl } from './callInBrowser';
test('resolveWebAppUrl keeps an https origin (+ path) without a trailing slash', () => {
assert.equal(resolveWebAppUrl('https://chat.example.org'), 'https://chat.example.org');
assert.equal(resolveWebAppUrl('https://chat.example.org/'), 'https://chat.example.org');
assert.equal(resolveWebAppUrl('https://example.org/chat/'), 'https://example.org/chat');
assert.equal(resolveWebAppUrl('https://chat.example.org/?x=1#y'), 'https://chat.example.org');
});
test('resolveWebAppUrl rejects anything but absolute https', () => {
[
undefined,
null,
1,
'',
' ',
'/home',
'http://chat.example.org',
'not a url',
['javascript', 'alert(1)'].join(':'),
'data:text/html,x',
].forEach((v) => assert.equal(resolveWebAppUrl(v), undefined, String(v)));
});
test('getCallInBrowserUrl points at the room in the web app (no auto-join)', () => {
const url = getCallInBrowserUrl('https://chat.example.org', '!room:example.org');
assert.equal(url, 'https://chat.example.org/home/!room%3Aexample.org');
assert.ok(!/join|call=/i.test(url ?? ''));
assert.equal(getCallInBrowserUrl(undefined, '!room:example.org'), undefined);
});
+27
View File
@@ -0,0 +1,27 @@
import { getLotusRoomPermalink } from '../plugins/lotus-permalink';
/**
* The public web app's address from config.json `webAppUrl` (the desktop app
* sets it; its own origin is a local server). Absolute https only; anything
* else is ignored.
*/
export const resolveWebAppUrl = (value: unknown): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
try {
const url = new URL(value);
if (url.protocol !== 'https:') return undefined;
return url.origin + url.pathname.replace(/\/+$/, '');
} catch {
return undefined;
}
};
/**
* Where to send the user to take a call this app can't make: the room in the
* web app (they press Join there themselves — never an auto-join link, which
* a crafted URL could abuse to open someone's mic).
*/
export const getCallInBrowserUrl = (
webAppUrl: string | undefined,
roomId: string,
): string | undefined => (webAppUrl ? getLotusRoomPermalink(webAppUrl, roomId) : undefined);
+8
View File
@@ -898,6 +898,14 @@ globalStyle(
boxShadow: '0 0 10px rgba(255,107,0,0.10)',
},
);
// [Gitea #246] The poll's winning answer (after it ends).
globalStyle(
`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer][data-winner="true"]`,
{
border: '1px solid rgba(0,255,136,0.55) !important',
boxShadow: '0 0 10px rgba(0,255,136,0.10)',
},
);
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-content-label]`, {
color: 'rgba(0,212,255,0.60) !important',
opacity: '1 !important',