diff --git a/src/app/components/QuickSwitcher.tsx b/src/app/components/QuickSwitcher.tsx new file mode 100644 index 000000000..02f90acd9 --- /dev/null +++ b/src/app/components/QuickSwitcher.tsx @@ -0,0 +1,206 @@ +import React, { + ChangeEventHandler, + KeyboardEventHandler, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { useAtomValue } from 'jotai'; +import { Avatar, Box, Icon, Icons, Input, Text, config } from 'folds'; +import { Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { allRoomsAtom } from '../state/room-list/roomList'; +import { useRoomNavigate } from '../hooks/useRoomNavigate'; +import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; +import { mDirectAtom } from '../state/mDirectList'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room'; +import { RoomAvatar, RoomIcon } from './room-avatar'; +import { nameInitials } from '../utils/common'; + +const MAX_RESULTS = 10; + +function RoomFallback({ room }: { room: Room }) { + return ( + + {nameInitials(room.name)} + + ); +} + +type QuickSwitcherProps = { + onClose: () => void; +}; + +export function QuickSwitcher({ onClose }: QuickSwitcherProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const allRoomIds = useAtomValue(allRoomsAtom); + const mDirects = useAtomValue(mDirectAtom); + const { navigateRoom } = useRoomNavigate(); + + const [query, setQuery] = useState(''); + const [selectedIdx, setSelectedIdx] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const filteredRooms = query.trim() + ? allRoomIds + .map((id) => ({ id, room: mx.getRoom(id) })) + .filter(({ room }) => room && room.name.toLowerCase().includes(query.trim().toLowerCase())) + .slice(0, MAX_RESULTS) + : allRoomIds + .map((id) => ({ id, room: mx.getRoom(id) })) + .filter(({ room }) => !!room) + .slice(0, MAX_RESULTS); + + const handleChange: ChangeEventHandler = (evt) => { + setQuery(evt.currentTarget.value); + setSelectedIdx(0); + }; + + const navigateToRoom = useCallback( + (roomId: string) => { + navigateRoom(roomId); + onClose(); + }, + [navigateRoom, onClose], + ); + + const handleKeyDown: KeyboardEventHandler = useCallback( + (evt) => { + if (evt.key === 'ArrowDown') { + evt.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, filteredRooms.length - 1)); + } else if (evt.key === 'ArrowUp') { + evt.preventDefault(); + setSelectedIdx((i) => Math.max(i - 1, 0)); + } else if (evt.key === 'Enter') { + const item = filteredRooms[selectedIdx]; + if (item) navigateToRoom(item.id); + } else if (evt.key === 'Escape') { + onClose(); + } + }, + [filteredRooms, selectedIdx, navigateToRoom, onClose], + ); + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Search input */} + + } + placeholder="Search rooms…" + size="500" + variant="Background" + outlined + /> + + + {/* Results list */} + {filteredRooms.length > 0 && ( + + {filteredRooms.map(({ id, room }, idx) => { + if (!room) return null; + const dm = mDirects.has(id); + const avatarUrl = dm + ? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication) + : getRoomAvatarUrl(mx, room, 32, useAuthentication); + const isSelected = idx === selectedIdx; + + return ( + + ); + })} + + )} + + {filteredRooms.length === 0 && ( + + + {query.trim() ? `No rooms matching "${query}"` : 'No rooms'} + + + )} +
+ + ); +} diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 7ec63934c..7887822cb 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -10,8 +10,9 @@ import { Spinner, Text, as, + color, } from 'folds'; -import { Room } from 'matrix-js-sdk'; +import { JoinRule, Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; @@ -42,6 +43,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const mDirects = useAtomValue(mDirectAtom); const [invitePrompt, setInvitePrompt] = useState(false); const [viewTopic, setViewTopic] = useState(false); + const [knocked, setKnocked] = useState(false); + const [knockError, setKnockError] = useState(); const createEvent = getStateEvent(room, StateEvent.RoomCreate); const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); @@ -168,6 +171,36 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => Join Old Room ))} + {room.getJoinRule() === JoinRule.Knock && + room.getMyMembership() !== Membership.Join && + (knocked ? ( + + Request sent — waiting for room admin approval + + ) : ( + <> + + {knockError && ( + + {knockError} + + )} + + ))} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 075a930a7..84023c87f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -27,6 +27,9 @@ import { import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; +import dayjs from 'dayjs'; +import isToday from 'dayjs/plugin/isToday'; +import isYesterday from 'dayjs/plugin/isYesterday'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -67,8 +70,31 @@ import { callChatAtom } from '../../state/callEmbed'; import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; -import { StateEvent } from '../../../types/matrix/room'; +import { MessageEvent, StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; +import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent'; + +dayjs.extend(isToday); +dayjs.extend(isYesterday); + +const PREVIEW_MAX_CHARS = 48; + +function formatDmTimestamp(ts: number): string { + const d = dayjs(ts); + const now = dayjs(); + const diffMinutes = now.diff(d, 'minute'); + if (diffMinutes < 60) { + return `${diffMinutes < 1 ? 0 : diffMinutes}m`; + } + const diffHours = now.diff(d, 'hour'); + if (diffHours < 24) { + return `${diffHours}h`; + } + if (d.isYesterday()) { + return 'Yesterday'; + } + return d.format('D MMM'); +} type RenameRoomDialogProps = { room: Room; @@ -419,6 +445,28 @@ function RoomNavItem_({ const roomName = useLocalRoomName(room); const hasLocalName = useHasLocalRoomName(room.roomId); + const latestEvent = useRoomLatestRenderedEvent(room); + const dmPreview = (() => { + if (!direct || !latestEvent) return null; + const type = latestEvent.getType(); + const ts = latestEvent.getTs(); + if (!ts) return null; + // Skip pure membership events + if (type === StateEvent.RoomMember) return null; + let body: string; + if (latestEvent.isEncrypted()) { + body = 'Encrypted message'; + } else if (type === MessageEvent.Sticker) { + body = 'Sticker'; + } else { + const rawBody: unknown = latestEvent.getContent()?.body; + body = typeof rawBody === 'string' ? rawBody.replace(/\s+/g, ' ').trim() : ''; + } + if (!body) return null; + const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}…` : body; + return { preview, time: formatDmTimestamp(ts) }; + })(); + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); setMenuAnchor({ @@ -510,26 +558,47 @@ function RoomNavItem_({ /> )} - - - {roomName} - - {hasLocalName && ( - - )} - {isFavorite && ( - + + + + {roomName} + + {hasLocalName && ( + + )} + {isFavorite && ( + + )} + + {dmPreview && ( + + + {dmPreview.preview} + + + {dmPreview.time} + + )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx new file mode 100644 index 000000000..c008854d2 --- /dev/null +++ b/src/app/features/room/MediaGallery.tsx @@ -0,0 +1,334 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Box, + Button, + Header, + Icon, + IconButton, + Icons, + Scroll, + Spinner, + Text, + Tooltip, + TooltipProvider, + config, +} from 'folds'; +import { Direction, EventType, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { mxcUrlToHttp } from '../../utils/matrix'; +import { ContainerColor } from '../../styles/ContainerColor.css'; + +type GalleryTab = 'image' | 'video' | 'file'; + +type MediaGalleryProps = { + room: Room; + onClose: () => void; +}; + +const TAB_LABELS: Record = { + image: 'Images', + video: 'Videos', + file: 'Files', +}; + +const TAB_MSGTYPES: Record = { + image: MsgType.Image, + video: MsgType.Video, + file: MsgType.File, +}; + +function TabButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function MediaGallery({ room, onClose }: MediaGalleryProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const [tab, setTab] = useState('image'); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [paginationToken, setPaginationToken] = useState(null); + + const msgtype = TAB_MSGTYPES[tab]; + + const loadMedia = useCallback( + async (fromToken: string | null, append: boolean) => { + setLoading(true); + try { + const response = await mx.createMessagesRequest( + room.roomId, + fromToken, + 100, + Direction.Backward, + undefined, + ); + const { end, chunk } = response; + const filtered = chunk + .filter( + (ev) => + ev.type === EventType.RoomMessage && + ev.content?.msgtype === msgtype && + !ev.unsigned?.redacted_because, + ) + .map((ev) => new MatrixEvent(ev)); + + setEvents((prev) => (append ? [...prev, ...filtered] : filtered)); + setPaginationToken(end ?? null); + } catch { + // silently swallow fetch errors — gallery stays showing what it has + } finally { + setLoading(false); + } + }, + [mx, room.roomId, msgtype], + ); + + useEffect(() => { + setEvents([]); + setPaginationToken(null); + loadMedia(null, false).catch(() => undefined); + }, [loadMedia]); + + const handleLoadMore = () => { + if (paginationToken) loadMedia(paginationToken, true).catch(() => undefined); + }; + + return ( + + {/* Header */} +
+ + + + + Media + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + +
+ + {/* Tab bar */} + + {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( + setTab(t)} /> + ))} + + + {/* Content */} + + + + {loading && events.length === 0 && ( + + + + )} + + {!loading && events.length === 0 && ( + + + {`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`} + + + )} + + {/* Image/Video grid */} + {(tab === 'image' || tab === 'video') && events.length > 0 && ( +
+ {events.map((mEvent) => { + const content = mEvent.getContent(); + const mxcUrl: string | undefined = content.url ?? content.file?.url; + if (!mxcUrl) return null; + const thumbUrl = + mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? ''; + const body: string = content.body ?? ''; + return ( + + {body} + + ); + })} +
+ )} + + {/* File list */} + {tab === 'file' && events.length > 0 && ( + + {events.map((mEvent) => { + const content = mEvent.getContent(); + const mxcUrl: string | undefined = content.url ?? content.file?.url; + const body: string = content.body ?? 'Unnamed file'; + const downloadUrl = mxcUrl + ? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#') + : '#'; + return ( + + + + + {body} + + + { + const anchor = document.createElement('a'); + anchor.href = downloadUrl; + anchor.download = body; + anchor.target = '_blank'; + anchor.rel = 'noreferrer'; + anchor.click(); + }} + > + + + + ); + })} + + )} + + {/* Load more */} + {paginationToken !== null && !loading && ( + + + + )} + + {/* Loading more spinner */} + {loading && events.length > 0 && ( + + + + )} +
+
+
+
+ ); +} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index e5a320b33..a4ad996ad 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -10,6 +10,7 @@ import { Avatar, Badge, Box, + Button, Chip, Header, Icon, @@ -29,6 +30,7 @@ import { import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; +import { Membership } from '../../../types/matrix/room'; import * as css from './MembersDrawer.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -51,7 +53,11 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter'; import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort'; -import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { + readPowerLevel, + useGetMemberPowerLevel, + usePowerLevelsContext, +} from '../../hooks/usePowerLevels'; import { MembershipFilterMenu } from '../../components/MembershipFilterMenu'; import { MemberSortMenu } from '../../components/MemberSortMenu'; import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile'; @@ -225,6 +231,15 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const typingMembers = useRoomTypingMember(room.roomId); + const myUserId = mx.getUserId(); + const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined); + const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite'); + const canApproveKnock = myPowerLevel >= invitePowerLevel; + const knockMembers = useMemo( + () => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []), + [room, canApproveKnock], + ); + const filteredMembers = useMemo( () => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort), [members, membershipFilter, memberSort, memberPowerSort], @@ -392,6 +407,78 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { + {knockMembers.length > 0 && ( + + + Pending Requests + + {knockMembers.map((knockMember) => { + const knockName = + getMemberDisplayName(room, knockMember.userId) ?? + getMxIdLocalPart(knockMember.userId) ?? + knockMember.userId; + const knockAvatarMxc = knockMember.getMxcAvatarUrl(); + const knockAvatarUrl = knockAvatarMxc + ? mx.mxcUrlToHttp( + knockAvatarMxc, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication, + ) + : undefined; + return ( + + + } + /> + + + + {knockName} + + + + + + + + ); + })} + + )} + {!fetchingMembers && !result && processMembers.length === 0 && ( {`No "${membershipFilter.name}" Members`} diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index acc3f8e81..8d96c1c78 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { webRTCSupported } from '../../utils/rtc'; +import { MediaGallery } from './MediaGallery'; type RoomMenuProps = { room: Room; @@ -431,6 +432,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { : undefined; const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [galleryOpen, setGalleryOpen] = useState(false); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -461,258 +463,284 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { }; return ( - - - {screenSize === ScreenSize.Mobile && ( - - {(onBack) => ( - - - - - - )} - - )} - - {screenSize !== ScreenSize.Mobile && ( - - ( - - )} - /> - + <> + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + + + )} + )} - - - - {name} - - {room.getType() === 'm.server_notice' && ( - - System messages from your homeserver administrator. - - } - > - {(triggerRef) => ( - - Server Notice - + + {screenSize !== ScreenSize.Mobile && ( + + ( + )} - + /> + + )} + + + + {name} + + {room.getType() === 'm.server_notice' && ( + + System messages from your homeserver administrator. + + } + > + {(triggerRef) => ( + + Server Notice + + )} + + )} + + {topic && ( + + {(viewTopic, setViewTopic) => ( + <> + }> + + setViewTopic(false), + escapeDeactivates: stopPropagation, + }} + > + setViewTopic(false)} + /> + + + + setViewTopic(true)} + className={css.HeaderTopic} + size="T200" + priority="300" + truncate + > + {topic.topic} + + + )} + )} - {topic && ( - - {(viewTopic, setViewTopic) => ( - <> - }> - - setViewTopic(false), - escapeDeactivates: stopPropagation, - }} - > - setViewTopic(false)} - /> - - - - setViewTopic(true)} - className={css.HeaderTopic} - size="T200" - priority="300" - truncate - > - {topic.topic} - - + + + + {!encryptedRoom && ( + + Search + + } + > + {(triggerRef) => ( + + + )} - + )} + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + + + )} + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} /> + + } + /> + {!room.isCallRoom() && + livekitSupported && + rtcSupported && + hasCallPermission && + (direct || + (room.getJoinRule() === 'invite' && + getStateEvents(room, StateEvent.SpaceParent).length === 0)) && } + {screenSize === ScreenSize.Desktop && ( + + {galleryOpen ? 'Hide Gallery' : 'Media Gallery'} + + } + > + {(triggerRef) => ( + setGalleryOpen(!galleryOpen)} + aria-label="Toggle media gallery" + aria-pressed={galleryOpen} + > + + + )} + + )} + {screenSize === ScreenSize.Desktop && ( + + {callView ? ( + Members + ) : ( + {peopleDrawer ? 'Hide Members' : 'Show Members'} + )} + + } + > + {(triggerRef) => ( + + + + )} + + )} + + + More Options + + } + > + {(triggerRef) => ( + + + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> - - - {!encryptedRoom && ( - - Search - - } - > - {(triggerRef) => ( - - - - )} - - )} - - Pinned Messages - - } - > - {(triggerRef) => ( - - {pinnedEvents.length > 0 && ( - - - {pinnedEvents.length} - - - )} - - - )} - - setPinMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setPinMenuAnchor(undefined)} /> - - } - /> - {!room.isCallRoom() && - livekitSupported && - rtcSupported && - hasCallPermission && - (direct || - (room.getJoinRule() === 'invite' && - getStateEvents(room, StateEvent.SpaceParent).length === 0)) && } - {screenSize === ScreenSize.Desktop && ( - - {callView ? ( - Members - ) : ( - {peopleDrawer ? 'Hide Members' : 'Show Members'} - )} - - } - > - {(triggerRef) => ( - - - - )} - - )} - - - More Options - - } - > - {(triggerRef) => ( - - - - )} - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - - + + {galleryOpen && setGalleryOpen(false)} />} + ); } diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index e1d078577..3449d3e99 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -46,7 +46,7 @@ export const ButterTheme: Theme = { export const LotusTerminalTheme: Theme = { id: 'lotus-terminal-theme', kind: ThemeKind.Dark, - classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-tds-dark'], }; export const LotusTerminalLightTheme: Theme = { id: 'lotus-terminal-light-theme', @@ -55,7 +55,7 @@ export const LotusTerminalLightTheme: Theme = { 'lotus-terminal-light-theme', lotusTerminalLightTheme, onLightFontWeight, - 'prism-light', + 'prism-tds-light', ], }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 8e0f33008..1d187d237 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from 'jotai'; -import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; @@ -27,6 +27,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePresenceUpdater } from '../../hooks/usePresenceUpdater'; +import { QuickSwitcher } from '../../components/QuickSwitcher'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -261,6 +262,26 @@ function MessageNotifications() { ); } +function QuickSwitcherFeature() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setOpen(true); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + if (!open) return null; + return setOpen(false)} />; +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -274,6 +295,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 3eac6b2c8..31502a9bf 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -42,9 +42,42 @@ import { import { onEnterOrSpace } from '../utils/keyboard'; import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom'; import { useTimeoutToggle } from '../hooks/useTimeoutToggle'; +import { tokenize, tokenStyle } from '../utils/syntaxHighlight'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); +/** Languages handled by the custom TDS tokenizer. */ +const TDS_TOKENIZER_LANGS = new Set([ + 'js', + 'javascript', + 'ts', + 'typescript', + 'jsx', + 'tsx', + 'py', + 'python', + 'rs', + 'rust', +]); + +/** + * Renders a code string as an array of coloured elements using the + * lightweight TDS tokenizer. Falls back to a plain text node when the + * language is not in the supported set. + */ +function renderTokenizedCode(code: string, lang: string): React.ReactNode { + const normalised = lang.toLowerCase().replace(/^language-/, ''); + if (!TDS_TOKENIZER_LANGS.has(normalised)) return code; + + const tokens = tokenize(code, normalised); + + return tokens.map((tok, idx) => ( + + {tok.text} + + )); +} + const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g'); export const LINKIFY_OPTS: LinkifyOpts = { @@ -420,6 +453,18 @@ export const getReactCustomHtmlParser = ( if (lang === 'language-rs') lang = 'language-rust'; else if (lang === 'language-js') lang = 'language-javascript'; else if (lang === 'language-ts') lang = 'language-typescript'; + + // Use lightweight TDS tokenizer for supported languages to render + // coloured elements with inline TDS CSS variable styles. + const normLang = (lang ?? '').toLowerCase().replace(/^language-/, ''); + if (TDS_TOKENIZER_LANGS.has(normLang)) { + return ( + + {renderTokenizedCode(codeReact, normLang)} + + ); + } + return ( {codeReact}}> {codeReact}}> diff --git a/src/app/plugins/react-prism/ReactPrism.css b/src/app/plugins/react-prism/ReactPrism.css index e6a121771..319a437dd 100644 --- a/src/app/plugins/react-prism/ReactPrism.css +++ b/src/app/plugins/react-prism/ReactPrism.css @@ -22,6 +22,54 @@ --prism-regex: #fd971f; } +/* ── Lotus Terminal Design System syntax theme ───────────────────────────── + Applied when the lotus-terminal-theme body class is active. + Maps Prism token roles to TDS accent variables: + keyword → --lt-accent-cyan (language control flow) + selector → --lt-accent-green (strings / inserted text) + boolean → --lt-accent-orange (numbers / booleans) + atrule → --lt-accent-purple (functions / class names) + comment → dimmed italic (comments use opacity) + property → --lt-accent-orange (properties / tags) + regex → --lt-accent-amber (regex / important) + ─────────────────────────────────────────────────────────────────────── */ +.prism-tds-dark { + --prism-comment: rgba(0, 255, 136, 0.4); + --prism-punctuation: rgba(196, 217, 238, 0.65); + --prism-property: var(--lt-accent-orange, #ff6b00); + --prism-boolean: var(--lt-accent-orange, #ff6b00); + --prism-selector: var(--lt-accent-green, #00ff88); + --prism-operator: rgba(196, 217, 238, 0.8); + --prism-atrule: var(--lt-accent-purple, #bf5fff); + --prism-keyword: var(--lt-accent-cyan, #00d4ff); + --prism-regex: var(--lt-accent-amber, #ffb300); +} + +.prism-tds-light { + --prism-comment: rgba(0, 109, 53, 0.55); + --prism-punctuation: rgba(45, 61, 86, 0.7); + --prism-property: var(--lt-accent-orange, #c44e00); + --prism-boolean: var(--lt-accent-orange, #c44e00); + --prism-selector: var(--lt-accent-green, #006d35); + --prism-operator: rgba(45, 61, 86, 0.85); + --prism-atrule: var(--lt-accent-purple, #6b2fb8); + --prism-keyword: var(--lt-accent-cyan, #0062b8); + --prism-regex: var(--lt-accent-amber, #8a5a00); +} + +/* Comment tokens get italic treatment in TDS themes */ +.prism-tds-dark code .token.comment, +.prism-tds-dark code .token.prolog, +.prism-tds-dark code .token.doctype, +.prism-tds-dark code .token.cdata, +.prism-tds-light code .token.comment, +.prism-tds-light code .token.prolog, +.prism-tds-light code .token.doctype, +.prism-tds-light code .token.cdata { + font-style: italic; + opacity: 0.65; +} + code .token.comment, code .token.prolog, code .token.doctype, diff --git a/src/app/utils/syntaxHighlight.ts b/src/app/utils/syntaxHighlight.ts new file mode 100644 index 000000000..1c69f7577 --- /dev/null +++ b/src/app/utils/syntaxHighlight.ts @@ -0,0 +1,325 @@ +/** + * Lightweight syntax tokenizer for code blocks. + * + * Returns an array of {text, type} tokens that can be rendered as + * coloured elements using TDS (Lotus Terminal Design System) + * CSS custom properties via inline styles. + * + * Supported token types: + * 'kw' → keywords → var(--lt-accent-cyan) + * 'str' → strings → var(--lt-accent-green) + * 'num' → numbers → var(--lt-accent-orange) + * 'cmt' → comments → opacity 0.5, fontStyle italic + * 'fn' → function names → var(--lt-accent-purple) + * 'plain' → everything else → inherit + * + * Supported languages: javascript / typescript / python / rust (and aliases). + */ + +import type { CSSProperties } from 'react'; + +export type SyntaxToken = { + text: string; + type: 'kw' | 'str' | 'num' | 'cmt' | 'fn' | 'plain'; +}; + +// ── Language keyword sets ────────────────────────────────────────────────── + +const JS_KEYWORDS = new Set([ + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'from', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'let', + 'new', + 'null', + 'of', + 'return', + 'static', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'undefined', + 'var', + 'void', + 'while', + 'with', + 'yield', + 'async', + 'await', + 'type', + 'interface', + 'enum', + 'declare', + 'abstract', + 'as', + 'namespace', + 'module', + 'readonly', +]); + +const PYTHON_KEYWORDS = new Set([ + 'False', + 'None', + 'True', + 'and', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'class', + 'continue', + 'def', + 'del', + 'elif', + 'else', + 'except', + 'finally', + 'for', + 'from', + 'global', + 'if', + 'import', + 'in', + 'is', + 'lambda', + 'nonlocal', + 'not', + 'or', + 'pass', + 'raise', + 'return', + 'try', + 'while', + 'with', + 'yield', +]); + +const RUST_KEYWORDS = new Set([ + 'as', + 'async', + 'await', + 'break', + 'const', + 'continue', + 'crate', + 'dyn', + 'else', + 'enum', + 'extern', + 'false', + 'fn', + 'for', + 'if', + 'impl', + 'in', + 'let', + 'loop', + 'match', + 'mod', + 'move', + 'mut', + 'pub', + 'ref', + 'return', + 'self', + 'Self', + 'static', + 'struct', + 'super', + 'trait', + 'true', + 'type', + 'union', + 'unsafe', + 'use', + 'where', + 'while', +]); + +function getKeywords(lang: string): Set { + const l = lang.toLowerCase(); + if (l === 'python' || l === 'py') return PYTHON_KEYWORDS; + if (l === 'rust' || l === 'rs') return RUST_KEYWORDS; + // js / ts / jsx / tsx and friends + return JS_KEYWORDS; +} + +// ── Tokenizer ────────────────────────────────────────────────────────────── + +/** + * Tokenises `code` for the given `lang` and returns an array of SyntaxToken + * objects. Falls back to a single 'plain' token when the language is not + * recognised or when `lang` is empty. + */ +export function tokenize(code: string, lang: string): SyntaxToken[] { + const normalised = lang.toLowerCase().replace(/^language-/, ''); + + const supported = [ + 'js', + 'javascript', + 'ts', + 'typescript', + 'jsx', + 'tsx', + 'py', + 'python', + 'rs', + 'rust', + ]; + if (!supported.includes(normalised)) { + return [{ text: code, type: 'plain' }]; + } + + const keywords = getKeywords(normalised); + const tokens: SyntaxToken[] = []; + let i = 0; + const len = code.length; + + while (i < len) { + // ── Block comment /* … */ ────────────────────────────────────────────── + if (code[i] === '/' && code[i + 1] === '*') { + const end = code.indexOf('*/', i + 2); + const closeIdx = end === -1 ? len : end + 2; + tokens.push({ text: code.slice(i, closeIdx), type: 'cmt' }); + i = closeIdx; + continue; + } + + // ── Line comment // … ────────────────────────────────────────────────── + if (code[i] === '/' && code[i + 1] === '/') { + const nl = code.indexOf('\n', i); + const closeIdx = nl === -1 ? len : nl; + tokens.push({ text: code.slice(i, closeIdx), type: 'cmt' }); + i = closeIdx; + continue; + } + + // ── Python / shell line comment # … ────────────────────────────────── + if ( + code[i] === '#' && + (normalised === 'python' || normalised === 'py') && + (i === 0 || code[i - 1] === '\n') + ) { + const nlHash = code.indexOf('\n', i); + const closeIdx = nlHash === -1 ? len : nlHash; + tokens.push({ text: code.slice(i, closeIdx), type: 'cmt' }); + i = closeIdx; + continue; + } + + // ── String literals (single, double, backtick) ───────────────────────── + const quote = code[i]; + if (quote === '"' || quote === "'" || quote === '`') { + let j = i + 1; + while (j < len) { + if (code[j] === '\\') { + j += 2; // skip escaped char + } else if (code[j] === quote) { + j += 1; + break; + } else if (quote !== '`' && code[j] === '\n') { + // unterminated single/double quote — stop at newline + break; + } else { + j += 1; + } + } + tokens.push({ text: code.slice(i, j), type: 'str' }); + i = j; + continue; + } + + // ── Numbers ──────────────────────────────────────────────────────────── + if (/\d/.test(code[i]) && (i === 0 || /\W/.test(code[i - 1]))) { + let j = i; + while (j < len && /[\d._xXbBoOeE]/.test(code[j])) j++; + tokens.push({ text: code.slice(i, j), type: 'num' }); + i = j; + continue; + } + + // ── Identifiers (keywords, function names, plain words) ─────────────── + if (/[a-zA-Z_$]/.test(code[i])) { + let j = i; + while (j < len && /[a-zA-Z0-9_$]/.test(code[j])) j++; + const word = code.slice(i, j); + + // Look ahead for `(` to detect function calls / definitions + let k = j; + while (k < len && (code[k] === ' ' || code[k] === '\t')) k++; + const isFunctionCall = code[k] === '('; + + if (keywords.has(word)) { + tokens.push({ text: word, type: 'kw' }); + } else if (isFunctionCall) { + tokens.push({ text: word, type: 'fn' }); + } else { + tokens.push({ text: word, type: 'plain' }); + } + i = j; + continue; + } + + // ── Everything else — collect a run of non-special chars ────────────── + const start = i; + while ( + i < len && + code[i] !== '/' && + code[i] !== '#' && + code[i] !== '"' && + code[i] !== "'" && + code[i] !== '`' && + !/[a-zA-Z0-9_$]/.test(code[i]) + ) { + i++; + } + if (i === start) i++; // safety: always advance + if (start < i) tokens.push({ text: code.slice(start, i), type: 'plain' }); + } + + return tokens; +} + +// ── Inline style helpers ──────────────────────────────────────────────────── + +/** Returns the React inline-style object for a given SyntaxToken type. */ +export function tokenStyle(type: SyntaxToken['type']): CSSProperties { + switch (type) { + case 'kw': + return { color: 'var(--lt-accent-cyan, #66d9ef)' }; + case 'str': + return { color: 'var(--lt-accent-green, #a6e22e)' }; + case 'num': + return { color: 'var(--lt-accent-orange, #fd971f)' }; + case 'cmt': + return { opacity: 0.5, fontStyle: 'italic' as const }; + case 'fn': + return { color: 'var(--lt-accent-purple, #ae81ff)' }; + default: + return {}; + } +}