diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index af0cdcc79..1b2b7e234 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -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} /> ); } diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 5bc3fc648..487deee04 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -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(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 ( +
+
+ {children} + {needsCollapse && collapsed && ( +
+ )} +
+ {needsCollapse && ( + + )} +
+ ); +} + export function MBadEncrypted() { return ( @@ -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 ( <> - - {renderBody({ - body: trimmedBody, - customBody: typeof customBody === 'string' ? customBody : undefined, - })} - {edited && } - + + + {renderBody({ + body: trimmedBody, + customBody: typeof customBody === 'string' ? customBody : undefined, + })} + {edited && } + + {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} ); @@ -126,6 +207,7 @@ type MEmoteProps = { content: Record; 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 ( <> - - {`${displayName} `} - {renderBody({ - body: trimmedBody, - customBody: typeof customBody === 'string' ? customBody : undefined, - })} - {edited && } - + + + {`${displayName} `} + {renderBody({ + body: trimmedBody, + customBody: typeof customBody === 'string' ? customBody : undefined, + })} + {edited && } + + {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} ); @@ -167,6 +252,7 @@ type MNoticeProps = { content: Record; 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 ( <> - - {renderBody({ - body: trimmedBody, - customBody: typeof customBody === 'string' ? customBody : undefined, - })} - {edited && } - + + + {renderBody({ + body: trimmedBody, + customBody: typeof customBody === 'string' ? customBody : undefined, + })} + {edited && } + + {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} ); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cc2cd0c64..1d811e55a 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -108,6 +108,26 @@ export const MessageBase = recipe({ export type MessageBaseVariants = RecipeVariants; +// ── 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, diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 13581773e..9f39cc82b 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -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', +}); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 5246ce05b..b6bab14ee 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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; + 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 ( + <> + + + + + {title && ( + + {title} + + )} + + + ); + } + + return ( + + {/* GIF thumbnail — full width */} + + {title} + GIF + + {/* Footer row */} + + + + {title && ( + + {title} + + )} + + + + ); +} + function GenericCard({ url, prev, @@ -1656,6 +1769,28 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( return ; case 'imdb': return ; + case 'giphy': + return ( + + ); + case 'tenor': + return ( + + ); default: { // Generic fallback — skip empty cards if (!prev['og:title'] && !prev['og:description']) return null; diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 15da2b11e..1c83cab58 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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( 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( 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( requestClose(); }; + const isMuted = notificationMode === RoomNotificationMode.Mute; + return ( - + {invitePrompt && room && ( ( Mark as Read + } + radii="300" + > + + {copiedLink ? 'Copied!' : 'Copy Link'} + + {(handleOpen, opened, changing) => ( ( )} + {!isMuted && ( + <> + + + } + radii="300" + onClick={() => handleMuteFor(15 * 60 * 1000)} + > + + Mute for 15m + + + } + radii="300" + onClick={() => handleMuteFor(60 * 60 * 1000)} + > + + Mute for 1h + + + } + radii="300" + onClick={() => handleMuteFor(8 * 60 * 60 * 1000)} + > + + Mute for 8h + + + } + radii="300" + onClick={() => handleMuteFor(24 * 60 * 60 * 1000)} + > + + Mute for 24h + + + } + radii="300" + onClick={() => handleMuteFor(null)} + > + + Mute indefinitely + + + + + )} { + const content = e.getContent(); + 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 ( + + + + {entry.entity} + + {glob && ( + + glob + + )} + + {recommendationLabel(entry.recommendation)} + + + {entry.reason && ( + + {entry.reason} + + )} + + ); +} + +// ── Tab button ──────────────────────────────────────────────────────────────── + +function TabButton({ + label, + count, + active, + onClick, +}: { + label: string; + count: number; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +type PolicyListViewerProps = { + requestClose: () => void; +}; + +export function PolicyListViewer({ requestClose }: PolicyListViewerProps) { + const mx = useMatrixClient(); + const inputRef = useRef(null); + + const [roomIdInput, setRoomIdInput] = useState(''); + const [activeTab, setActiveTab] = useState('users'); + const [error, setError] = useState(); + + const [userEntries, setUserEntries] = useState([]); + const [roomEntries, setRoomEntries] = useState([]); + const [serverEntries, setServerEntries] = useState([]); + const [loadedRoomId, setLoadedRoomId] = useState(); + + 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 ( + + + + + + Policy Lists + + + + + + + + + + + + + + {/* Description */} + + About + + + 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. + + + + + {/* Room ID input */} + + Policy List Room + + + 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', + }} + /> + + + {error && ( + + {error} + + )} + {loadedRoomId && ( + + Showing rules from:{' '} + {loadedRoomId} + + )} + + + + {/* Rules viewer */} + {loadedRoomId && ( + + Rules + + {/* Tabs */} + + setActiveTab('users')} + /> + setActiveTab('rooms')} + /> + setActiveTab('servers')} + /> + + + {/* Entry list */} + + {activeEntries.length === 0 ? ( + + + No{' '} + {activeTab === 'users' + ? 'user' + : activeTab === 'rooms' + ? 'room' + : 'server'}{' '} + ban rules found. + + + ) : ( + activeEntries.map((entry) => ( + + )) + )} + + + + )} + + + + + + ); +} diff --git a/src/app/features/room-settings/RoomActivityLog.tsx b/src/app/features/room-settings/RoomActivityLog.tsx index 73bb5e361..161e4f4cb 100644 --- a/src/app/features/room-settings/RoomActivityLog.tsx +++ b/src/app/features/room-settings/RoomActivityLog.tsx @@ -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) { diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index e8d1f2a2a..e19139cbf 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -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(() => { 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 && ( )} + {activePage === RoomSettingsPage.PolicyListsPage && ( + + )} ); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 695d529ce..0531b82e6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1132,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} + eventId={mEventId} /> )} @@ -1241,6 +1242,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} + eventId={mEventId} /> ); } diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 0df416d62..8649f0521 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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( { + if (playAppear) { + isNewRef.current = false; + setPlayAppear(false); + } + if (playMentionPulse) { + isNewRef.current = false; + setPlayMentionPulse(false); + } + }} {...props} {...hoverProps} {...focusWithinProps} diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index bdb965e32..937201e08 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -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(() => { 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 && ( )} + {activePage === SpaceSettingsPage.PolicyListsPage && ( + + )} ); } diff --git a/src/app/hooks/useFileDrop.ts b/src/app/hooks/useFileDrop.ts index 78438ea51..e7d97ba86 100644 --- a/src/app/hooks/useFileDrop.ts +++ b/src/app/hooks/useFileDrop.ts @@ -14,14 +14,14 @@ export const useFileDropZone = ( zoneRef: RefObject, 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); diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts index 19d2b6673..08875b1e8 100644 --- a/src/app/state/roomSettings.ts +++ b/src/app/state/roomSettings.ts @@ -10,6 +10,7 @@ export enum RoomSettingsPage { ActivityLogPage, ServerACLPage, InsightsPage, + PolicyListsPage, } export type RoomSettingsState = { diff --git a/src/app/state/spaceSettings.ts b/src/app/state/spaceSettings.ts index e79506bb0..c82a838ed 100644 --- a/src/app/state/spaceSettings.ts +++ b/src/app/state/spaceSettings.ts @@ -6,6 +6,7 @@ export enum SpaceSettingsPage { PermissionsPage, EmojisStickersPage, DeveloperToolsPage, + PolicyListsPage, } export type SpaceSettingsState = { diff --git a/src/app/styles/Animations.css.ts b/src/app/styles/Animations.css.ts index 4e64f110b..5a92b9a64 100644 --- a/src/app/styles/Animations.css.ts +++ b/src/app/styles/Animations.css.ts @@ -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`, + }, + }, +});