Files
cinny/src/app/features/common-settings/general/RoomShareInvite.tsx
T

148 lines
5.4 KiB
TypeScript

import React, { useCallback, useRef, useState } from 'react';
import { Box, Button, color, config, Icon, Icons, Text } from 'folds';
import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { CutoutCard } from '../../../components/cutout-card';
import { useRoom } from '../../../hooks/useRoom';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useSaveFile } from '../../../hooks/useSaveFile';
// Filesystem-safe basename from an arbitrary room name. Denylist only the
// path-hostile / control characters so Unicode names (CJK, Cyrillic, emoji, …)
// survive instead of being stripped to the 'room' fallback. Collapse whitespace,
// trim separator runs, and cap length to stay under filesystem name limits.
// eslint-disable-next-line no-control-regex
const UNSAFE_FILENAME = /[/\\:*?"<>|-]+/g;
function sanitizeFilename(name: string): string {
const cleaned = name
.replace(UNSAFE_FILENAME, '')
.replace(/\s+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.slice(0, 80)
.replace(/[-.]+$/g, '');
return cleaned || 'room';
}
export function RoomShareInvite() {
const room = useRoom();
const saveFile = useSaveFile();
const [copied, setCopied] = useState(false);
const [qrError, setQrError] = useState(false);
// Offscreen high-resolution canvas used only to export the QR as a PNG; the
// visible code stays an SVG so it renders crisply at any size/theme.
const qrCanvasRef = useRef<HTMLCanvasElement>(null);
// Use the shared via-server resolver (creator's/highest-power server plus the
// top-populated member servers) instead of only the local homeserver, so the
// link keeps resolving even if the sharer's own server later goes offline.
const inviteUrl = getMatrixToRoom(room.roomId, getViaServers(room));
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(inviteUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [inviteUrl]);
const handleDownloadQr = useCallback(() => {
const canvas = qrCanvasRef.current;
if (!canvas) return;
setQrError(false);
canvas.toBlob((blob) => {
if (blob) {
saveFile(blob, `${sanitizeFilename(room.name)}-invite-qr.png`);
} else {
// PNG encoding failed — surface it so the user isn't left re-clicking a
// button that silently did nothing.
setQrError(true);
setTimeout(() => setQrError(false), 4000);
}
}, 'image/png');
}, [saveFile, room.name]);
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Share Room"
description="Share this invite link so others can join the room."
/>
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="300">
<Box gap="200" alignItems="Center">
<Box grow="Yes">
<Text
size="T200"
style={{
wordBreak: 'break-all',
userSelect: 'all',
}}
>
{inviteUrl}
</Text>
</Box>
<Box shrink="No">
<Button
size="300"
variant={copied ? 'Success' : 'Secondary'}
fill={copied ? 'Solid' : 'Soft'}
radii="300"
onClick={handleCopy}
before={<Icon size="100" src={copied ? Icons.Check : Icons.Link} />}
>
<Text size="B300">{copied ? 'Copied!' : 'Copy Link'}</Text>
</Button>
</Box>
</Box>
<Box direction="Column" alignItems="Center" gap="300">
{/* Generated locally (qrcode.react) — no third-party service, works
offline + under strict CSP. White padded quiet-zone so the
default black-on-white code scans on any theme. */}
<Box
style={{
padding: config.space.S200,
background: '#ffffff',
borderRadius: config.radii.R300,
lineHeight: 0,
}}
>
<QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
</Box>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleDownloadQr}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Download QR</Text>
</Button>
{qrError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Could not generate the QR image. Try again.
</Text>
)}
</Box>
</Box>
</CutoutCard>
{/* Offscreen, high-res (spec 4-module quiet zone) source for PNG export. */}
<QRCodeCanvas
ref={qrCanvasRef}
value={inviteUrl}
size={1024}
level="M"
marginSize={4}
style={{ display: 'none' }}
/>
</SequenceCard>
);
}