From 6b81401e2da8c1d1e61d297d256dc06d8f4e599b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:30:30 +0530 Subject: [PATCH 01/10] fix room not opening when two rooms has same alias (#2387) --- src/app/utils/matrix.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index a495e8d5..4b695724 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -50,7 +50,11 @@ export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): str const room = mx.getRoom(roomId); if (!room) return roomId; if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId; - return room.getCanonicalAlias() || roomId; + const alias = room.getCanonicalAlias(); + if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) { + return alias; + } + return roomId; }; export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => { From fbd7e0a14b4e50c7af69815d57254f97233296a3 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:33:55 +0530 Subject: [PATCH 02/10] improve parent selection when opening a room (#2388) when a room has more than one orphan parent, we will select parent which has highest number of special users who have special powers in selected room. --- src/app/hooks/useRoomNavigate.ts | 24 +++++++++---------- src/app/utils/room.ts | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index e626c06b..b2d7a91a 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -9,7 +9,7 @@ import { getSpaceRoomPath, } from '../pages/pathUtils'; import { useMatrixClient } from './useMatrixClient'; -import { getOrphanParents } from '../utils/room'; +import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { mDirectAtom } from '../state/mDirectList'; import { useSelectedSpace } from './router/useSelectedSpace'; @@ -39,19 +39,19 @@ export const useRoomNavigate = () => { const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { - const pSpaceIdOrAlias = getCanonicalAliasOrRoomId( - mx, - spaceSelectedId && orphanParents.includes(spaceSelectedId) - ? spaceSelectedId - : orphanParents[0] // TODO: better orphan parent selection. - ); - - if (openSpaceTimeline) { - navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomId, eventId), opts); - return; + let parentSpace: string; + if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { + parentSpace = spaceSelectedId; + } else { + parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0]; } - navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); + const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); + + navigate( + getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), + opts + ); return; } diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 79dcff9e..cae23514 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -5,6 +5,7 @@ import { EventTimelineSet, EventType, IMentions, + IPowerLevelsContent, IPushRule, IPushRules, JoinRule, @@ -473,3 +474,43 @@ export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: st const banned = room.hasMembershipState(otherUserId, Membership.Ban); return banned; }); + +export const guessPerfectParent = ( + mx: MatrixClient, + roomId: string, + parents: string[] +): string | undefined => { + if (parents.length === 1) { + return parents[0]; + } + + const getSpecialUsers = (rId: string): string[] => { + const r = mx.getRoom(rId); + const powerLevels = + r && getStateEvent(r, StateEvent.RoomPowerLevels)?.getContent(); + + const { users_default: usersDefault, users } = powerLevels ?? {}; + if (typeof users !== 'object') return []; + + const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0; + return Object.keys(users).filter((userId) => users[userId] > defaultPower); + }; + + let perfectParent: string | undefined; + let score = 0; + + const roomSpecialUsers = getSpecialUsers(roomId); + parents.forEach((parentId) => { + const parentSpecialUsers = getSpecialUsers(parentId); + const matchedUsersCount = parentSpecialUsers.filter((userId) => + roomSpecialUsers.includes(userId) + ).length; + + if (matchedUsersCount > score) { + score = matchedUsersCount; + perfectParent = parentId; + } + }); + + return perfectParent; +}; From c30c142653c1c0b4d9f514ff86b01b2db2d4e4b3 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:03:45 +0530 Subject: [PATCH 03/10] Stop parsing servername from roomId (#2391) --- .../editor/autocomplete/RoomMentionAutocomplete.tsx | 4 ++-- .../editor/autocomplete/UserMentionAutocomplete.tsx | 4 ++-- src/app/molecules/space-add-existing/SpaceAddExisting.jsx | 6 +----- src/app/utils/matrix.ts | 6 +++--- src/client/action/room.js | 5 +---- 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index cc431f58..b0c64f60 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteMenu } from './AutocompleteMenu'; -import { getMxIdServer, validMxId } from '../../../utils/matrix'; +import { getMxIdServer, isRoomAlias } from '../../../utils/matrix'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; import { useKeyDown } from '../../../hooks/useKeyDown'; @@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers'; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; const roomAliasFromQueryText = (mx: MatrixClient, text: string) => - validMxId(`#${text}`) + isRoomAlias(`#${text}`) ? `#${text}` : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index d6c0f302..7a8012eb 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -15,7 +15,7 @@ import { import { onTabPress } from '../../../utils/keyboard'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { useKeyDown } from '../../../hooks/useKeyDown'; -import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix'; +import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { UserAvatar } from '../../user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; @@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room'; type MentionAutoCompleteHandler = (userId: string, name: string) => void; const userIdFromQueryText = (mx: MatrixClient, text: string) => - validMxId(`@${text}`) + isUserId(`@${text}`) ? `@${text}` : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; diff --git a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx index b084a1ad..05b8d85f 100644 --- a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx +++ b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx @@ -5,7 +5,7 @@ import './SpaceAddExisting.scss'; import cons from '../../../client/state/cons'; import navigation from '../../../client/state/navigation'; -import { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil'; +import { joinRuleToIconSrc } from '../../../util/matrixUtil'; import { Debounce } from '../../../util/common'; import Text from '../../atoms/text/Text'; @@ -21,7 +21,6 @@ import Dialog from '../dialog/Dialog'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import SearchIC from '../../../../public/res/ic/outlined/search.svg'; -import { useStore } from '../../hooks/useStore'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList'; @@ -73,9 +72,6 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) { await rateLimitedActions(selected, async (rId) => { const room = mx.getRoom(rId); const via = getViaServers(room); - if (via.length === 0) { - via.push(getIdServer(rId)); - } await mx.sendStateEvent( roomId, diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4b695724..610ef0af 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -23,9 +23,9 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); -export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/); +const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])(.+):(\S+)$/); -export const validMxId = (id: string): boolean => !!matchMxId(id); +const validMxId = (id: string): boolean => !!matchMxId(id); export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3]; @@ -33,7 +33,7 @@ export const getMxIdLocalPart = (userId: string): string | undefined => matchMxI export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); -export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith('!'); +export const isRoomId = (id: string): boolean => id.startsWith('!'); export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); diff --git a/src/client/action/room.js b/src/client/action/room.js index 767914b5..e39aeed8 100644 --- a/src/client/action/room.js +++ b/src/client/action/room.js @@ -93,11 +93,8 @@ function convertToRoom(mx, roomId) { * @param {string[]} via */ async function join(mx, roomIdOrAlias, isDM = false, via = undefined) { - const roomIdParts = roomIdOrAlias.split(':'); - const viaServers = via || [roomIdParts[1]]; - try { - const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers }); + const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers: via }); if (isDM) { const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId()); From c462a3b8d5f9948eed3de76c1c3180d41a00fbdc Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:10:16 +0530 Subject: [PATCH 04/10] Link device account management with OIDC (#2390) * load auth metadata configs on startup * deep-link cross-signing reset button with oidc * deep-link manage devices and delete device with oidc * fix import typo --- .../CapabilitiesAndMediaConfigLoader.tsx | 36 --------- src/app/components/ServerConfigsLoader.tsx | 52 ++++++++++++ .../settings/devices/OtherDevices.tsx | 80 +++++++++++++++++-- .../settings/devices/Verification.tsx | 17 ++++ src/app/hooks/useAccountManagement.ts | 17 ++++ src/app/hooks/useAuthMetadata.ts | 12 +++ src/app/pages/client/ClientRoot.tsx | 23 +++--- 7 files changed, 185 insertions(+), 52 deletions(-) delete mode 100644 src/app/components/CapabilitiesAndMediaConfigLoader.tsx create mode 100644 src/app/components/ServerConfigsLoader.tsx create mode 100644 src/app/hooks/useAccountManagement.ts create mode 100644 src/app/hooks/useAuthMetadata.ts diff --git a/src/app/components/CapabilitiesAndMediaConfigLoader.tsx b/src/app/components/CapabilitiesAndMediaConfigLoader.tsx deleted file mode 100644 index 574d0ca7..00000000 --- a/src/app/components/CapabilitiesAndMediaConfigLoader.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { ReactNode, useCallback, useEffect } from 'react'; -import { Capabilities } from 'matrix-js-sdk'; -import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useMatrixClient } from '../hooks/useMatrixClient'; -import { MediaConfig } from '../hooks/useMediaConfig'; -import { promiseFulfilledResult } from '../utils/common'; - -type CapabilitiesAndMediaConfigLoaderProps = { - children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode; -}; -export function CapabilitiesAndMediaConfigLoader({ - children, -}: CapabilitiesAndMediaConfigLoaderProps) { - const mx = useMatrixClient(); - - const [state, load] = useAsyncCallback< - [Capabilities | undefined, MediaConfig | undefined], - unknown, - [] - >( - useCallback(async () => { - const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]); - const capabilities = promiseFulfilledResult(result[0]); - const mediaConfig = promiseFulfilledResult(result[1]); - return [capabilities, mediaConfig]; - }, [mx]) - ); - - useEffect(() => { - load(); - }, [load]); - - const [capabilities, mediaConfig] = - state.status === AsyncStatus.Success ? state.data : [undefined, undefined]; - return children(capabilities, mediaConfig); -} diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx new file mode 100644 index 00000000..3c8ce8eb --- /dev/null +++ b/src/app/components/ServerConfigsLoader.tsx @@ -0,0 +1,52 @@ +import { ReactNode, useCallback, useMemo } from 'react'; +import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { MediaConfig } from '../hooks/useMediaConfig'; +import { promiseFulfilledResult } from '../utils/common'; + +export type ServerConfigs = { + capabilities?: Capabilities; + mediaConfig?: MediaConfig; + authMetadata?: ValidatedAuthMetadata; +}; + +type ServerConfigsLoaderProps = { + children: (configs: ServerConfigs) => ReactNode; +}; +export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { + const mx = useMatrixClient(); + const fallbackConfigs = useMemo(() => ({}), []); + + const [configsState] = useAsyncCallbackValue( + useCallback(async () => { + const result = await Promise.allSettled([ + mx.getCapabilities(), + mx.getMediaConfig(), + mx.getAuthMetadata(), + ]); + + const capabilities = promiseFulfilledResult(result[0]); + const mediaConfig = promiseFulfilledResult(result[1]); + const authMetadata = promiseFulfilledResult(result[2]); + let validatedAuthMetadata: ValidatedAuthMetadata | undefined; + + try { + validatedAuthMetadata = validateAuthMetadata(authMetadata); + } catch (e) { + console.error(e); + } + + return { + capabilities, + mediaConfig, + authMetadata: validatedAuthMetadata, + }; + }, [mx]) + ); + + const configs: ServerConfigs = + configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs; + + return children(configs); +} diff --git a/src/app/features/settings/devices/OtherDevices.tsx b/src/app/features/settings/devices/OtherDevices.tsx index 0d879e59..4bd83dd6 100644 --- a/src/app/features/settings/devices/OtherDevices.tsx +++ b/src/app/features/settings/devices/OtherDevices.tsx @@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows'; import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus'; import { VerifyOtherDeviceTile } from './Verification'; import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus'; +import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; +import { withSearchParam } from '../../../pages/pathUtils'; +import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; +import { SettingTile } from '../../../components/setting-tile'; type OtherDevicesProps = { devices: IMyDevice[]; @@ -20,8 +24,39 @@ type OtherDevicesProps = { export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) { const mx = useMatrixClient(); const crypto = mx.getCrypto(); + const authMetadata = useAuthMetadata(); + const accountManagementActions = useAccountManagementActions(); + const [deleted, setDeleted] = useState>(new Set()); + const handleDashboardOIDC = useCallback(() => { + const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer; + if (!authUrl) return; + + window.open( + withSearchParam(authUrl, { + action: accountManagementActions.sessionsList, + }), + '_blank' + ); + }, [authMetadata, accountManagementActions]); + + const handleDeleteOIDC = useCallback( + (deviceId: string) => { + const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer; + if (!authUrl) return; + + window.open( + withSearchParam(authUrl, { + action: accountManagementActions.sessionEnd, + device_id: deviceId, + }), + '_blank' + ); + }, + [authMetadata, accountManagementActions] + ); + const handleToggleDelete = useCallback((deviceId: string) => { setDeleted((deviceIds) => { const newIds = new Set(deviceIds); @@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O <> Others + {authMetadata && ( + + + Open + + } + /> + + )} {devices .sort((d1, d2) => { if (!d1.last_seen_ts || !d2.last_seen_ts) return 0; @@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O refreshDeviceList={refreshDeviceList} disabled={deleting} options={ - + authMetadata ? ( + + ) : ( + + ) } /> {showVerification && crypto && ( diff --git a/src/app/features/settings/devices/Verification.tsx b/src/app/features/settings/devices/Verification.tsx index 59fa6b67..6c7eab17 100644 --- a/src/app/features/settings/devices/Verification.tsx +++ b/src/app/features/settings/devices/Verification.tsx @@ -32,6 +32,9 @@ import { DeviceVerificationSetup, } from '../../../components/DeviceVerificationSetup'; import { stopPropagation } from '../../../utils/keyboard'; +import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; +import { withSearchParam } from '../../../pages/pathUtils'; +import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; type VerificationStatusBadgeProps = { verificationStatus: VerificationStatus; @@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) { export function DeviceVerificationOptions() { const [menuCords, setMenuCords] = useState(); + const authMetadata = useAuthMetadata(); + const accountManagementActions = useAccountManagementActions(); const [reset, setReset] = useState(false); @@ -265,6 +270,18 @@ export function DeviceVerificationOptions() { const handleReset = () => { setMenuCords(undefined); + + if (authMetadata) { + const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer; + window.open( + withSearchParam(authUrl, { + action: accountManagementActions.crossSigningReset, + }), + '_blank' + ); + return; + } + setReset(true); }; diff --git a/src/app/hooks/useAccountManagement.ts b/src/app/hooks/useAccountManagement.ts new file mode 100644 index 00000000..5eafedc4 --- /dev/null +++ b/src/app/hooks/useAccountManagement.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; + +export const useAccountManagementActions = () => { + const actions = useMemo( + () => ({ + profile: 'org.matrix.profile', + sessionsList: 'org.matrix.sessions_list', + sessionView: 'org.matrix.session_view', + sessionEnd: 'org.matrix.session_end', + accountDeactivate: 'org.matrix.account_deactivate', + crossSigningReset: 'org.matrix.cross_signing_reset', + }), + [] + ); + + return actions; +}; diff --git a/src/app/hooks/useAuthMetadata.ts b/src/app/hooks/useAuthMetadata.ts new file mode 100644 index 00000000..db967463 --- /dev/null +++ b/src/app/hooks/useAuthMetadata.ts @@ -0,0 +1,12 @@ +import { ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { createContext, useContext } from 'react'; + +const AuthMetadataContext = createContext(undefined); + +export const AuthMetadataProvider = AuthMetadataContext.Provider; + +export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => { + const metadata = useContext(AuthMetadataContext); + + return metadata; +}; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 846d8ff3..c48dbf53 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -25,7 +25,7 @@ import { } from '../../../client/initMatrix'; import { getSecret } from '../../../client/state/auth'; import { SplashScreen } from '../../components/splash-screen'; -import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader'; +import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { MediaConfigProvider } from '../../hooks/useMediaConfig'; import { MatrixClientProvider } from '../../hooks/useMatrixClient'; @@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; +import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; function ClientRootLoading() { return ( @@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) { ) : ( - - {(capabilities, mediaConfig) => ( - - - {children} - - - + + {(serverConfigs) => ( + + + + {children} + + + + )} - + )} From 50cc78788f8b1da50898b9863fdba9b714550e52 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:11:33 +0530 Subject: [PATCH 05/10] Jump to time option in room timeline (#2377) * add time and date picker components * add time utils * add jump to time in room timeline * fix typo causing crash in safari --- src/app/components/time-date/DatePicker.tsx | 129 +++++++++ src/app/components/time-date/PickerColumn.tsx | 23 ++ src/app/components/time-date/TimePicker.tsx | 132 +++++++++ src/app/components/time-date/index.ts | 2 + src/app/components/time-date/styles.css.ts | 16 ++ src/app/features/room/RoomViewHeader.tsx | 30 ++ .../features/room/jump-to-time/JumpToTime.tsx | 256 ++++++++++++++++++ src/app/features/room/jump-to-time/index.ts | 1 + src/app/utils/time.ts | 48 ++++ 9 files changed, 637 insertions(+) create mode 100644 src/app/components/time-date/DatePicker.tsx create mode 100644 src/app/components/time-date/PickerColumn.tsx create mode 100644 src/app/components/time-date/TimePicker.tsx create mode 100644 src/app/components/time-date/index.ts create mode 100644 src/app/components/time-date/styles.css.ts create mode 100644 src/app/features/room/jump-to-time/JumpToTime.tsx create mode 100644 src/app/features/room/jump-to-time/index.ts diff --git a/src/app/components/time-date/DatePicker.tsx b/src/app/components/time-date/DatePicker.tsx new file mode 100644 index 00000000..faa43a3f --- /dev/null +++ b/src/app/components/time-date/DatePicker.tsx @@ -0,0 +1,129 @@ +import React, { forwardRef } from 'react'; +import { Menu, Box, Text, Chip } from 'folds'; +import dayjs from 'dayjs'; +import * as css from './styles.css'; +import { PickerColumn } from './PickerColumn'; +import { dateFor, daysInMonth, daysToMs } from '../../utils/time'; + +type DatePickerProps = { + min: number; + max: number; + value: number; + onChange: (value: number) => void; +}; +export const DatePicker = forwardRef( + ({ min, max, value, onChange }, ref) => { + const selectedYear = dayjs(value).year(); + const selectedMonth = dayjs(value).month() + 1; + const selectedDay = dayjs(value).date(); + + const handleSubmit = (newValue: number) => { + onChange(Math.min(Math.max(min, newValue), max)); + }; + + const handleDay = (day: number) => { + const seconds = daysToMs(day); + const lastSeconds = daysToMs(selectedDay); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const handleMonthAndYear = (month: number, year: number) => { + const mDays = daysInMonth(month, year); + const currentDate = dateFor(selectedYear, selectedMonth, selectedDay); + const time = value - currentDate; + + const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay); + + const newValue = newDate + time; + handleSubmit(newValue); + }; + + const handleMonth = (month: number) => { + handleMonthAndYear(month, selectedYear); + }; + + const handleYear = (year: number) => { + handleMonthAndYear(selectedMonth, year); + }; + + const minYear = dayjs(min).year(); + const maxYear = dayjs(max).year(); + const yearsRange = maxYear - minYear + 1; + + const minMonth = dayjs(min).month() + 1; + const maxMonth = dayjs(max).month() + 1; + + const minDay = dayjs(min).date(); + const maxDay = dayjs(max).date(); + return ( + + + + {Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys()) + .map((i) => i + 1) + .map((day) => ( + handleDay(day)} + disabled={ + (selectedYear === minYear && selectedMonth === minMonth && day < minDay) || + (selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay) + } + > + {day} + + ))} + + + {Array.from(Array(12).keys()) + .map((i) => i + 1) + .map((month) => ( + handleMonth(month)} + disabled={ + (selectedYear === minYear && month < minMonth) || + (selectedYear === maxYear && month > maxMonth) + } + > + + {dayjs() + .month(month - 1) + .format('MMM')} + + + ))} + + + {Array.from(Array(yearsRange).keys()) + .map((i) => minYear + i) + .map((year) => ( + handleYear(year)} + > + {year} + + ))} + + + + ); + } +); diff --git a/src/app/components/time-date/PickerColumn.tsx b/src/app/components/time-date/PickerColumn.tsx new file mode 100644 index 00000000..c31daf43 --- /dev/null +++ b/src/app/components/time-date/PickerColumn.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react'; +import { Box, Text, Scroll } from 'folds'; +import { CutoutCard } from '../cutout-card'; +import * as css from './styles.css'; + +export function PickerColumn({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + + + + + {children} + + + + + + ); +} diff --git a/src/app/components/time-date/TimePicker.tsx b/src/app/components/time-date/TimePicker.tsx new file mode 100644 index 00000000..1dd0958b --- /dev/null +++ b/src/app/components/time-date/TimePicker.tsx @@ -0,0 +1,132 @@ +import React, { forwardRef } from 'react'; +import { Menu, Box, Text, Chip } from 'folds'; +import dayjs from 'dayjs'; +import * as css from './styles.css'; +import { PickerColumn } from './PickerColumn'; +import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time'; + +type TimePickerProps = { + min: number; + max: number; + value: number; + onChange: (value: number) => void; +}; +export const TimePicker = forwardRef( + ({ min, max, value, onChange }, ref) => { + const hour24 = dayjs(value).hour(); + + const selectedHour = hour24to12(hour24); + const selectedMinute = dayjs(value).minute(); + const selectedPM = hour24 >= 12; + + const handleSubmit = (newValue: number) => { + onChange(Math.min(Math.max(min, newValue), max)); + }; + + const handleHour = (hour: number) => { + const seconds = hoursToMs(hour12to24(hour, selectedPM)); + const lastSeconds = hoursToMs(hour24); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const handleMinute = (minute: number) => { + const seconds = minutesToMs(minute); + const lastSeconds = minutesToMs(selectedMinute); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const handlePeriod = (pm: boolean) => { + const seconds = hoursToMs(hour12to24(selectedHour, pm)); + const lastSeconds = hoursToMs(hour24); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const minHour24 = dayjs(min).hour(); + const maxHour24 = dayjs(max).hour(); + + const minMinute = dayjs(min).minute(); + const maxMinute = dayjs(max).minute(); + const minPM = minHour24 >= 12; + const maxPM = maxHour24 >= 12; + + const minDay = inSameDay(min, value); + const maxDay = inSameDay(max, value); + + return ( + + + + {Array.from(Array(12).keys()) + .map((i) => { + if (i === 0) return 12; + return i; + }) + .map((hour) => ( + handleHour(hour)} + disabled={ + (minDay && hour12to24(hour, selectedPM) < minHour24) || + (maxDay && hour12to24(hour, selectedPM) > maxHour24) + } + > + {hour < 10 ? `0${hour}` : hour} + + ))} + + + {Array.from(Array(60).keys()).map((minute) => ( + handleMinute(minute)} + disabled={ + (minDay && hour24 === minHour24 && minute < minMinute) || + (maxDay && hour24 === maxHour24 && minute > maxMinute) + } + > + {minute < 10 ? `0${minute}` : minute} + + ))} + + + handlePeriod(false)} + disabled={minDay && minPM} + > + AM + + handlePeriod(true)} + disabled={maxDay && !maxPM} + > + PM + + + + + ); + } +); diff --git a/src/app/components/time-date/index.ts b/src/app/components/time-date/index.ts new file mode 100644 index 00000000..592c5af7 --- /dev/null +++ b/src/app/components/time-date/index.ts @@ -0,0 +1,2 @@ +export * from './TimePicker'; +export * from './DatePicker'; diff --git a/src/app/components/time-date/styles.css.ts b/src/app/components/time-date/styles.css.ts new file mode 100644 index 00000000..97926d3f --- /dev/null +++ b/src/app/components/time-date/styles.css.ts @@ -0,0 +1,16 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const PickerMenu = style({ + padding: config.space.S200, +}); +export const PickerContainer = style({ + maxHeight: toRem(250), +}); +export const PickerColumnLabel = style({ + padding: config.space.S200, +}); +export const PickerColumnContent = style({ + padding: config.space.S200, + paddingRight: 0, +}); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 352ae4b5..63e9d55d 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -65,6 +65,8 @@ import { getRoomNotificationModeIcon, useRoomsNotificationPreferencesContext, } from '../../hooks/useRoomsNotificationPreferences'; +import { JumpToTime } from './jump-to-time'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; type RoomMenuProps = { room: Room; @@ -79,6 +81,7 @@ const RoomMenu = forwardRef(({ room, requestClose const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); + const { navigateRoom } = useRoomNavigate(); const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); @@ -175,6 +178,33 @@ const RoomMenu = forwardRef(({ room, requestClose Room Settings + + {(promptJump, setPromptJump) => ( + <> + setPromptJump(true)} + size="300" + after={} + radii="300" + aria-pressed={promptJump} + > + + Jump to Time + + + {promptJump && ( + { + setPromptJump(false); + navigateRoom(room.roomId, eventId); + requestClose(); + }} + onCancel={() => setPromptJump(false)} + /> + )} + + )} + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx new file mode 100644 index 00000000..8c4e2c0b --- /dev/null +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -0,0 +1,256 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + config, + Box, + Text, + IconButton, + Icon, + Icons, + color, + Button, + Spinner, + Chip, + PopOut, + RectCords, +} from 'folds'; +import { Direction, MatrixError } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useAlive } from '../../../hooks/useAlive'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { useRoom } from '../../../hooks/useRoom'; +import { StateEvent } from '../../../../types/matrix/room'; +import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time'; +import { DatePicker, TimePicker } from '../../../components/time-date'; + +type JumpToTimeProps = { + onCancel: () => void; + onSubmit: (eventId: string) => void; +}; +export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const alive = useAlive(); + const createStateEvent = useStateEvent(room, StateEvent.RoomCreate); + + const todayTs = getToday(); + const yesterdayTs = getYesterday(); + const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]); + const [ts, setTs] = useState(() => Date.now()); + + const [timePickerCords, setTimePickerCords] = useState(); + const [datePickerCords, setDatePickerCords] = useState(); + + const handleTimePicker: MouseEventHandler = (evt) => { + setTimePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleDatePicker: MouseEventHandler = (evt) => { + setDatePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleToday = () => { + setTs(todayTs < createTs ? createTs : todayTs); + }; + const handleYesterday = () => { + setTs(yesterdayTs < createTs ? createTs : yesterdayTs); + }; + const handleBeginning = () => setTs(createTs); + + const [timestampState, timestampToEvent] = useAsyncCallback( + useCallback( + async (newTs) => { + const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward); + return result.event_id; + }, + [mx, room] + ) + ); + + const handleSubmit = () => { + timestampToEvent(ts).then((eventId) => { + if (alive()) { + onSubmit(eventId); + } + }); + }; + + return ( + }> + + + +
+ + Jump to Time + + + + +
+ + + + + Time + + + } + onClick={handleTimePicker} + > + {timeHourMinute(ts)} + + setTimePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + +
+ } + /> +
+ + + + Date + + + } + onClick={handleDatePicker} + > + {timeDayMonthYear(ts)} + + setDatePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + } + /> + + + + + Preset + + {createTs < todayTs && ( + + Today + + )} + {createTs < yesterdayTs && ( + + Yesterday + + )} + + Beginning + + + + {timestampState.status === AsyncStatus.Error && ( + + {timestampState.error.message} + + )} + + + + + + + ); +} diff --git a/src/app/features/room/jump-to-time/index.ts b/src/app/features/room/jump-to-time/index.ts new file mode 100644 index 00000000..9bdc2c74 --- /dev/null +++ b/src/app/features/room/jump-to-time/index.ts @@ -0,0 +1 @@ +export * from './JumpToTime'; diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index 3ee6720c..f230e59b 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -9,12 +9,26 @@ export const today = (ts: number): boolean => dayjs(ts).isToday(); export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday(); +export const timeHour = (ts: number): string => dayjs(ts).format('hh'); +export const timeMinute = (ts: number): string => dayjs(ts).format('mm'); +export const timeAmPm = (ts: number): string => dayjs(ts).format('A'); +export const timeDay = (ts: number): string => dayjs(ts).format('D'); +export const timeMon = (ts: number): string => dayjs(ts).format('MMM'); +export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM'); +export const timeYear = (ts: number): string => dayjs(ts).format('YYYY'); + export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A'); export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY'); export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY'); +export const daysInMonth = (month: number, year: number): number => + dayjs(`${year}-${month}-1`).daysInMonth(); + +export const dateFor = (year: number, month: number, day: number): number => + dayjs(`${year}-${month}-${day}`).valueOf(); + export const inSameDay = (ts1: number, ts2: number): boolean => { const dt1 = new Date(ts1); const dt2 = new Date(ts2); @@ -33,3 +47,37 @@ export const minuteDifference = (ts1: number, ts2: number): number => { diff /= 60; return Math.abs(Math.round(diff)); }; + +export const hour24to12 = (hour24: number): number => { + const h = hour24 % 12; + + if (h === 0) return 12; + return h; +}; + +export const hour12to24 = (hour: number, pm: boolean): number => { + if (hour === 12) { + return pm ? 12 : 0; + } + return pm ? hour + 12 : hour; +}; + +export const secondsToMs = (seconds: number) => seconds * 1000; + +export const minutesToMs = (minutes: number) => minutes * secondsToMs(60); + +export const hoursToMs = (hour: number) => hour * minutesToMs(60); + +export const daysToMs = (days: number) => days * hoursToMs(24); + +export const getToday = () => { + const nowTs = Date.now(); + const date = dayjs(nowTs); + return dateFor(date.year(), date.month() + 1, date.date()); +}; + +export const getYesterday = () => { + const nowTs = Date.now() - daysToMs(1); + const date = dayjs(nowTs); + return dateFor(date.year(), date.month() + 1, date.date()); +}; From acc7d4ff565fa6f8f0666920c5959f43be28d9ea Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:19:13 +0530 Subject: [PATCH 06/10] Support oidc action param for login and register page (#2389) --- src/app/pages/auth/SSOLogin.tsx | 8 +++++--- src/app/pages/auth/login/Login.tsx | 2 ++ src/app/pages/auth/register/Register.tsx | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/pages/auth/SSOLogin.tsx b/src/app/pages/auth/SSOLogin.tsx index d0cdaeb6..3ff1a229 100644 --- a/src/app/pages/auth/SSOLogin.tsx +++ b/src/app/pages/auth/SSOLogin.tsx @@ -1,19 +1,21 @@ import { Avatar, AvatarImage, Box, Button, Text } from 'folds'; -import { IIdentityProvider, createClient } from 'matrix-js-sdk'; +import { IIdentityProvider, SSOAction, createClient } from 'matrix-js-sdk'; import React, { useMemo } from 'react'; import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; type SSOLoginProps = { providers?: IIdentityProvider[]; redirectUrl: string; + action?: SSOAction; saveScreenSpace?: boolean; }; -export function SSOLogin({ providers, redirectUrl, saveScreenSpace }: SSOLoginProps) { +export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); - const getSSOIdUrl = (ssoId?: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId); + const getSSOIdUrl = (ssoId?: string): string => + mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action); const withoutIcon = providers ? providers.find( diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 6b9f1223..2f04a733 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { Box, Text, color } from 'folds'; import { Link, useSearchParams } from 'react-router-dom'; +import { SSOAction } from 'matrix-js-sdk'; import { useAuthFlows } from '../../../hooks/useAuthFlows'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows'; @@ -76,6 +77,7 @@ export function Login() { diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index d2986d70..7176489b 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { Box, Text, color } from 'folds'; import { Link, useSearchParams } from 'react-router-dom'; +import { SSOAction } from 'matrix-js-sdk'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows'; import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows'; @@ -83,6 +84,7 @@ export function Register() { From 3cdb5c2fe6483e08ea49ff4f90209fd22dc329fc Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:10:56 +0300 Subject: [PATCH 07/10] Add code block copy and collapse functionality (#2361) * add buttons to codeblocks * add functionality * Document functions * Improve accessibility * Remove pointless DefaultReset * implement some requested changes * fix content shift when expanding or collapsing --------- Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> --- src/app/components/editor/Elements.tsx | 2 +- src/app/hooks/useTimeoutToggle.ts | 37 +++++++ src/app/plugins/react-custom-html-parser.tsx | 102 ++++++++++++++++--- src/app/styles/CustomHtml.css.ts | 29 +++++- 4 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 src/app/hooks/useTimeoutToggle.ts diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index a7438ecd..6a6659b9 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr visibility="Hover" hideTrack > -
{children}
+
{children}
); diff --git a/src/app/hooks/useTimeoutToggle.ts b/src/app/hooks/useTimeoutToggle.ts new file mode 100644 index 00000000..7eda99c1 --- /dev/null +++ b/src/app/hooks/useTimeoutToggle.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Temporarily sets a boolean state. + * + * @param duration - Duration in milliseconds before resetting (default: 1500) + * @param initial - Initial value (default: false) + */ +export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] { + const [active, setActive] = useState(initial); + const timeoutRef = useRef(null); + + const clear = () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const trigger = useCallback(() => { + setActive(!initial); + clear(); + timeoutRef.current = window.setTimeout(() => { + setActive(initial); + timeoutRef.current = null; + }, duration); + }, [duration, initial]); + + useEffect( + () => () => { + clear(); + }, + [] + ); + + return [active, trigger]; +} diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index cd683e36..04ebacd4 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,5 +1,13 @@ /* eslint-disable jsx-a11y/alt-text */ -import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react'; +import React, { + ComponentPropsWithoutRef, + ReactEventHandler, + Suspense, + lazy, + useCallback, + useMemo, + useState, +} from 'react'; import { Element, Text as DOMText, @@ -9,10 +17,11 @@ import { } from 'html-react-parser'; import { MatrixClient } from 'matrix-js-sdk'; import classNames from 'classnames'; -import { Scroll, Text } from 'folds'; +import { Icon, IconButton, Icons, Scroll, Text } from 'folds'; import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; +import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; import { getMxIdLocalPart, @@ -31,7 +40,8 @@ import { testMatrixTo, } from './matrix-to'; import { onEnterOrSpace } from '../utils/keyboard'; -import { tryDecodeURIComponent } from '../utils/dom'; +import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom'; +import { useTimeoutToggle } from '../hooks/useTimeoutToggle'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); @@ -195,6 +205,82 @@ export const highlightText = ( ); }); +export function CodeBlock(children: ChildNode[], opts: HTMLReactParserOptions) { + const LINE_LIMIT = 14; + + /** + * Recursively extracts and concatenates all text content from an array of ChildNode objects. + * + * @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from. + * @returns {string} The concatenated plain text content of all descendant text nodes. + */ + const extractTextFromChildren = useCallback((nodes: ChildNode[]): string => { + let text = ''; + + nodes.forEach((node) => { + if (node.type === 'text') { + text += node.data; + } else if (node instanceof Element && node.children) { + text += extractTextFromChildren(node.children); + } + }); + + return text; + }, []); + + const [copied, setCopied] = useTimeoutToggle(); + const collapsible = useMemo( + () => extractTextFromChildren(children).split('\n').length > LINE_LIMIT, + [children, extractTextFromChildren] + ); + const [collapsed, setCollapsed] = useState(collapsible); + + const handleCopy = useCallback(() => { + copyToClipboard(extractTextFromChildren(children)); + setCopied(); + }, [children, extractTextFromChildren, setCopied]); + + const toggleCollapse = useCallback(() => { + setCollapsed((prev) => !prev); + }, []); + + return ( + <> +
+ + + + {collapsible && ( + + + + )} +
+ +
+ {domToReact(children, opts)} +
+
+ + ); +} + export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, @@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = ( if (name === 'pre') { return ( - -
{domToReact(children, opts)}
-
+ {CodeBlock(children, opts)}
); } diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index d86a3236..ecbdbeee 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -85,10 +85,35 @@ export const CodeBlock = style([ MarginSpaced, { fontStyle: 'normal', + position: 'relative', }, ]); -export const CodeBlockInternal = style({ - padding: `${config.space.S200} ${config.space.S200} 0`, +export const CodeBlockInternal = recipe({ + base: { + padding: `${config.space.S200} ${config.space.S200} 0`, + minWidth: toRem(100), + }, + variants: { + collapsed: { + true: { + maxHeight: `calc(${config.lineHeight.T400} * 9.6)`, + }, + }, + }, +}); +export const CodeBlockControls = style({ + position: 'absolute', + top: config.space.S200, + right: config.space.S200, + visibility: 'hidden', + selectors: { + [`${CodeBlock}:hover &`]: { + visibility: 'visible', + }, + [`${CodeBlock}:focus-within &`]: { + visibility: 'visible', + }, + }, }); export const List = style([ From 9073dee9862c898dfdc206dbd6b301008c3bff83 Mon Sep 17 00:00:00 2001 From: Filipe Medeiros Date: Wed, 23 Jul 2025 16:17:17 +0100 Subject: [PATCH 08/10] Add button to start thread on reply (#2320) * add simple button to start a thread on reply * force build * remove useless actions * add actions back * change icon to ThreadPlus * add button to context menu * fix capital T --------- Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 6 ++-- src/app/features/room/message/Message.tsx | 39 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 773e115b..f2218b04 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -933,7 +933,7 @@ export function RoomTimeline({ ); const handleReplyClick: MouseEventHandler = useCallback( - (evt) => { + (evt, startThread = false) => { const replyId = evt.currentTarget.getAttribute('data-event-id'); if (!replyId) { console.warn('Button should have "data-event-id" attribute!'); @@ -944,7 +944,9 @@ export function RoomTimeline({ const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const { body, formatted_body: formattedBody } = content; - const { 'm.relates_to': relation } = replyEvt.getWireContent(); + const { 'm.relates_to': relation } = startThread + ? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } } + : replyEvt.getWireContent(); const senderId = replyEvt.getSender(); if (senderId && typeof body === 'string') { setReplyDraft({ diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b85605d5..c5de9ea1 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -669,7 +669,10 @@ export type MessageProps = { messageSpacing: MessageSpacing; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; - onReplyClick: MouseEventHandler; + onReplyClick: ( + ev: Parameters>[0], + startThread?: boolean + ) => void; onEditId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; @@ -859,6 +862,8 @@ export const Message = as<'div', MessageProps>( }, 100); }; + const isThreadedMessage = mEvent.threadRootId !== undefined; + return ( ( > + {!isThreadedMessage && ( + onReplyClick(ev, true)} + data-event-id={mEvent.getId()} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} @@ -1000,6 +1016,27 @@ export const Message = as<'div', MessageProps>( Reply + {!isThreadedMessage && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt, true); + closeMenu(); + }} + > + + Reply in Thread + + + )} {canEditEvent(mx, mEvent) && onEditId && ( Date: Wed, 23 Jul 2025 20:59:32 +0530 Subject: [PATCH 09/10] Fix small height image half clickable view button (#2397) --- src/app/components/message/content/style.css.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index f6cadd3c..93f3649c 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -16,6 +16,7 @@ export const AbsoluteContainer = style([ position: 'absolute', top: 0, left: 0, + zIndex: 1, width: '100%', height: '100%', }, From 67b05eeb09c8019fc67564f65ebca3d4b37653c6 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:00:02 +0530 Subject: [PATCH 10/10] Render room avatar as fallback for dm group chat (#2398) * render room avatar for dm group chat * remove extra conditions --- src/app/utils/room.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index cae23514..a962c45d 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -294,9 +294,14 @@ export const getDirectRoomAvatarUrl = ( useAuthentication = false ): string | undefined => { const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl(); - return mxcUrl - ? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined - : undefined; + + if (!mxcUrl) { + return getRoomAvatarUrl(mx, room, size, useAuthentication); + } + + return ( + mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined + ); }; export const trimReplyFromBody = (body: string): string => {