CI / Build & Quality Checks (pull_request) Successful in 3m4s
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 13m29s
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.
useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.
Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".
Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.
Refs #336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
258 lines
8.3 KiB
TypeScript
258 lines
8.3 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
Icon,
|
|
Icons,
|
|
Input,
|
|
MenuItem,
|
|
Scroll,
|
|
Spinner,
|
|
Text,
|
|
config,
|
|
} from 'folds';
|
|
import { Room } from 'matrix-js-sdk';
|
|
import { useAtomValue, useStore } from 'jotai';
|
|
import { useSetting } from '../../../state/hooks/settings';
|
|
import { settingsAtom } from '../../../state/settings';
|
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
|
import { mDirectAtom } from '../../../state/mDirectList';
|
|
import {
|
|
roomIdToMsgDraftAtomFamily,
|
|
roomIdToUploadItemsAtomFamily,
|
|
} from '../../../state/room/roomInputDrafts';
|
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
|
import { filesToUploadItems } from '../../../utils/uploadItems';
|
|
import {
|
|
clearSharedPayload,
|
|
readSharedPayload,
|
|
shareDraftText,
|
|
SharePayload,
|
|
} from '../../../../swShare';
|
|
import { BlockType } from '../../../components/editor';
|
|
import { bytesToSize } from '../../../utils/common';
|
|
import { getHomePath } from '../../pathUtils';
|
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
|
|
|
type Shared = { payload: SharePayload; files: File[] };
|
|
|
|
/**
|
|
* [Gitea #155] Landing page for the PWA share target: what was shared, pick
|
|
* a room, and the files land on that room's composer upload board (text/url
|
|
* as the draft) — the user still presses Send.
|
|
*/
|
|
export function Share() {
|
|
const localNames = useLocalRoomNames();
|
|
const mx = useMatrixClient();
|
|
const navigate = useNavigate();
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const directs = useAtomValue(mDirectAtom);
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
|
|
const [query, setQuery] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
|
|
|
useEffect(() => {
|
|
readSharedPayload()
|
|
.then((s) => setShared(s ?? null))
|
|
.catch(() => setShared(null));
|
|
}, []);
|
|
|
|
const rooms = useMemo(() => {
|
|
const all = mx
|
|
.getRooms()
|
|
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
|
const q = query.trim().toLowerCase();
|
|
return q
|
|
? all.filter((r) =>
|
|
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
|
|
)
|
|
: all;
|
|
}, [mx, query, localNames]);
|
|
|
|
// Written straight into the room's draft atoms so its composer shows the
|
|
// upload board + text as soon as it mounts.
|
|
const store = useStore();
|
|
const pick = useCallback(
|
|
async (room: Room) => {
|
|
if (!shared || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
if (shared.files.length) {
|
|
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
|
|
type: 'PUT',
|
|
item: await filesToUploadItems(room, shared.files, stripImageMetadata),
|
|
});
|
|
}
|
|
const text = shareDraftText(shared.payload);
|
|
if (text) {
|
|
store.set(roomIdToMsgDraftAtomFamily(room.roomId), [
|
|
{ type: BlockType.Paragraph, children: [{ text }] },
|
|
]);
|
|
}
|
|
await clearSharedPayload();
|
|
navigateRoom(room.roomId);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[shared, busy, navigateRoom, store, stripImageMetadata],
|
|
);
|
|
|
|
return (
|
|
<Page>
|
|
<PageHeader>
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
<Icon size="400" src={Icons.Send} />
|
|
<Text size="H3" truncate>
|
|
Share to Lotus Chat
|
|
</Text>
|
|
</Box>
|
|
</PageHeader>
|
|
<Box grow="Yes" direction="Column">
|
|
<Scroll hideTrack visibility="Hover">
|
|
<PageContent>
|
|
{shared === undefined && (
|
|
<Box justifyContent="Center" style={{ padding: config.space.S500 }}>
|
|
<Spinner size="400" />
|
|
</Box>
|
|
)}
|
|
{shared === null && (
|
|
<Box direction="Column" gap="300" alignItems="Start">
|
|
<Text size="T300">
|
|
Nothing was shared, or it has already been placed in a room.
|
|
</Text>
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
onClick={() => navigate(getHomePath())}
|
|
>
|
|
<Text size="B300">Go home</Text>
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
{shared && (
|
|
<Box direction="Column" gap="400">
|
|
<Box direction="Column" gap="200">
|
|
<Text size="L400">Shared</Text>
|
|
{shared.files.map((f) => (
|
|
<Box key={f.name} alignItems="Center" gap="200">
|
|
<Icon
|
|
size="100"
|
|
src={f.type.startsWith('image/') ? Icons.Photo : Icons.File}
|
|
/>
|
|
<Text size="T300" truncate>
|
|
{f.name}
|
|
</Text>
|
|
<Text size="T200" priority="300">
|
|
{bytesToSize(f.size)}
|
|
</Text>
|
|
</Box>
|
|
))}
|
|
{shareDraftText(shared.payload) && (
|
|
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
|
{shareDraftText(shared.payload)}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Box direction="Column" gap="200">
|
|
<Text size="L400">Send to</Text>
|
|
<Input
|
|
size="400"
|
|
variant="Background"
|
|
radii="400"
|
|
placeholder="Search rooms"
|
|
value={query}
|
|
onChange={(evt) => setQuery(evt.currentTarget.value)}
|
|
autoFocus
|
|
/>
|
|
<Box direction="Column" gap="100">
|
|
{rooms.slice(0, 60).map((room) => (
|
|
<ShareRoomRow
|
|
key={room.roomId}
|
|
room={room}
|
|
dm={directs.has(room.roomId)}
|
|
useAuthentication={useAuthentication}
|
|
disabled={busy}
|
|
onPick={pick}
|
|
/>
|
|
))}
|
|
{rooms.length === 0 && (
|
|
<Text size="T300" priority="300">
|
|
No rooms match.
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</PageContent>
|
|
</Scroll>
|
|
</Box>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function ShareRoomRow({
|
|
room,
|
|
dm,
|
|
useAuthentication,
|
|
disabled,
|
|
onPick,
|
|
}: {
|
|
room: Room;
|
|
dm: boolean;
|
|
useAuthentication: boolean;
|
|
disabled: boolean;
|
|
onPick: (room: Room) => void;
|
|
}) {
|
|
const localNames = useLocalRoomNames();
|
|
const mx = useMatrixClient();
|
|
const avatarMxc = room.getMxcAvatarUrl();
|
|
const avatarUrl = avatarMxc
|
|
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
|
|
: undefined;
|
|
return (
|
|
<MenuItem
|
|
size="300"
|
|
radii="300"
|
|
variant="Background"
|
|
disabled={disabled}
|
|
onClick={() => onPick(room)}
|
|
before={
|
|
<Avatar size="200" radii="300">
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={avatarUrl}
|
|
alt={room.name}
|
|
renderFallback={() => (
|
|
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
}
|
|
>
|
|
<Box direction="Column">
|
|
<Text size="T300" truncate>
|
|
{localNames[room.roomId] ?? room.name}
|
|
</Text>
|
|
{dm && (
|
|
<Text size="T200" priority="300" truncate>
|
|
Direct Message
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</MenuItem>
|
|
);
|
|
}
|