Files
cinny/src/app/pages/client/share/Share.tsx
T
Lotus CIandClaude Opus 5.5 a4a163df3d
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
fix(rooms): "Rename for me" names in Ctrl+K, forward/share pickers and notifications (#336)
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
2026-10-12 15:40:56 -04:00

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>
);
}