Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7393a1471c | |||
| 877e1eaca7 |
@@ -47,3 +47,94 @@ export const UrlPreviewDescription = style([
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
// YouTube card styles
|
||||
export const YouTubeThumbnailWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
width: toRem(160),
|
||||
height: toRem(90),
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.Surface.Container,
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(0.85)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubeThumbnailImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubePlayOverlay = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubePlayButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(44),
|
||||
height: toRem(44),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
},
|
||||
]);
|
||||
|
||||
// GitHub card styles
|
||||
export const GitHubIconWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flexShrink: 0,
|
||||
width: toRem(72),
|
||||
minHeight: toRem(72),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.8,
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
]);
|
||||
|
||||
// Generic card favicon styles
|
||||
export const GenericFaviconWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
]);
|
||||
|
||||
export const GenericFaviconImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(16),
|
||||
height: toRem(16),
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import * as css from './UrlPreviewCard.css';
|
||||
import * as previewCss from './UrlPreview.css';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -18,6 +19,281 @@ import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — URL parsing & variant detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CardVariant = 'youtube' | 'github' | 'generic';
|
||||
|
||||
function getYouTubeVideoId(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const { hostname, pathname, searchParams } = parsed;
|
||||
|
||||
// youtu.be/<id>
|
||||
if (hostname === 'youtu.be') {
|
||||
const id = pathname.slice(1).split('/')[0];
|
||||
return id || null;
|
||||
}
|
||||
|
||||
// youtube.com/watch?v=<id>
|
||||
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
||||
if (pathname === '/watch') {
|
||||
return searchParams.get('v');
|
||||
}
|
||||
// youtube.com/shorts/<id>
|
||||
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||
if (shortsMatch) return shortsMatch[1];
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed URLs
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isGitHubRepo(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const { hostname, pathname } = parsed;
|
||||
if (hostname !== 'github.com' && hostname !== 'www.github.com') return false;
|
||||
// Exactly two path segments: /<owner>/<repo> (no deeper pages)
|
||||
const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
return parts.length === 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getCardVariant(url: string): CardVariant {
|
||||
if (getYouTubeVideoId(url) !== null) return 'youtube';
|
||||
if (isGitHubRepo(url)) return 'github';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub SVG icon (inline, no external dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
function GitHubIcon({ size = 24 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card variant renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const videoId = getYouTubeVideoId(url)!;
|
||||
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.YouTubeThumbnailWrapper}
|
||||
aria-label={`Play on YouTube: ${title}`}
|
||||
>
|
||||
<img
|
||||
className={previewCss.YouTubeThumbnailImg}
|
||||
src={thumbnailSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className={previewCss.YouTubePlayOverlay}>
|
||||
<div className={previewCss.YouTubePlayButton}>
|
||||
<Icon size="400" src={Icons.Play} />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
YouTube
|
||||
</Text>
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
<b>{title}</b>
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||
</Text>
|
||||
)}
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
|
||||
// GitHub og:title is usually "owner/repo: short description" — split on ': '
|
||||
const colonIdx = title.indexOf(': ');
|
||||
const repoName = colonIdx !== -1 ? title.slice(0, colonIdx) : title;
|
||||
const repoDesc = colonIdx !== -1 ? title.slice(colonIdx + 2) : description;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
className={previewCss.GitHubIconWrapper}
|
||||
>
|
||||
<GitHubIcon size={28} />
|
||||
</Box>
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
GitHub
|
||||
</Text>
|
||||
{repoName && (
|
||||
<Text truncate priority="400">
|
||||
<b>{repoName}</b>
|
||||
</Text>
|
||||
)}
|
||||
{repoDesc && (
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{repoDesc}</UrlPreviewDescription>
|
||||
</Text>
|
||||
)}
|
||||
{description && description !== repoDesc && (
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||
</Text>
|
||||
)}
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericCard({
|
||||
url,
|
||||
prev,
|
||||
onOpenViewer,
|
||||
viewer,
|
||||
onCloseViewer,
|
||||
thumbUrl,
|
||||
imgUrl,
|
||||
}: {
|
||||
url: string;
|
||||
prev: IPreviewUrlResponse;
|
||||
onOpenViewer: () => void;
|
||||
viewer: boolean;
|
||||
onCloseViewer: () => void;
|
||||
thumbUrl: string | null;
|
||||
imgUrl: string | null;
|
||||
}) {
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
|
||||
const domain = getDomain(url);
|
||||
const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{thumbUrl && (
|
||||
<UrlPreviewImg
|
||||
src={thumbUrl}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
||||
onClick={onOpenViewer}
|
||||
/>
|
||||
)}
|
||||
{imgUrl && (
|
||||
<ImageOverlay
|
||||
src={imgUrl}
|
||||
alt={prev['og:title']}
|
||||
viewer={viewer}
|
||||
requestClose={onCloseViewer}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{!thumbUrl && (
|
||||
<img
|
||||
className={previewCss.GenericFaviconImg}
|
||||
src={faviconSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
style={{ marginRight: '4px', verticalAlign: 'text-bottom' }}
|
||||
/>
|
||||
)}
|
||||
{siteName ? `${siteName} | ` : ''}
|
||||
{tryDecodeURIComponent(url)}
|
||||
</Text>
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
<b>{title}</b>
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||
</Text>
|
||||
)}
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main UrlPreviewCard component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
({ url, ts, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
@@ -33,7 +309,20 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||
const variant = getCardVariant(url);
|
||||
|
||||
if (variant === 'youtube') {
|
||||
return <YouTubeCard url={url} prev={prev} />;
|
||||
}
|
||||
|
||||
if (variant === 'github') {
|
||||
return <GitHubCard url={url} prev={prev} />;
|
||||
}
|
||||
|
||||
// Generic fallback — skip empty cards
|
||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||
|
||||
const thumbUrl = mxcUrlToHttp(
|
||||
mx,
|
||||
prev['og:image'] || '',
|
||||
@@ -43,66 +332,37 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
'scale',
|
||||
false,
|
||||
);
|
||||
|
||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||
|
||||
return (
|
||||
<>
|
||||
{thumbUrl && (
|
||||
<UrlPreviewImg
|
||||
src={thumbUrl}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
|
||||
onClick={() => setViewer(true)}
|
||||
/>
|
||||
)}
|
||||
{imgUrl && (
|
||||
<ImageOverlay
|
||||
src={imgUrl}
|
||||
alt={prev['og:title']}
|
||||
viewer={viewer}
|
||||
requestClose={() => {
|
||||
setViewer(false);
|
||||
}}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
|
||||
{tryDecodeURIComponent(url)}
|
||||
</Text>
|
||||
<Text truncate priority="400">
|
||||
<b>{prev['og:title']}</b>
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
|
||||
</Text>
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
<GenericCard
|
||||
url={url}
|
||||
prev={prev}
|
||||
onOpenViewer={() => setViewer(true)}
|
||||
viewer={viewer}
|
||||
onCloseViewer={() => setViewer(false)}
|
||||
thumbUrl={thumbUrl}
|
||||
imgUrl={imgUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||
if (previewStatus.status === AsyncStatus.Success) {
|
||||
const content = renderContent(previewStatus.data);
|
||||
if (content === null) return null;
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref}>
|
||||
{content}
|
||||
</UrlPreview>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref}>
|
||||
{previewStatus.status === AsyncStatus.Success ? (
|
||||
renderContent(previewStatus.data)
|
||||
) : (
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
</UrlPreview>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,6 +21,9 @@ import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { ImageViewer } from '../image-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useLocalTime } from '../../hooks/useLocalTime';
|
||||
|
||||
type UserHeroProps = {
|
||||
userId: string;
|
||||
@@ -96,9 +99,19 @@ type UserHeroNameProps = {
|
||||
displayName?: string;
|
||||
userId: string;
|
||||
status?: string;
|
||||
pronouns?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
export function UserHeroName({ displayName, userId, status }: UserHeroNameProps) {
|
||||
export function UserHeroName({
|
||||
displayName,
|
||||
userId,
|
||||
status,
|
||||
pronouns,
|
||||
timezone,
|
||||
}: UserHeroNameProps) {
|
||||
const username = getMxIdLocalPart(userId);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const localTimeInfo = useLocalTime(timezone, !hour24Clock);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" gap="0">
|
||||
@@ -111,11 +124,31 @@ export function UserHeroName({ displayName, userId, status }: UserHeroNameProps)
|
||||
{displayName ?? username ?? userId}
|
||||
</Text>
|
||||
</Box>
|
||||
{pronouns && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||
<Text
|
||||
size="T200"
|
||||
className={classNames(BreakWord, LineClamp2)}
|
||||
style={{ opacity: 0.6, fontStyle: 'italic' }}
|
||||
>
|
||||
{pronouns}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
|
||||
@{username}
|
||||
</Text>
|
||||
</Box>
|
||||
{localTimeInfo && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||
<Icon size="50" src={Icons.Clock} />
|
||||
<Text size="T200" className={classNames(BreakWord, LineClamp2)} style={{ opacity: 0.6 }}>
|
||||
{localTimeInfo.time}
|
||||
{localTimeInfo.abbr && <span style={{ opacity: 0.7 }}>{` ${localTimeInfo.abbr}`}</span>}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{status && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '2px', overflow: 'hidden' }}>
|
||||
<Text
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||
import { CreatorChip } from './CreatorChip';
|
||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||
|
||||
type VerifyDeviceButtonProps = {
|
||||
userId: string;
|
||||
@@ -243,6 +244,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||
|
||||
const presence = useUserPresence(userId);
|
||||
const extProfile = useExtendedProfile(userId);
|
||||
|
||||
const handleMessage = () => {
|
||||
closeUserRoomProfile();
|
||||
@@ -262,7 +264,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
||||
<Box direction="Column" gap="400">
|
||||
<Box gap="400" alignItems="Center">
|
||||
<UserHeroName displayName={displayName} userId={userId} status={presence?.status} />
|
||||
<UserHeroName
|
||||
displayName={displayName}
|
||||
userId={userId}
|
||||
status={presence?.status}
|
||||
pronouns={extProfile.pronouns}
|
||||
timezone={extProfile.timezone}
|
||||
/>
|
||||
{showEncryption && <MemberVerificationBadge userId={userId} />}
|
||||
{userId !== myUserId && (
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_ACL: ServerAclContent = {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: false,
|
||||
};
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a server name or wildcard pattern.
|
||||
* Allowed forms:
|
||||
* - plain hostname / IP: letters, digits, hyphens, dots
|
||||
* - wildcard prefix: *.example.com (asterisk only at the very start)
|
||||
* The Matrix spec allows `*` on its own (match-all wildcard).
|
||||
*/
|
||||
function isValidServerPattern(value: string): boolean {
|
||||
if (value === '*') return true;
|
||||
// Strip leading wildcard
|
||||
const rest = value.startsWith('*.') ? value.slice(2) : value;
|
||||
// Must not be empty after stripping wildcard
|
||||
if (!rest) return false;
|
||||
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports)
|
||||
return /^[A-Za-z0-9.:_-]+$/.test(rest);
|
||||
}
|
||||
|
||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||
|
||||
type ServerListProps = {
|
||||
label: string;
|
||||
entries: string[];
|
||||
canEdit: boolean;
|
||||
onAdd: (value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const handleAdd = () => {
|
||||
const raw = inputRef.current?.value ?? '';
|
||||
const value = raw.trim();
|
||||
if (!value) return;
|
||||
|
||||
if (!isValidServerPattern(value)) {
|
||||
setError('Invalid server pattern. Use a hostname or *.example.com');
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
onAdd(value);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween">
|
||||
<Text size="L400">{label}</Text>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Plus} size="100" />}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Text size="B300">Add</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{canEdit && (
|
||||
<Box direction="Column" gap="100">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="e.g. *.example.com or badserver.org"
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="0"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<Text size="T300" priority="300" style={{ padding: `${config.space.S100} 0` }}>
|
||||
(empty)
|
||||
</Text>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<Box
|
||||
key={`${entry}-${i}`}
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
style={{
|
||||
padding: `${config.space.S100} 0`,
|
||||
borderBottom:
|
||||
i < entries.length - 1 ? `1px solid ${color.Surface.ContainerLine}` : undefined,
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
{entry}
|
||||
</Text>
|
||||
{canEdit && (
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="Background"
|
||||
radii="300"
|
||||
aria-label={`Remove ${entry}`}
|
||||
onClick={() => onRemove(i)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type RoomServerACLProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
|
||||
// Power level checks
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const canEdit = permissions.stateEvent(StateEvent.RoomServerAcl, myUserId);
|
||||
|
||||
// Read current ACL from room state
|
||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
||||
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
||||
|
||||
// Local draft state — initialised from current ACL
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||
() => currentAcl.allow_ip_literals ?? false,
|
||||
);
|
||||
|
||||
// Track whether there are unsaved changes
|
||||
const isDirty =
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
||||
|
||||
// Save handler
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, {
|
||||
allow: allowList,
|
||||
deny: denyList,
|
||||
allow_ip_literals: allowIpLiterals,
|
||||
});
|
||||
}, [mx, room.roomId, allowList, denyList, allowIpLiterals]),
|
||||
);
|
||||
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
const saveError =
|
||||
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
|
||||
|
||||
// Required power level for this state event
|
||||
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
||||
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Shield} size="200" />
|
||||
<Text as="h2" size="H3" truncate>
|
||||
Server ACL
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="400"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={saving || !isDirty}
|
||||
onClick={() => save()}
|
||||
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
||||
>
|
||||
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
{/* Info banner */}
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Box gap="200" alignItems="Start">
|
||||
<Icon src={Icons.Warning} size="200" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
Server ACL controls which servers can participate in this room. Changes take
|
||||
effect immediately.
|
||||
</Text>
|
||||
{!canEdit && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
You need power level {requiredPL} to edit the ACL (your level: {myPL}).
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
|
||||
{/* Save error */}
|
||||
{saveError && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
{saveError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Allow IP literals toggle */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">IP Address Access</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Box alignItems="Center" gap="300">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow-ip-literals"
|
||||
checked={allowIpLiterals}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setAllowIpLiterals(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
cursor: canEdit ? 'pointer' : 'default',
|
||||
}}
|
||||
/>
|
||||
<Box direction="Column" gap="0">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label
|
||||
htmlFor="allow-ip-literals"
|
||||
style={{ cursor: canEdit ? 'pointer' : 'default' }}
|
||||
>
|
||||
<Text size="T300">Allow IP literal addresses</Text>
|
||||
</label>
|
||||
<Text size="T200" priority="300">
|
||||
When disabled, clients connecting via raw IP addresses (e.g. 1.2.3.4) cannot
|
||||
participate.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
{/* Allowed servers */}
|
||||
<ServerList
|
||||
label="Allowed Servers"
|
||||
entries={allowList}
|
||||
canEdit={canEdit}
|
||||
onAdd={(value) => setAllowList((prev) => [...prev, value])}
|
||||
onRemove={(index) => setAllowList((prev) => prev.filter((_, i) => i !== index))}
|
||||
/>
|
||||
|
||||
{/* Denied servers */}
|
||||
<ServerList
|
||||
label="Denied Servers"
|
||||
entries={denyList}
|
||||
canEdit={canEdit}
|
||||
onAdd={(value) => setDenyList((prev) => [...prev, value])}
|
||||
onRemove={(index) => setDenyList((prev) => prev.filter((_, i) => i !== index))}
|
||||
/>
|
||||
|
||||
{/* Note about defaults */}
|
||||
<Text size="T200" priority="300">
|
||||
Tip: The default ACL allows all servers (allow: ["*"], deny: []). Adding
|
||||
"*" to the allow list permits all servers not explicitly denied.
|
||||
</Text>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import { useRoom } from '../../hooks/useRoom';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { ExportRoomHistory } from './ExportRoomHistory';
|
||||
import { RoomActivityLog } from './RoomActivityLog';
|
||||
import { RoomServerACL } from './RoomServerACL';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
|
||||
type RoomSettingsMenuItem = {
|
||||
page: RoomSettingsPage;
|
||||
@@ -26,47 +30,56 @@ type RoomSettingsMenuItem = {
|
||||
icon: IconSrc;
|
||||
};
|
||||
|
||||
const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
page: RoomSettingsPage.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.MembersPage,
|
||||
name: 'Members',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.PermissionsPage,
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.ExportPage,
|
||||
name: 'Export',
|
||||
icon: Icons.Download,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.ActivityLogPage,
|
||||
name: 'Activity',
|
||||
icon: Icons.RecentClock,
|
||||
},
|
||||
],
|
||||
[],
|
||||
const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
||||
{
|
||||
page: RoomSettingsPage.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.MembersPage,
|
||||
name: 'Members',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.PermissionsPage,
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.ExportPage,
|
||||
name: 'Export',
|
||||
icon: Icons.Download,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.ActivityLogPage,
|
||||
name: 'Activity',
|
||||
icon: Icons.RecentClock,
|
||||
},
|
||||
];
|
||||
|
||||
const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
|
||||
page: RoomSettingsPage.ServerACLPage,
|
||||
name: 'Server ACL',
|
||||
icon: Icons.Shield,
|
||||
};
|
||||
|
||||
function useRoomSettingsMenuItems(canSeeServerACL: boolean): RoomSettingsMenuItem[] {
|
||||
return useMemo(
|
||||
() => (canSeeServerACL ? [...BASE_MENU_ITEMS, SERVER_ACL_MENU_ITEM] : BASE_MENU_ITEMS),
|
||||
[canSeeServerACL],
|
||||
);
|
||||
}
|
||||
|
||||
type RoomSettingsProps = {
|
||||
initialPage?: RoomSettingsPage;
|
||||
@@ -86,12 +99,24 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
// Power level check: show Server ACL menu item to anyone who can read the state
|
||||
// (i.e. has at least state_default power level, or a custom ACL event power).
|
||||
// We show it to all users at or above the required power level; read-only view
|
||||
// for those who cannot edit.
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
||||
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
||||
// Show the menu item if user meets the power level OR is a room creator.
|
||||
const canSeeServerACL = myPL >= requiredPL || creators.has(myUserId);
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [activePage, setActivePage] = useState<RoomSettingsPage | undefined>(() => {
|
||||
if (initialPage) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
|
||||
});
|
||||
const menuItems = useRoomSettingsMenuItems();
|
||||
const menuItems = useRoomSettingsMenuItems(canSeeServerACL);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -190,6 +215,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
{activePage === RoomSettingsPage.ActivityLogPage && (
|
||||
<RoomActivityLog requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.ServerACLPage && (
|
||||
<RoomServerACL requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
PopOut,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -566,6 +567,290 @@ function ProfileStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Sao_Paulo',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Africa/Cairo',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Singapore',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
|
||||
function ProfilePronouns() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
const [pronouns, setPronouns] = useState<string>('');
|
||||
const [savedPronouns, setSavedPronouns] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
mx.http
|
||||
.authedRequest<{ 'm.pronouns': string }>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
|
||||
)
|
||||
.then((res) => {
|
||||
const val = res['m.pronouns'] ?? '';
|
||||
setPronouns(val);
|
||||
setSavedPronouns(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setPronouns('');
|
||||
setSavedPronouns('');
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, savePronouns] = useAsyncCallback(
|
||||
useCallback(
|
||||
(value: string) =>
|
||||
mx.http
|
||||
.authedRequest(
|
||||
Method.Put,
|
||||
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
|
||||
undefined,
|
||||
{ 'm.pronouns': value },
|
||||
)
|
||||
.then(() => {
|
||||
setSavedPronouns(value);
|
||||
}),
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setPronouns(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPronouns(savedPronouns);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
savePronouns(pronouns.trim());
|
||||
};
|
||||
|
||||
const hasChanges = pronouns !== savedPronouns;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Pronouns
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Shown on your profile. Visible to other users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={saving}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
name="pronounsInput"
|
||||
aria-label="Pronouns"
|
||||
value={pronouns}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. they/them, she/her"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
maxLength={64}
|
||||
readOnly={saving}
|
||||
after={
|
||||
hasChanges &&
|
||||
!saving && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
aria-label="Reset pronouns"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save pronouns. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTimezone() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
const [timezone, setTimezone] = useState<string>('');
|
||||
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
mx.http
|
||||
.authedRequest<{ 'm.tz': string }>(Method.Get, `/profile/${encodeURIComponent(userId)}/m.tz`)
|
||||
.then((res) => {
|
||||
const val = res['m.tz'] ?? '';
|
||||
setTimezone(val);
|
||||
setSavedTimezone(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setTimezone('');
|
||||
setSavedTimezone('');
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, saveTimezone] = useAsyncCallback(
|
||||
useCallback(
|
||||
(value: string) =>
|
||||
mx.http
|
||||
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
||||
'm.tz': value,
|
||||
})
|
||||
.then(() => {
|
||||
setSavedTimezone(value);
|
||||
}),
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSelectChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setTimezone(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setTimezone(savedTimezone);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
saveTimezone(timezone);
|
||||
};
|
||||
|
||||
const hasChanges = timezone !== savedTimezone;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Timezone
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Your local timezone. Visible to other users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<select
|
||||
name="timezoneInput"
|
||||
aria-label="Timezone"
|
||||
value={timezone}
|
||||
onChange={handleSelectChange}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
colorScheme: 'dark',
|
||||
fontSize: '0.875rem',
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">— select timezone —</option>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<option
|
||||
key={tz}
|
||||
value={tz}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
{hasChanges && !saving && (
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
size="400"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
aria-label="Reset timezone"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
size="400"
|
||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save timezone. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
export function Profile() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
@@ -583,6 +868,8 @@ export function Profile() {
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
<ProfileStatus />
|
||||
<ProfilePronouns />
|
||||
<ProfileTimezone />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SystemNotification } from './SystemNotification';
|
||||
import { AllMessagesNotifications } from './AllMessages';
|
||||
import { SpecialMessagesNotifications } from './SpecialMessages';
|
||||
import { KeywordMessagesNotifications } from './KeywordMessages';
|
||||
import { PushRuleEditor } from './PushRuleEditor';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
@@ -37,6 +38,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
||||
<AllMessagesNotifications />
|
||||
<SpecialMessagesNotifications />
|
||||
<KeywordMessagesNotifications />
|
||||
<PushRuleEditor />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Block Messages</Text>
|
||||
<SequenceCard
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
||||
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
||||
import { useAccountData } from '../../../hooks/useAccountData';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
NotificationMode,
|
||||
useNotificationModeActions,
|
||||
} from '../../../hooks/useNotificationMode';
|
||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
|
||||
|
||||
const RULE_LABELS: Record<string, string> = {
|
||||
'.m.rule.master': 'Disable all notifications',
|
||||
'.m.rule.suppress_notices': 'Suppress bot notices',
|
||||
'.m.rule.invite_for_me': 'Invited to a room',
|
||||
'.m.rule.member_event': 'Member events (joins/leaves)',
|
||||
'.m.rule.is_user_mention': '@mention',
|
||||
'.m.rule.contains_display_name': 'Message contains my name',
|
||||
'.m.rule.is_room_mention': 'Room @mention',
|
||||
'.m.rule.tombstone': 'Room upgrade',
|
||||
'.m.rule.reaction': 'Reactions',
|
||||
'.m.rule.room_one_to_one': 'DM messages',
|
||||
'.m.rule.message': 'All messages',
|
||||
'.m.rule.encrypted': 'Encrypted messages',
|
||||
};
|
||||
|
||||
function getRuleLabel(ruleId: string): string {
|
||||
return RULE_LABELS[ruleId] ?? ruleId;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<NotificationMode, string> = {
|
||||
[NotificationMode.NotifyLoud]: 'Notify Loud',
|
||||
[NotificationMode.Notify]: 'Notify Silent',
|
||||
[NotificationMode.OFF]: 'Disable',
|
||||
};
|
||||
|
||||
const ADD_MODES: NotificationMode[] = [
|
||||
NotificationMode.NotifyLoud,
|
||||
NotificationMode.Notify,
|
||||
NotificationMode.OFF,
|
||||
];
|
||||
|
||||
type RuleEnableToggleProps = {
|
||||
kind: PushRuleKind;
|
||||
pushRule: IPushRule;
|
||||
};
|
||||
|
||||
function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
||||
|
||||
const [toggleState, toggle] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (value: boolean) => {
|
||||
await mx.setPushRuleEnabled('global', kind, pushRule.rule_id, value);
|
||||
setEnabled(value);
|
||||
},
|
||||
[mx, kind, pushRule.rule_id],
|
||||
),
|
||||
);
|
||||
|
||||
const toggling = toggleState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={enabled}
|
||||
onChange={toggling ? undefined : toggle}
|
||||
aria-label={enabled ? 'Disable rule' : 'Enable rule'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type RuleDeleteButtonProps = {
|
||||
kind: PushRuleKind;
|
||||
pushRule: IPushRule;
|
||||
};
|
||||
|
||||
function RuleDeleteButton({ kind, pushRule }: RuleDeleteButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [deleteState, doDelete] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => mx.deletePushRule('global', kind, pushRule.rule_id),
|
||||
[mx, kind, pushRule.rule_id],
|
||||
),
|
||||
);
|
||||
|
||||
const deleting = deleteState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={doDelete}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
disabled={deleting}
|
||||
aria-label="Delete rule"
|
||||
>
|
||||
{deleting ? <Spinner size="100" /> : <Icon src={Icons.Delete} size="100" />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
type RuleModeSwitcherProps = {
|
||||
kind: PushRuleKind;
|
||||
pushRule: IPushRule;
|
||||
};
|
||||
|
||||
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
|
||||
const mx = useMatrixClient();
|
||||
const getModeActions = useNotificationModeActions();
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (mode: NotificationMode) => {
|
||||
const actions = getModeActions(mode);
|
||||
await mx.setPushRuleActions('global', kind, pushRule.rule_id, actions);
|
||||
},
|
||||
[mx, getModeActions, kind, pushRule.rule_id],
|
||||
);
|
||||
|
||||
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
type RuleRowProps = {
|
||||
kind: PushRuleKind;
|
||||
pushRule: IPushRule;
|
||||
custom: boolean;
|
||||
};
|
||||
|
||||
function RuleRow({ kind, pushRule, custom }: RuleRowProps) {
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={getRuleLabel(pushRule.rule_id)}
|
||||
before={<RuleEnableToggle kind={kind} pushRule={pushRule} />}
|
||||
after={
|
||||
<Box gap="200" alignItems="Center">
|
||||
<RuleModeSwitcher kind={kind} pushRule={pushRule} />
|
||||
{custom && <RuleDeleteButton kind={kind} pushRule={pushRule} />}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
type AddRuleFormProps = {
|
||||
kind: PushRuleKind.RoomSpecific | PushRuleKind.SenderSpecific;
|
||||
placeholder: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [ruleId, setRuleId] = useState('');
|
||||
const [mode, setMode] = useState<NotificationMode>(NotificationMode.Notify);
|
||||
|
||||
const [addState, doAdd] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (id: string, notifyMode: NotificationMode) => {
|
||||
const actions = getNotificationModeActions(notifyMode);
|
||||
await mx.addPushRule('global', kind, id, { actions });
|
||||
setRuleId('');
|
||||
},
|
||||
[mx, kind],
|
||||
),
|
||||
);
|
||||
|
||||
const adding = addState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (adding) return;
|
||||
const trimmedId = ruleId.trim();
|
||||
if (!trimmedId) return;
|
||||
doAdd(trimmedId, mode);
|
||||
};
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setRuleId(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
|
||||
setMode(evt.target.value as NotificationMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
|
||||
<Text size="T200" priority="300">
|
||||
{label}
|
||||
</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Box grow="Yes">
|
||||
<Input
|
||||
required
|
||||
aria-label={placeholder}
|
||||
placeholder={placeholder}
|
||||
value={ruleId}
|
||||
onChange={handleChange}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={adding}
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<select
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid currentColor',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
}}
|
||||
>
|
||||
{ADD_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{MODE_LABELS[m]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
type="submit"
|
||||
disabled={adding}
|
||||
>
|
||||
{adding && <Spinner variant="Secondary" size="300" />}
|
||||
<Text size="B400">Add</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type RuleSectionProps = {
|
||||
title: string;
|
||||
kind: PushRuleKind;
|
||||
rules: IPushRule[];
|
||||
addForm?: React.ReactNode;
|
||||
};
|
||||
|
||||
function RuleSection({ title, kind, rules, addForm }: RuleSectionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={title}
|
||||
description={`${rules.length} rule${rules.length !== 1 ? 's' : ''}`}
|
||||
after={
|
||||
<Button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
before={
|
||||
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
|
||||
}
|
||||
>
|
||||
<Text size="B300">{expanded ? 'Collapse' : 'Expand'}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expanded && addForm && <Box direction="Column">{addForm}</Box>}
|
||||
{expanded && rules.length === 0 && !addForm && (
|
||||
<Text size="T200" priority="300">
|
||||
No rules configured.
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
{expanded &&
|
||||
rules.map((pushRule) => (
|
||||
<RuleRow
|
||||
key={pushRule.rule_id}
|
||||
kind={kind}
|
||||
pushRule={pushRule}
|
||||
custom={pushRule.default === false}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function PushRuleEditor() {
|
||||
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
||||
const pushRules = useMemo(
|
||||
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
|
||||
[pushRulesEvt],
|
||||
);
|
||||
|
||||
const overrideRules = useMemo(() => pushRules.global[PushRuleKind.Override] ?? [], [pushRules]);
|
||||
const roomRules = useMemo(() => pushRules.global[PushRuleKind.RoomSpecific] ?? [], [pushRules]);
|
||||
const senderRules = useMemo(
|
||||
() => pushRules.global[PushRuleKind.SenderSpecific] ?? [],
|
||||
[pushRules],
|
||||
);
|
||||
const underrideRules = useMemo(() => pushRules.global[PushRuleKind.Underride] ?? [], [pushRules]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Advanced Push Rules</Text>
|
||||
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||
<RuleSection
|
||||
title="Room Rules"
|
||||
kind={PushRuleKind.RoomSpecific}
|
||||
rules={roomRules}
|
||||
addForm={
|
||||
<AddRuleForm
|
||||
kind={PushRuleKind.RoomSpecific}
|
||||
placeholder="!roomid:server"
|
||||
label="Add a per-room notification rule by room ID"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RuleSection
|
||||
title="Sender Rules"
|
||||
kind={PushRuleKind.SenderSpecific}
|
||||
rules={senderRules}
|
||||
addForm={
|
||||
<AddRuleForm
|
||||
kind={PushRuleKind.SenderSpecific}
|
||||
placeholder="@user:server"
|
||||
label="Add a per-user notification rule by user ID"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
export type ExtendedProfile = {
|
||||
pronouns?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export const useExtendedProfile = (userId: string): ExtendedProfile => {
|
||||
const mx = useMatrixClient();
|
||||
const [extProfile, setExtProfile] = useState<ExtendedProfile>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchField = async <T extends Record<string, string>>(
|
||||
field: string,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const res = await mx.http.authedRequest<T>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/${field}`,
|
||||
);
|
||||
return res[field as keyof T] as string | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
Promise.all([
|
||||
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
|
||||
fetchField<{ 'm.tz': string }>('m.tz'),
|
||||
]).then(([pronouns, timezone]) => {
|
||||
if (!cancelled) {
|
||||
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
return extProfile;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function formatLocalTime(timezone: string, hour12: boolean): string | undefined {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
}).format(new Date());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneAbbr(timezone: string): string | undefined {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
.formatToParts(new Date())
|
||||
.find((p) => p.type === 'timeZoneName')?.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalTimeInfo = {
|
||||
time: string;
|
||||
abbr: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current time (and timezone abbreviation) in the given IANA
|
||||
* timezone, updated every minute. Returns undefined when timezone is
|
||||
* undefined or invalid.
|
||||
*/
|
||||
export function useLocalTime(
|
||||
timezone: string | undefined,
|
||||
hour12: boolean = true,
|
||||
): LocalTimeInfo | undefined {
|
||||
const compute = useCallback((): LocalTimeInfo | undefined => {
|
||||
if (!timezone) return undefined;
|
||||
const time = formatLocalTime(timezone, hour12);
|
||||
if (!time) return undefined;
|
||||
return { time, abbr: getTimezoneAbbr(timezone) };
|
||||
}, [timezone, hour12]);
|
||||
|
||||
const [info, setInfo] = useState<LocalTimeInfo | undefined>(compute);
|
||||
|
||||
useEffect(() => {
|
||||
setInfo(compute());
|
||||
const id = window.setInterval(() => setInfo(compute()), 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [compute]);
|
||||
|
||||
return info;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export enum RoomSettingsPage {
|
||||
DeveloperToolsPage,
|
||||
ExportPage,
|
||||
ActivityLogPage,
|
||||
ServerACLPage,
|
||||
}
|
||||
|
||||
export type RoomSettingsState = {
|
||||
|
||||
Reference in New Issue
Block a user