feat: GIF previews, room context menu, policy lists, mention pulse, collapsible messages, send animation, D&D fix
P3-5: Giphy/Tenor URL preview cards — full-width thumbnail from og:image mxc URL, GIF badge overlay, site badge + title footer; GifCard shared by both; BadgeGiphy (teal) and BadgeTenor (blue) CSS classes P3-9: Policy list viewer — read-only panel in Room Settings + Space Settings (admin/50+ PL only); enter room ID or alias; tabs for Users / Rooms / Servers; glob pattern warning color; Ban badge; entity + reason P5-8: Mention highlight pulse — 0.6s scale+glow keyframe on incoming @mention messages; prefers-reduced-motion aware; only fires on new incoming messages (isNewRef), not on history load; onAnimationEnd cleanup P5-19: Collapsible long messages — ResizeObserver clamps text bodies >320px with gradient fade + "Read more ↓" / "Show less ↑" button; resets on eventId change; skips images/video/audio/file; smooth CSS transition P5-23: Message send animation — own messages fade+scale in (0.97→1, 0.4→1 opacity, 150ms ease-out); prefers-reduced-motion aware; one-shot via isNewRef + onAnimationEnd clear P5-26: Room context menu — Copy Link (matrix.to URL, 1.5s Copied! feedback), Mute with duration (15m/1h/8h/24h/indefinite, localStorage timer key io.lotus.mute_timers), Mark as read; Icons.Link + Icons.BellMute BUG D&D: dragCounter ref replaces fragile dragState machine — enter increments, leave decrements (hides at 0), drop resets to 0; fixes spurious dragleave from child element boundary crossings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ type RenderMessageContentProps = {
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
eventId?: string;
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
@@ -60,6 +61,7 @@ export function RenderMessageContent({
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
eventId,
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
@@ -147,6 +149,7 @@ export function RenderMessageContent({
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
eventId={eventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -167,6 +170,7 @@ export function RenderMessageContent({
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
eventId={eventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -186,6 +190,7 @@ export function RenderMessageContent({
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
eventId={eventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { CSSProperties, ReactNode } from 'react';
|
||||
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
|
||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Chip, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||
import { trimReplyFromBody } from '../../utils/room';
|
||||
@@ -31,6 +31,83 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||
|
||||
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
|
||||
|
||||
type CollapsibleBodyProps = {
|
||||
eventId?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [needsCollapse, setNeedsCollapse] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
// Reset collapsed state when the event changes (new message)
|
||||
useEffect(() => {
|
||||
setCollapsed(true);
|
||||
setNeedsCollapse(false);
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return undefined;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
...(needsCollapse && collapsed
|
||||
? {
|
||||
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
|
||||
overflow: 'hidden',
|
||||
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
|
||||
}
|
||||
: {
|
||||
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{needsCollapse && collapsed && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3rem',
|
||||
background: `linear-gradient(transparent, ${color.Surface.Container})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{needsCollapse && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
style={{ marginTop: '4px' }}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
<Text size="B300">{collapsed ? 'Read more ↓' : 'Show less ↑'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MBadEncrypted() {
|
||||
return (
|
||||
<Text>
|
||||
@@ -85,6 +162,7 @@ type MTextProps = {
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||
style?: CSSProperties;
|
||||
eventId?: string;
|
||||
};
|
||||
export function MText({
|
||||
edited,
|
||||
@@ -93,6 +171,7 @@ export function MText({
|
||||
renderBody,
|
||||
renderUrlsPreview,
|
||||
style,
|
||||
eventId,
|
||||
}: MTextProps) {
|
||||
const { body, formatted_body: customBody } = content;
|
||||
|
||||
@@ -103,17 +182,19 @@ export function MText({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageTextBody
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
style={style}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
<CollapsibleBody eventId={eventId}>
|
||||
<MessageTextBody
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
style={style}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
</CollapsibleBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
</>
|
||||
);
|
||||
@@ -126,6 +207,7 @@ type MEmoteProps = {
|
||||
content: Record<string, unknown>;
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||
eventId?: string;
|
||||
};
|
||||
export function MEmote({
|
||||
displayName,
|
||||
@@ -134,6 +216,7 @@ export function MEmote({
|
||||
content,
|
||||
renderBody,
|
||||
renderUrlsPreview,
|
||||
eventId,
|
||||
}: MEmoteProps) {
|
||||
const { body, formatted_body: customBody } = content;
|
||||
|
||||
@@ -144,18 +227,20 @@ export function MEmote({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageTextBody
|
||||
emote
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
<b>{`${displayName} `}</b>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
<CollapsibleBody eventId={eventId}>
|
||||
<MessageTextBody
|
||||
emote
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
<b>{`${displayName} `}</b>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
</CollapsibleBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
</>
|
||||
);
|
||||
@@ -167,6 +252,7 @@ type MNoticeProps = {
|
||||
content: Record<string, unknown>;
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||
eventId?: string;
|
||||
};
|
||||
export function MNotice({
|
||||
edited,
|
||||
@@ -174,6 +260,7 @@ export function MNotice({
|
||||
content,
|
||||
renderBody,
|
||||
renderUrlsPreview,
|
||||
eventId,
|
||||
}: MNoticeProps) {
|
||||
const { body, formatted_body: customBody } = content;
|
||||
|
||||
@@ -184,17 +271,19 @@ export function MNotice({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageTextBody
|
||||
notice
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
<CollapsibleBody eventId={eventId}>
|
||||
<MessageTextBody
|
||||
notice
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
</CollapsibleBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -108,6 +108,26 @@ export const MessageBase = recipe({
|
||||
|
||||
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
|
||||
|
||||
// ── Mention pulse animation ───────────────────────────────────────────────────
|
||||
|
||||
const mentionPulseKeyframes = keyframes({
|
||||
'0%': { transform: 'scale(1)', boxShadow: 'none' },
|
||||
'30%': { transform: 'scale(1.003)', boxShadow: `0 0 8px ${color.Warning.Main}` },
|
||||
'100%': { transform: 'scale(1)', boxShadow: 'none' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Applied only to new incoming @mention messages.
|
||||
* Respects `prefers-reduced-motion`: no animation when motion is reduced.
|
||||
*/
|
||||
export const MentionHighlightPulse = style({
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${mentionPulseKeyframes} 0.6s ease-out`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const CompactHeader = style([
|
||||
DefaultReset,
|
||||
StickySection,
|
||||
|
||||
@@ -675,3 +675,68 @@ export const PortraitSideLayout = style([
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GIF card (Giphy / Tenor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GifThumbnailWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: color.Surface.Container,
|
||||
cursor: 'pointer',
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(0.9)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const GifThumbnailImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
export const GifBadge = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: config.space.S100,
|
||||
right: config.space.S100,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: config.space.S100,
|
||||
paddingRight: config.space.S100,
|
||||
paddingTop: '2px',
|
||||
paddingBottom: '2px',
|
||||
borderRadius: config.radii.R300,
|
||||
fontSize: toRem(11),
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.4',
|
||||
letterSpacing: '0.05em',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
color: '#ffffff',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const BadgeGiphy = style({
|
||||
backgroundColor: '#00c0c0',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeTenor = style({
|
||||
backgroundColor: '#0078d4',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ type CardVariant =
|
||||
| 'npm'
|
||||
| 'stackoverflow'
|
||||
| 'imdb'
|
||||
| 'giphy'
|
||||
| 'tenor'
|
||||
| 'generic';
|
||||
|
||||
function getYouTubeVideoId(url: string): string | null {
|
||||
@@ -300,6 +302,34 @@ function isImdb(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isGiphy(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
if (h === 'gph.is') return true;
|
||||
if (h === 'giphy.com' || h === 'media.giphy.com') {
|
||||
return (
|
||||
pathname.startsWith('/gifs/') ||
|
||||
pathname.startsWith('/clips/') ||
|
||||
pathname.startsWith('/media/')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isTenor(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
return h === 'tenor.com' && pathname.startsWith('/view/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getCardVariant(url: string): CardVariant {
|
||||
// Shorts must be detected before generic youtube
|
||||
if (isYouTubeShorts(url)) return 'youtube-shorts';
|
||||
@@ -317,6 +347,8 @@ function getCardVariant(url: string): CardVariant {
|
||||
if (isNpm(url)) return 'npm';
|
||||
if (isStackOverflow(url)) return 'stackoverflow';
|
||||
if (isImdb(url)) return 'imdb';
|
||||
if (isGiphy(url)) return 'giphy';
|
||||
if (isTenor(url)) return 'tenor';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
@@ -1518,6 +1550,87 @@ function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card: GIF (Giphy / Tenor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GifCard({
|
||||
url,
|
||||
prev,
|
||||
mx,
|
||||
useAuthentication,
|
||||
siteBadgeLabel,
|
||||
siteBadgeClass,
|
||||
}: {
|
||||
url: string;
|
||||
prev: IPreviewUrlResponse;
|
||||
mx: ReturnType<typeof useMatrixClient>;
|
||||
useAuthentication: boolean;
|
||||
siteBadgeLabel: string;
|
||||
siteBadgeClass: string;
|
||||
}) {
|
||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
|
||||
const thumbSrc = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
: null;
|
||||
|
||||
// If there's no image, fall back to a generic-style layout
|
||||
if (!thumbSrc) {
|
||||
return (
|
||||
<>
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
|
||||
</Text>
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
<b>{title}</b>
|
||||
</Text>
|
||||
)}
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: '100%' }}>
|
||||
{/* GIF thumbnail — full width */}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.GifThumbnailWrapper}
|
||||
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
|
||||
>
|
||||
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
|
||||
<span className={previewCss.GifBadge}>GIF</span>
|
||||
</a>
|
||||
{/* Footer row */}
|
||||
<UrlPreviewContent>
|
||||
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
|
||||
{title && (
|
||||
<Text truncate priority="400" style={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</UrlPreviewContent>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericCard({
|
||||
url,
|
||||
prev,
|
||||
@@ -1656,6 +1769,28 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
return <StackOverflowCard url={url} prev={prev} />;
|
||||
case 'imdb':
|
||||
return <ImdbCard url={url} prev={prev} />;
|
||||
case 'giphy':
|
||||
return (
|
||||
<GifCard
|
||||
url={url}
|
||||
prev={prev}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
siteBadgeLabel="Giphy"
|
||||
siteBadgeClass={previewCss.BadgeGiphy}
|
||||
/>
|
||||
);
|
||||
case 'tenor':
|
||||
return (
|
||||
<GifCard
|
||||
url={url}
|
||||
prev={prev}
|
||||
mx={mx}
|
||||
useAuthentication={useAuthentication}
|
||||
siteBadgeLabel="Tenor"
|
||||
siteBadgeClass={previewCss.BadgeTenor}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
// Generic fallback — skip empty cards
|
||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||
|
||||
@@ -216,6 +216,30 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// localStorage key for timed mute timers
|
||||
const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||
|
||||
type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||
|
||||
function loadMuteTimers(): MuteTimerEntry[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||
}
|
||||
|
||||
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||
const unmuteAt = Date.now() + durationMs;
|
||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||
setTimeout(onUnmute, durationMs);
|
||||
}
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
requestClose: () => void;
|
||||
@@ -236,6 +260,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
const space = useSpaceOptionally();
|
||||
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const isServerNotice = room.getType() === 'm.server_notice';
|
||||
|
||||
const isFavorite = !!room.tags?.['m.favourite'];
|
||||
@@ -254,6 +279,41 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleCopyRoomLink = () => {
|
||||
const roomAlias = room.getCanonicalAlias() ?? room.roomId;
|
||||
const link = `https://matrix.to/#/${encodeURIComponent(roomAlias)}`;
|
||||
navigator.clipboard.writeText(link).catch(() => {});
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 1500);
|
||||
};
|
||||
|
||||
const handleMuteFor = useCallback(
|
||||
async (durationMs: number | null) => {
|
||||
const { setRoomNotificationPreference } =
|
||||
await import('../../hooks/useRoomsNotificationPreferences');
|
||||
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
|
||||
await setRoomNotificationPreference(
|
||||
mx,
|
||||
room.roomId,
|
||||
RoomNotificationMode.Mute,
|
||||
prevMode,
|
||||
).catch(() => {});
|
||||
if (durationMs !== null) {
|
||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
||||
setRoomNotificationPreference(
|
||||
mx,
|
||||
room.roomId,
|
||||
RoomNotificationMode.Unset,
|
||||
RoomNotificationMode.Mute,
|
||||
).catch(() => {});
|
||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
|
||||
});
|
||||
}
|
||||
requestClose();
|
||||
},
|
||||
[mx, room.roomId, notificationMode, requestClose],
|
||||
);
|
||||
|
||||
const handleInvite = () => {
|
||||
setInvitePrompt(true);
|
||||
};
|
||||
@@ -263,8 +323,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const isMuted = notificationMode === RoomNotificationMode.Mute;
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||
{invitePrompt && room && (
|
||||
<InviteUserPrompt
|
||||
room={room}
|
||||
@@ -286,6 +348,16 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleCopyRoomLink}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Link} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{copiedLink ? 'Copied!' : 'Copy Link'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||
{(handleOpen, opened, changing) => (
|
||||
<MenuItem
|
||||
@@ -308,6 +380,63 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
)}
|
||||
</RoomNotificationModeSwitcher>
|
||||
</Box>
|
||||
{!isMuted && (
|
||||
<>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.BellMute} />}
|
||||
radii="300"
|
||||
onClick={() => handleMuteFor(15 * 60 * 1000)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mute for 15m
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.BellMute} />}
|
||||
radii="300"
|
||||
onClick={() => handleMuteFor(60 * 60 * 1000)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mute for 1h
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.BellMute} />}
|
||||
radii="300"
|
||||
onClick={() => handleMuteFor(8 * 60 * 60 * 1000)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mute for 8h
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.BellMute} />}
|
||||
radii="300"
|
||||
onClick={() => handleMuteFor(24 * 60 * 60 * 1000)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mute for 24h
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.BellMute} />}
|
||||
radii="300"
|
||||
onClick={() => handleMuteFor(null)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mute indefinitely
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Badge, Box, Button, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
|
||||
import { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
|
||||
// ── Policy event types ────────────────────────────────────────────────────────
|
||||
|
||||
const POLICY_USER_EVENT = 'm.policy.rule.user';
|
||||
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
|
||||
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
|
||||
|
||||
type PolicyRuleContent = {
|
||||
entity?: string;
|
||||
reason?: string;
|
||||
recommendation?: string;
|
||||
};
|
||||
|
||||
type PolicyEntry = {
|
||||
entity: string;
|
||||
reason: string;
|
||||
recommendation: string;
|
||||
stateKey: string;
|
||||
};
|
||||
|
||||
type PolicyTab = 'users' | 'rooms' | 'servers';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function isGlob(entity: string): boolean {
|
||||
return entity.includes('*') || entity.includes('?');
|
||||
}
|
||||
|
||||
function recommendationLabel(rec: string): string {
|
||||
if (rec === 'm.ban') return 'Ban';
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all state events of a given type from a room's live forward state.
|
||||
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
||||
*/
|
||||
function getRoomPolicyEvents(room: Room, eventType: string): MatrixEvent[] {
|
||||
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!state) return [];
|
||||
return state.getStateEvents(eventType);
|
||||
}
|
||||
|
||||
function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
||||
return events
|
||||
.map((e) => {
|
||||
const content = e.getContent<PolicyRuleContent>();
|
||||
return {
|
||||
entity: content.entity ?? '',
|
||||
reason: content.reason ?? '',
|
||||
recommendation: content.recommendation ?? '',
|
||||
stateKey: e.getStateKey() ?? '',
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.entity !== '');
|
||||
}
|
||||
|
||||
// ── Entry row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
||||
const glob = isGlob(entry.entity);
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||
<Text
|
||||
size="T300"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
color: glob ? color.Warning.Main : undefined,
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{entry.entity}
|
||||
</Text>
|
||||
{glob && (
|
||||
<Badge variant="Warning" fill="Soft" radii="Pill">
|
||||
<Text size="T200">glob</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="Critical" fill="Soft" radii="Pill">
|
||||
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
{entry.reason && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
{entry.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab button ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">
|
||||
{label} ({count})
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type PolicyListViewerProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [roomIdInput, setRoomIdInput] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<PolicyTab>('users');
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const [userEntries, setUserEntries] = useState<PolicyEntry[]>([]);
|
||||
const [roomEntries, setRoomEntries] = useState<PolicyEntry[]>([]);
|
||||
const [serverEntries, setServerEntries] = useState<PolicyEntry[]>([]);
|
||||
const [loadedRoomId, setLoadedRoomId] = useState<string | undefined>();
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const rawInput = (inputRef.current?.value ?? roomIdInput).trim();
|
||||
if (!rawInput) {
|
||||
setError('Please enter a room ID or alias.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve alias to room ID using local cache
|
||||
let roomId = rawInput;
|
||||
if (rawInput.startsWith('#')) {
|
||||
const cachedId = mx.getRooms().find((r) => {
|
||||
const aliases = r.getAltAliases();
|
||||
const canonical = r.getCanonicalAlias();
|
||||
return aliases.includes(rawInput) || canonical === rawInput;
|
||||
})?.roomId;
|
||||
|
||||
if (cachedId) {
|
||||
roomId = cachedId;
|
||||
} else {
|
||||
setError(`Cannot resolve alias "${rawInput}". Make sure you have joined that room.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) {
|
||||
setError(`Not joined to room "${roomId}". Join the policy list room first.`);
|
||||
setUserEntries([]);
|
||||
setRoomEntries([]);
|
||||
setServerEntries([]);
|
||||
setLoadedRoomId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
|
||||
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
|
||||
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
|
||||
setLoadedRoomId(roomId);
|
||||
setError(undefined);
|
||||
}, [mx, roomIdInput]);
|
||||
|
||||
const activeEntries =
|
||||
activeTab === 'users' ? userEntries : activeTab === 'rooms' ? roomEntries : serverEntries;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text as="h2" size="H3" truncate>
|
||||
Policy Lists
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<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">
|
||||
{/* Description */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">About</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Text size="T300">
|
||||
Policy lists are Matrix rooms containing ban rules managed by moderation bots
|
||||
(e.g. Draupnir). Enter a policy list room ID below to inspect its current rules.
|
||||
This is a read-only viewer — rule enforcement is handled by your moderation bot.
|
||||
</Text>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
{/* Room ID input */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Policy List Room</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={roomIdInput}
|
||||
onChange={(e) => setRoomIdInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleLoad();
|
||||
}}
|
||||
placeholder="!roomId:server or #alias:server"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
|
||||
background: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleLoad}
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Search} size="100" />}
|
||||
>
|
||||
<Text size="B300">Load</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
{loadedRoomId && (
|
||||
<Text size="T200" priority="300">
|
||||
Showing rules from:{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{loadedRoomId}</span>
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
{/* Rules viewer */}
|
||||
{loadedRoomId && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Rules</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
{/* Tabs */}
|
||||
<Box gap="200">
|
||||
<TabButton
|
||||
label="Users"
|
||||
count={userEntries.length}
|
||||
active={activeTab === 'users'}
|
||||
onClick={() => setActiveTab('users')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Rooms"
|
||||
count={roomEntries.length}
|
||||
active={activeTab === 'rooms'}
|
||||
onClick={() => setActiveTab('rooms')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Servers"
|
||||
count={serverEntries.length}
|
||||
active={activeTab === 'servers'}
|
||||
onClick={() => setActiveTab('servers')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Entry list */}
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{activeEntries.length === 0 ? (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ padding: config.space.S500 }}
|
||||
>
|
||||
<Text size="T300" priority="300">
|
||||
No{' '}
|
||||
{activeTab === 'users'
|
||||
? 'user'
|
||||
: activeTab === 'rooms'
|
||||
? 'room'
|
||||
: 'server'}{' '}
|
||||
ban rules found.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
activeEntries.map((entry) => (
|
||||
<PolicyEntryRow key={entry.stateKey || entry.entity} entry={entry} />
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -316,13 +316,6 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
|
||||
setEvents(getStateEvents());
|
||||
}, [getStateEvents]);
|
||||
|
||||
// Auto-paginate on mount — state events are rarely in the initial sync
|
||||
// window, so we immediately fetch backwards to populate the log.
|
||||
useEffect(() => {
|
||||
handleLoadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loading || !canLoadMore) return;
|
||||
setLoading(true);
|
||||
@@ -341,6 +334,13 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
|
||||
}
|
||||
}, [loading, canLoadMore, mx, room, getStateEvents]);
|
||||
|
||||
// Auto-paginate on mount — state events are rarely in the initial sync
|
||||
// window, so we immediately fetch backwards to populate the log.
|
||||
useEffect(() => {
|
||||
handleLoadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Build described entries
|
||||
const entries: Array<{ ev: MatrixEvent; desc: EventDesc }> = [];
|
||||
for (const ev of events) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ExportRoomHistory } from './ExportRoomHistory';
|
||||
import { RoomActivityLog } from './RoomActivityLog';
|
||||
import { RoomServerACL } from './RoomServerACL';
|
||||
import { RoomInsights } from './RoomInsights';
|
||||
import { PolicyListViewer } from './PolicyListViewer';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -80,11 +81,22 @@ const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
|
||||
icon: Icons.Shield,
|
||||
};
|
||||
|
||||
function useRoomSettingsMenuItems(canSeeServerACL: boolean): RoomSettingsMenuItem[] {
|
||||
return useMemo(
|
||||
() => (canSeeServerACL ? [...BASE_MENU_ITEMS, SERVER_ACL_MENU_ITEM] : BASE_MENU_ITEMS),
|
||||
[canSeeServerACL],
|
||||
);
|
||||
const POLICY_LISTS_MENU_ITEM: RoomSettingsMenuItem = {
|
||||
page: RoomSettingsPage.PolicyListsPage,
|
||||
name: 'Policy Lists',
|
||||
icon: Icons.NoEntry,
|
||||
};
|
||||
|
||||
function useRoomSettingsMenuItems(
|
||||
canSeeServerACL: boolean,
|
||||
canSeePolicyLists: boolean,
|
||||
): RoomSettingsMenuItem[] {
|
||||
return useMemo(() => {
|
||||
const items = [...BASE_MENU_ITEMS];
|
||||
if (canSeeServerACL) items.push(SERVER_ACL_MENU_ITEM);
|
||||
if (canSeePolicyLists) items.push(POLICY_LISTS_MENU_ITEM);
|
||||
return items;
|
||||
}, [canSeeServerACL, canSeePolicyLists]);
|
||||
}
|
||||
|
||||
type RoomSettingsProps = {
|
||||
@@ -116,13 +128,15 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
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);
|
||||
// Show Policy Lists to admins (power level 50+) or creators.
|
||||
const canSeePolicyLists = myPL >= 50 || 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(canSeeServerACL);
|
||||
const menuItems = useRoomSettingsMenuItems(canSeeServerACL, canSeePolicyLists);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -227,6 +241,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
{activePage === RoomSettingsPage.InsightsPage && (
|
||||
<RoomInsights requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.PolicyListsPage && (
|
||||
<PolicyListViewer requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
@@ -1241,6 +1242,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import React, {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -58,7 +59,8 @@ import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
import { SendingSpinClass } from '../../../styles/Animations.css';
|
||||
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
||||
import { MentionHighlightPulse } from '../../../components/message/layout/layout.css';
|
||||
import { EventReaders } from '../../../components/event-readers';
|
||||
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
|
||||
import { useReadPositions } from '../ReadPositionsContext';
|
||||
@@ -787,6 +789,19 @@ export const Message = React.memo(
|
||||
: (readPositions.get(mEvent.getId() ?? '') ?? []);
|
||||
const isMine = mEvent.getSender() === mx.getUserId();
|
||||
const lotusTerminal = lotusTerminalProp;
|
||||
// Track whether this message should play the appear animation (own messages only)
|
||||
const isNewRef = useRef(true);
|
||||
const [playAppear, setPlayAppear] = useState(isMine && isNewRef.current);
|
||||
// Mention pulse: play once for new incoming @mention messages from others
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
const mentionContent = mEvent.getContent<{
|
||||
'm.mentions'?: { user_ids?: string[]; room?: boolean };
|
||||
}>();
|
||||
const isMentioned =
|
||||
!isMine &&
|
||||
(mentionContent['m.mentions']?.user_ids?.includes(myUserId) === true ||
|
||||
mentionContent['m.mentions']?.room === true);
|
||||
const [playMentionPulse, setPlayMentionPulse] = useState(isMentioned && isNewRef.current);
|
||||
const [hover, setHover] = useState(false);
|
||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||
@@ -956,12 +971,24 @@ export const Message = React.memo(
|
||||
<MessageBase
|
||||
className={classNames(css.MessageBase, className, {
|
||||
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
||||
[MsgAppearClass]: playAppear,
|
||||
[MentionHighlightPulse]: playMentionPulse,
|
||||
})}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
collapse={collapse}
|
||||
highlight={highlight}
|
||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||
onAnimationEnd={() => {
|
||||
if (playAppear) {
|
||||
isNewRef.current = false;
|
||||
setPlayAppear(false);
|
||||
}
|
||||
if (playMentionPulse) {
|
||||
isNewRef.current = false;
|
||||
setPlayMentionPulse(false);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
|
||||
@@ -17,6 +17,9 @@ import { Members } from '../common-settings/members';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { General } from './general';
|
||||
import { Permissions } from './permissions';
|
||||
import { PolicyListViewer } from '../room-settings/PolicyListViewer';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
|
||||
type SpaceSettingsMenuItem = {
|
||||
page: SpaceSettingsPage;
|
||||
@@ -24,36 +27,47 @@ type SpaceSettingsMenuItem = {
|
||||
icon: IconSrc;
|
||||
};
|
||||
|
||||
const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] =>
|
||||
const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
|
||||
{
|
||||
page: SpaceSettingsPage.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.MembersPage,
|
||||
name: 'Members',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.PermissionsPage,
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
];
|
||||
|
||||
const SPACE_POLICY_LISTS_ITEM: SpaceSettingsMenuItem = {
|
||||
page: SpaceSettingsPage.PolicyListsPage,
|
||||
name: 'Policy Lists',
|
||||
icon: Icons.NoEntry,
|
||||
};
|
||||
|
||||
const useSpaceSettingsMenuItems = (canSeePolicyLists: boolean): SpaceSettingsMenuItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
page: SpaceSettingsPage.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.MembersPage,
|
||||
name: 'Members',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.PermissionsPage,
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
],
|
||||
[],
|
||||
() =>
|
||||
canSeePolicyLists
|
||||
? [...BASE_SPACE_MENU_ITEMS, SPACE_POLICY_LISTS_ITEM]
|
||||
: BASE_SPACE_MENU_ITEMS,
|
||||
[canSeePolicyLists],
|
||||
);
|
||||
|
||||
type SpaceSettingsProps = {
|
||||
@@ -74,12 +88,19 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
||||
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
||||
// Show Policy Lists to admins (power level 50+) or creators.
|
||||
const canSeePolicyLists = myPL >= 50 || creators.has(myUserId);
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [activePage, setActivePage] = useState<SpaceSettingsPage | undefined>(() => {
|
||||
if (initialPage) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SpaceSettingsPage.GeneralPage;
|
||||
});
|
||||
const menuItems = useSpaceSettingsMenuItems();
|
||||
const menuItems = useSpaceSettingsMenuItems(canSeePolicyLists);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -172,6 +193,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
||||
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SpaceSettingsPage.PolicyListsPage && (
|
||||
<PolicyListViewer requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ export const useFileDropZone = (
|
||||
zoneRef: RefObject<HTMLElement>,
|
||||
onDrop: (file: File[]) => void,
|
||||
): boolean => {
|
||||
const dragStateRef = useRef<'start' | 'leave' | 'over' | undefined>(undefined);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const target = zoneRef.current;
|
||||
const handleDrop = (evt: DragEvent) => {
|
||||
evt.preventDefault();
|
||||
dragStateRef.current = undefined;
|
||||
dragCounterRef.current = 0;
|
||||
setActive(false);
|
||||
if (!evt.dataTransfer) return;
|
||||
const files = getDataTransferFiles(evt.dataTransfer);
|
||||
@@ -38,18 +38,19 @@ export const useFileDropZone = (
|
||||
const target = zoneRef.current;
|
||||
const handleDragEnter = (evt: DragEvent) => {
|
||||
if (evt.dataTransfer?.types.includes('Files')) {
|
||||
dragStateRef.current = 'start';
|
||||
dragCounterRef.current += 1;
|
||||
setActive(true);
|
||||
}
|
||||
};
|
||||
const handleDragLeave = () => {
|
||||
if (dragStateRef.current !== 'over') return;
|
||||
dragStateRef.current = 'leave';
|
||||
setActive(false);
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current <= 0) {
|
||||
dragCounterRef.current = 0;
|
||||
setActive(false);
|
||||
}
|
||||
};
|
||||
const handleDragOver = (evt: DragEvent) => {
|
||||
evt.preventDefault();
|
||||
dragStateRef.current = 'over';
|
||||
};
|
||||
|
||||
target?.addEventListener('dragenter', handleDragEnter);
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum RoomSettingsPage {
|
||||
ActivityLogPage,
|
||||
ServerACLPage,
|
||||
InsightsPage,
|
||||
PolicyListsPage,
|
||||
}
|
||||
|
||||
export type RoomSettingsState = {
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum SpaceSettingsPage {
|
||||
PermissionsPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
PolicyListsPage,
|
||||
}
|
||||
|
||||
export type SpaceSettingsState = {
|
||||
|
||||
@@ -56,3 +56,16 @@ export const SendingSpinClass = style({
|
||||
animation: `${spin} 900ms linear infinite`,
|
||||
transformOrigin: 'center',
|
||||
});
|
||||
|
||||
const msgAppearKeyframe = keyframes({
|
||||
from: { opacity: 0.4, transform: 'scale(0.97)' },
|
||||
to: { opacity: 1, transform: 'scale(1)' },
|
||||
});
|
||||
|
||||
export const MsgAppearClass = style({
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${msgAppearKeyframe} 150ms ease-out both`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user