From 13f1d53191954ed9bc1bff814a262d4c9673df7a Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 13 May 2025 14:18:52 +0530 Subject: [PATCH 01/79] fix room setting crash in knock_restricted join rule (#2323) * fix room setting crash in knock_restricted join rule * only show knock & space member join rule for space children * fix knock restricted icon and label --- src/app/components/JoinRulesSwitcher.tsx | 20 ++++++++++++------- .../common-settings/general/RoomJoinRules.tsx | 17 ++++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index e78c19ce..9507317a 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -17,12 +17,16 @@ import { JoinRule } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../utils/keyboard'; -type JoinRuleIcons = Record; +export type ExtraJoinRules = 'knock_restricted'; +export type ExtendedJoinRules = JoinRule | ExtraJoinRules; + +type JoinRuleIcons = Record; export const useRoomJoinRuleIcon = (): JoinRuleIcons => useMemo( () => ({ [JoinRule.Invite]: Icons.HashLock, [JoinRule.Knock]: Icons.HashLock, + knock_restricted: Icons.Hash, [JoinRule.Restricted]: Icons.Hash, [JoinRule.Public]: Icons.HashGlobe, [JoinRule.Private]: Icons.HashLock, @@ -34,6 +38,7 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons => () => ({ [JoinRule.Invite]: Icons.SpaceLock, [JoinRule.Knock]: Icons.SpaceLock, + knock_restricted: Icons.Space, [JoinRule.Restricted]: Icons.Space, [JoinRule.Public]: Icons.SpaceGlobe, [JoinRule.Private]: Icons.SpaceLock, @@ -41,12 +46,13 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons => [] ); -type JoinRuleLabels = Record; +type JoinRuleLabels = Record; export const useRoomJoinRuleLabel = (): JoinRuleLabels => useMemo( () => ({ [JoinRule.Invite]: 'Invite Only', [JoinRule.Knock]: 'Knock & Invite', + knock_restricted: 'Space Members or Knock', [JoinRule.Restricted]: 'Space Members', [JoinRule.Public]: 'Public', [JoinRule.Private]: 'Invite Only', @@ -54,7 +60,7 @@ export const useRoomJoinRuleLabel = (): JoinRuleLabels => [] ); -type JoinRulesSwitcherProps = { +type JoinRulesSwitcherProps = { icons: JoinRuleIcons; labels: JoinRuleLabels; rules: T; @@ -63,7 +69,7 @@ type JoinRulesSwitcherProps = { disabled?: boolean; changing?: boolean; }; -export function JoinRulesSwitcher({ +export function JoinRulesSwitcher({ icons, labels, rules, @@ -79,7 +85,7 @@ export function JoinRulesSwitcher({ }; const handleChange = useCallback( - (selectedRule: JoinRule) => { + (selectedRule: ExtendedJoinRules) => { setCords(undefined); onChange(selectedRule); }, @@ -131,7 +137,7 @@ export function JoinRulesSwitcher({ fill="Soft" radii="300" outlined - before={} + before={} after={ changing ? ( @@ -142,7 +148,7 @@ export function JoinRulesSwitcher({ onClick={handleOpenMenu} disabled={disabled} > - {labels[value]} + {labels[value] ?? 'Unsupported'} ); diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index 158ca25b..ebd4cad5 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -4,6 +4,7 @@ import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { + ExtendedJoinRules, JoinRulesSwitcher, useRoomJoinRuleIcon, useRoomJoinRuleLabel, @@ -32,6 +33,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const mx = useMatrixClient(); const room = useRoom(); const roomVersion = parseInt(room.getVersion(), 10); + const allowKnockRestricted = roomVersion >= 10; const allowRestricted = roomVersion >= 8; const allowKnock = roomVersion >= 7; const space = useSpaceOptionally(); @@ -47,18 +49,21 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const content = joinRuleEvent?.getContent(); const rule: JoinRule = content?.join_rule ?? JoinRule.Invite; - const joinRules: Array = useMemo(() => { - const r: JoinRule[] = [JoinRule.Invite]; + const joinRules: Array = useMemo(() => { + const r: ExtendedJoinRules[] = [JoinRule.Invite]; if (allowKnock) { r.push(JoinRule.Knock); } if (allowRestricted && space) { r.push(JoinRule.Restricted); } + if (allowKnockRestricted && space) { + r.push('knock_restricted'); + } r.push(JoinRule.Public); return r; - }, [allowRestricted, allowKnock, space]); + }, [allowKnockRestricted, allowRestricted, allowKnock, space]); const icons = useRoomJoinRuleIcon(); const spaceIcons = useSpaceJoinRuleIcon(); @@ -66,9 +71,9 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const [submitState, submit] = useAsyncCallback( useCallback( - async (joinRule: JoinRule) => { + async (joinRule: ExtendedJoinRules) => { const allow: RestrictedRoomAllowContent[] = []; - if (joinRule === JoinRule.Restricted) { + if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') { const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) => event.getStateKey() ); @@ -82,7 +87,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { } const c: RoomJoinRulesEventContent = { - join_rule: joinRule, + join_rule: joinRule as JoinRule, }; if (allow.length > 0) c.allow = allow; await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); From 87e97eab8872e091cdf24788db10037da9044edc Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 13 May 2025 16:16:22 +0530 Subject: [PATCH 02/79] Update commands (#2325) * kick-ban all members by servername * Add command for deleting multiple messages * remove console logs and improve ban command description * improve commands description * add server acl command * fix code highlight not working after editing in dev tools --- src/app/components/text-viewer/TextViewer.tsx | 2 +- src/app/features/room/CommandAutocomplete.tsx | 22 +- src/app/hooks/useCommands.ts | 317 ++++++++++++++++-- src/app/utils/common.ts | 6 + src/app/utils/matrix.ts | 37 ++ 5 files changed, 339 insertions(+), 45 deletions(-) diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index f39ef953..ec4ed0a5 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef {text}}> {text}}> - {(codeRef) => {text}} + {(codeRef) => {text}} diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx index 31903ac6..6b7ba56e 100644 --- a/src/app/features/room/CommandAutocomplete.tsx +++ b/src/app/features/room/CommandAutocomplete.tsx @@ -1,6 +1,6 @@ import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import { Editor } from 'slate'; -import { Box, MenuItem, Text } from 'folds'; +import { Box, config, MenuItem, Text } from 'folds'; import { Room } from 'matrix-js-sdk'; import { Command, useCommands } from '../../hooks/useCommands'; import { @@ -75,9 +75,6 @@ export function CommandAutocomplete({ headerContent={ Commands - - Begin your message with command - } requestClose={requestClose} @@ -87,17 +84,22 @@ export function CommandAutocomplete({ key={commandName} as="button" radii="300" + style={{ height: 'unset' }} onKeyDown={(evt: ReactKeyboardEvent) => onTabPress(evt, () => handleAutocomplete(commandName)) } onClick={() => handleAutocomplete(commandName)} > - - - - {`/${commandName}`} - - + + + {`/${commandName}`} + {commands[commandName].description} diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index bc7d2892..c7a53580 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -1,34 +1,127 @@ -import { MatrixClient, Room } from 'matrix-js-sdk'; +import { Direction, IContextResponse, MatrixClient, Method, Room, RoomMember } from 'matrix-js-sdk'; +import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types'; import { useMemo } from 'react'; -import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix'; +import { + getDMRoomFor, + isRoomAlias, + isRoomId, + isServerName, + isUserId, + rateLimitedActions, +} from '../utils/matrix'; import { hasDevices } from '../../util/matrixUtil'; import * as roomActions from '../../client/action/room'; import { useRoomNavigate } from './useRoomNavigate'; +import { Membership, StateEvent } from '../../types/matrix/room'; +import { getStateEvent } from '../utils/room'; +import { splitWithSpace } from '../utils/common'; export const SHRUG = '¯\\_(ツ)_/¯'; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; export const UNFLIP = '┬─┬ノ( º_ºノ)'; -export function parseUsersAndReason(payload: string): { - users: string[]; - reason?: string; -} { - let reason: string | undefined; - let ids: string = payload; +const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b'; +const FLAG_REG = new RegExp(FLAG_PAT); +const FLAG_REG_G = new RegExp(FLAG_PAT, 'g'); - const reasonMatch = payload.match(/\s-r\s/); - if (reasonMatch) { - ids = payload.slice(0, reasonMatch.index); - reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length); - if (reason.trim() === '') reason = undefined; +export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => { + const flagMatch = payload.match(FLAG_REG); + + if (!flagMatch) { + return [payload, undefined]; } - const rawIds = ids.split(' '); - const users = rawIds.filter((id) => isUserId(id)); - return { - users, - reason, - }; -} + const content = payload.slice(0, flagMatch.index); + const flags = payload.slice(flagMatch.index); + + return [content, flags]; +}; + +export const parseFlags = (flags: string | undefined): Record => { + const result: Record = {}; + if (!flags) return result; + + const matches: { key: string; index: number; match: string }[] = []; + + for (let match = FLAG_REG_G.exec(flags); match !== null; match = FLAG_REG_G.exec(flags)) { + matches.push({ key: match[1], index: match.index, match: match[0] }); + } + + for (let i = 0; i < matches.length; i += 1) { + const { key, match } = matches[i]; + const start = matches[i].index + match.length; + const end = i + 1 < matches.length ? matches[i + 1].index : flags.length; + const value = flags.slice(start, end).trim(); + result[key] = value; + } + + return result; +}; + +export const parseUsers = (payload: string): string[] => { + const users: string[] = []; + + splitWithSpace(payload).forEach((item) => { + if (isUserId(item)) { + users.push(item); + } + }); + + return users; +}; + +export const parseServers = (payload: string): string[] => { + const servers: string[] = []; + + splitWithSpace(payload).forEach((item) => { + if (isServerName(item)) { + servers.push(item); + } + }); + + return servers; +}; + +const getServerMembers = (room: Room, server: string): RoomMember[] => { + const members: RoomMember[] = room + .getMembers() + .filter((member) => member.userId.endsWith(`:${server}`)); + + return members; +}; + +export const parseTimestampFlag = (input: string): number | undefined => { + const match = input.match(/^(\d+(?:\.\d+)?)([dhms])$/); // supports floats like 1.5d + + if (!match) { + return undefined; + } + + const value = parseFloat(match[1]); // supports decimal values + const unit = match[2]; + + const now = Date.now(); // in milliseconds + let delta = 0; + + switch (unit) { + case 'd': + delta = value * 24 * 60 * 60 * 1000; + break; + case 'h': + delta = value * 60 * 60 * 1000; + break; + case 'm': + delta = value * 60 * 1000; + break; + case 's': + delta = value * 1000; + break; + default: + return undefined; + } + + const timestamp = now - delta; + return timestamp; +}; export type CommandExe = (payload: string) => Promise; @@ -52,6 +145,8 @@ export enum Command { ConvertToRoom = 'converttoroom', TableFlip = 'tableflip', UnFlip = 'unflip', + Delete = 'delete', + Acl = 'acl', } export type CommandContent = { @@ -96,7 +191,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.StartDm, description: 'Start direct message with user. Example: /startdm userId1', exe: async (payload) => { - const rawIds = payload.split(' '); + const rawIds = splitWithSpace(payload); const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId()); if (userIds.length === 0) return; if (userIds.length === 1) { @@ -106,7 +201,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { return; } } - const devices = await Promise.all(userIds.map(uid => hasDevices(mx, uid))); + const devices = await Promise.all(userIds.map((uid) => hasDevices(mx, uid))); const isEncrypt = devices.every((hasDevice) => hasDevice); const result = await roomActions.createDM(mx, userIds, isEncrypt); navigateRoom(result.room_id); @@ -116,7 +211,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', exe: async (payload) => { - const rawIds = payload.split(' '); + const rawIds = splitWithSpace(payload); const roomIds = rawIds.filter( (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) ); @@ -131,7 +226,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { mx.leave(room.roomId); return; } - const rawIds = payload.split(' '); + const rawIds = splitWithSpace(payload); const roomIds = rawIds.filter((id) => isRoomId(id)); roomIds.map((id) => mx.leave(id)); }, @@ -140,7 +235,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.Invite, description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]', exe: async (payload) => { - const { users, reason } = parseUsersAndReason(payload); + const [content, flags] = splitPayloadContentAndFlags(payload); + const users = parseUsers(content); + const flagToContent = parseFlags(flags); + const reason = flagToContent.r; users.map((id) => mx.invite(room.roomId, id, reason)); }, }, @@ -148,7 +246,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.DisInvite, description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]', exe: async (payload) => { - const { users, reason } = parseUsersAndReason(payload); + const [content, flags] = splitPayloadContentAndFlags(payload); + const users = parseUsers(content); + const flagToContent = parseFlags(flags); + const reason = flagToContent.r; users.map((id) => mx.kick(room.roomId, id, reason)); }, }, @@ -156,23 +257,53 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.Kick, description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]', exe: async (payload) => { - const { users, reason } = parseUsersAndReason(payload); - users.map((id) => mx.kick(room.roomId, id, reason)); + const [content, flags] = splitPayloadContentAndFlags(payload); + const users = parseUsers(content); + const servers = parseServers(content); + const flagToContent = parseFlags(flags); + const reason = flagToContent.r; + + const serverMembers = servers?.flatMap((server) => getServerMembers(room, server)); + const serverUsers = serverMembers + ?.filter((m) => m.membership !== Membership.Ban) + .map((m) => m.userId); + + if (Array.isArray(serverUsers)) { + serverUsers.forEach((user) => { + if (!users.includes(user)) users.push(user); + }); + } + + rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason)); }, }, [Command.Ban]: { name: Command.Ban, - description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]', + description: 'Ban user from room. Example: /ban userId1 userId2 servername [-r reason]', exe: async (payload) => { - const { users, reason } = parseUsersAndReason(payload); - users.map((id) => mx.ban(room.roomId, id, reason)); + const [content, flags] = splitPayloadContentAndFlags(payload); + const users = parseUsers(content); + const servers = parseServers(content); + const flagToContent = parseFlags(flags); + const reason = flagToContent.r; + + const serverMembers = servers?.flatMap((server) => getServerMembers(room, server)); + const serverUsers = serverMembers?.map((m) => m.userId); + + if (Array.isArray(serverUsers)) { + serverUsers.forEach((user) => { + if (!users.includes(user)) users.push(user); + }); + } + + rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason)); }, }, [Command.UnBan]: { name: Command.UnBan, description: 'Unban user from room. Example: /unban userId1 userId2', exe: async (payload) => { - const rawIds = payload.split(' '); + const rawIds = splitWithSpace(payload); const users = rawIds.filter((id) => isUserId(id)); users.map((id) => mx.unban(room.roomId, id)); }, @@ -181,7 +312,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.Ignore, description: 'Ignore user. Example: /ignore userId1 userId2', exe: async (payload) => { - const rawIds = payload.split(' '); + const rawIds = splitWithSpace(payload); const userIds = rawIds.filter((id) => isUserId(id)); if (userIds.length > 0) roomActions.ignore(mx, userIds); }, @@ -190,7 +321,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.UnIgnore, description: 'Unignore user. Example: /unignore userId1 userId2', exe: async (payload) => { - const rawIds = payload.split(' '); + const rawIds = splitWithSpace(payload); const userIds = rawIds.filter((id) => isUserId(id)); if (userIds.length > 0) roomActions.unignore(mx, userIds); }, @@ -227,6 +358,124 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { roomActions.convertToRoom(mx, room.roomId); }, }, + [Command.Delete]: { + name: Command.Delete, + description: + 'Delete messages from users. Example: /delete userId1 servername -past 1d|2h|5m|30s [-t m.room.message] [-r spam]', + exe: async (payload) => { + const [content, flags] = splitPayloadContentAndFlags(payload); + const users = parseUsers(content); + const servers = parseServers(content); + + const flagToContent = parseFlags(flags); + const reason = flagToContent.r; + const pastContent = flagToContent.past ?? ''; + const msgTypeContent = flagToContent.t; + const messageTypes: string[] = msgTypeContent ? splitWithSpace(msgTypeContent) : []; + + const ts = parseTimestampFlag(pastContent); + if (!ts) return; + + const serverMembers = servers?.flatMap((server) => getServerMembers(room, server)); + const serverUsers = serverMembers?.map((m) => m.userId); + + if (Array.isArray(serverUsers)) { + serverUsers.forEach((user) => { + if (!users.includes(user)) users.push(user); + }); + } + + const result = await mx.timestampToEvent(room.roomId, ts, Direction.Forward); + const startEventId = result.event_id; + + const path = `/rooms/${encodeURIComponent(room.roomId)}/context/${encodeURIComponent( + startEventId + )}`; + const eventContext = await mx.http.authedRequest(Method.Get, path, { + limit: 0, + }); + + let token: string | undefined = eventContext.start; + while (token) { + // eslint-disable-next-line no-await-in-loop + const response = await mx.createMessagesRequest( + room.roomId, + token, + 20, + Direction.Forward, + undefined + ); + const { end, chunk } = response; + // remove until the latest event; + token = end; + + const eventsToDelete = chunk.filter( + (roomEvent) => + (messageTypes.length > 0 ? messageTypes.includes(roomEvent.type) : true) && + users.includes(roomEvent.sender) && + roomEvent.unsigned?.redacted_because === undefined + ); + + const eventIds = eventsToDelete.map((roomEvent) => roomEvent.event_id); + + // eslint-disable-next-line no-await-in-loop + await rateLimitedActions(eventIds, (eventId) => + mx.redactEvent(room.roomId, eventId, undefined, { reason }) + ); + } + }, + }, + [Command.Acl]: { + name: Command.Acl, + description: + 'Manage server access control list. Example /acl [-a servername1] [-d servername2] [-ra servername1] [-rd servername2]', + exe: async (payload) => { + const [, flags] = splitPayloadContentAndFlags(payload); + + const flagToContent = parseFlags(flags); + const allowFlag = flagToContent.a; + const denyFlag = flagToContent.d; + const removeAllowFlag = flagToContent.ra; + const removeDenyFlag = flagToContent.rd; + + const allowList = allowFlag ? splitWithSpace(allowFlag) : []; + const denyList = denyFlag ? splitWithSpace(denyFlag) : []; + const removeAllowList = removeAllowFlag ? splitWithSpace(removeAllowFlag) : []; + const removeDenyList = removeDenyFlag ? splitWithSpace(removeDenyFlag) : []; + + const serverAcl = getStateEvent( + room, + StateEvent.RoomServerAcl + )?.getContent(); + + const aclContent: RoomServerAclEventContent = { + allow: serverAcl?.allow ? [...serverAcl.allow] : [], + allow_ip_literals: serverAcl?.allow_ip_literals, + deny: serverAcl?.deny ? [...serverAcl.deny] : [], + }; + + allowList.forEach((servername) => { + if (!Array.isArray(aclContent.allow) || aclContent.allow.includes(servername)) return; + aclContent.allow.push(servername); + }); + denyList.forEach((servername) => { + if (!Array.isArray(aclContent.deny) || aclContent.deny.includes(servername)) return; + aclContent.deny.push(servername); + }); + + aclContent.allow = aclContent.allow?.filter( + (servername) => !removeAllowList.includes(servername) + ); + aclContent.deny = aclContent.deny?.filter( + (servername) => !removeDenyList.includes(servername) + ); + + aclContent.allow?.sort(); + aclContent.deny?.sort(); + + await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent); + }, + }, }), [mx, room, navigateRoom] ); diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index d230c6bb..34e1ecbf 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -125,3 +125,9 @@ export const suffixRename = (name: string, validator: (newName: string) => boole }; export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-'); + +export const splitWithSpace = (content: string): string[] => { + const trimmedContent = content.trim(); + if (trimmedContent === '') return []; + return trimmedContent.split(' '); +}; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index cd3c0862..75430c20 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -13,11 +13,16 @@ import { UploadProgress, UploadResponse, } from 'matrix-js-sdk'; +import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { getStateEvent } from './room'; import { StateEvent } from '../../types/matrix/room'; +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+)$/); export const validMxId = (id: string): boolean => !!matchMxId(id); @@ -292,3 +297,35 @@ export const downloadEncryptedMedia = async ( return decryptedContent; }; + +export const rateLimitedActions = async ( + data: T[], + callback: (item: T) => Promise, + maxRetryCount?: number +) => { + let retryCount = 0; + const performAction = async (dataItem: T) => { + const [err] = await to(callback(dataItem)); + + if (err?.httpStatus === 429) { + if (retryCount === maxRetryCount) { + return; + } + + const waitMS = err.getRetryAfterMs() ?? 200; + await new Promise((resolve) => { + setTimeout(resolve, waitMS); + }); + retryCount += 1; + + await performAction(dataItem); + } + }; + + for (let i = 0; i < data.length; i += 1) { + const dataItem = data[i]; + retryCount = 0; + // eslint-disable-next-line no-await-in-loop + await performAction(dataItem); + } +}; From 6ddcf2cb0238ddc1f1be10bca692c19b8177e11b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 13 May 2025 16:58:43 +0530 Subject: [PATCH 03/79] update kick command example --- src/app/hooks/useCommands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c7a53580..c95142e8 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -255,7 +255,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }, [Command.Kick]: { name: Command.Kick, - description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]', + description: 'Kick user from room. Example: /kick userId1 userId2 servername [-r reason]', exe: async (payload) => { const [content, flags] = splitPayloadContentAndFlags(payload); const users = parseUsers(content); From 387ce9c4621de2217f4e404e08f1268825b28161 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 18 May 2025 10:53:56 +0530 Subject: [PATCH 04/79] upgrade to matrix-js-sdk v37.5.0 (#2327) * upgrade to js-sdk 37 * fix server crypto wasm locally --- package-lock.json | 76 +++++++++++++++++---------- package.json | 2 +- src/app/components/SecretStorage.tsx | 12 +++-- src/app/hooks/useDeviceList.ts | 2 +- src/app/pages/auth/login/loginUtil.ts | 2 +- src/app/state/backupRestore.ts | 14 ++--- src/client/initMatrix.ts | 6 --- vite.config.js | 33 ++++++++++++ 8 files changed, 96 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index f85dd74d..51500d9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "jotai": "2.6.0", "linkify-react": "4.1.3", "linkifyjs": "4.1.3", - "matrix-js-sdk": "35.0.0", + "matrix-js-sdk": "37.5.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -2263,17 +2263,19 @@ } }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-11.1.0.tgz", - "integrity": "sha512-JPuO9RCVDklDjbFzMvZfQb7PuiFkLY72bniRSu81lRzkkrcbZtmKqBFMm9H4f2FSz+tHVkDnmsvn12I4sdJJ5A==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz", + "integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==", + "license": "Apache-2.0", "engines": { - "node": ">= 10" + "node": ">= 18" } }, "node_modules/@matrix-org/olm": { "version": "3.2.15", "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz", - "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==" + "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==", + "license": "Apache-2.0" }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -4589,7 +4591,8 @@ "node_modules/@types/events": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", - "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==" + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "license": "MIT" }, "node_modules/@types/file-saver": { "version": "2.0.5", @@ -4678,7 +4681,8 @@ "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" }, "node_modules/@types/sanitize-html": { "version": "2.9.0", @@ -5088,7 +5092,8 @@ "node_modules/another-json": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", - "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==" + "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==", + "license": "Apache-2.0" }, "node_modules/ansi-regex": { "version": "5.0.1", @@ -5438,9 +5443,10 @@ "devOptional": true }, "node_modules/base-x": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", - "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", @@ -5546,6 +5552,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", "dependencies": { "base-x": "^5.0.0" } @@ -5848,6 +5855,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6999,6 +7007,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", "engines": { "node": ">=0.8.x" } @@ -8557,6 +8566,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", "engines": { "node": ">=18" } @@ -8689,6 +8699,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", "engines": { "node": ">= 0.6.0" }, @@ -8764,21 +8775,23 @@ "node_modules/matrix-events-sdk": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", - "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==" + "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==", + "license": "Apache-2.0" }, "node_modules/matrix-js-sdk": { - "version": "35.0.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-35.0.0.tgz", - "integrity": "sha512-X8hIsd/8x1SC9vRr8DiNKQxmdrfRujtvEWPz8mY4FxVDJG8HEGDHvqUmaSy2jrtnOUn4oHzGQVLFO3DnhsSf8w==", + "version": "37.5.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz", + "integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==", + "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^11.0.0", + "@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1", "@matrix-org/olm": "3.2.15", "another-json": "^0.2.0", "bs58": "^6.0.0", "content-type": "^1.0.4", "jwt-decode": "^4.0.0", - "loglevel": "^1.7.1", + "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", "matrix-widget-api": "^1.10.0", "oidc-client-ts": "^3.0.1", @@ -8792,21 +8805,23 @@ } }, "node_modules/matrix-js-sdk/node_modules/uuid": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", - "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/esm/bin/uuid" } }, "node_modules/matrix-widget-api": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz", - "integrity": "sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz", + "integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==", + "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", "events": "^3.2.0" @@ -9198,9 +9213,10 @@ } }, "node_modules/oidc-client-ts": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.1.0.tgz", - "integrity": "sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz", + "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==", + "license": "Apache-2.0", "dependencies": { "jwt-decode": "^4.0.0" }, @@ -9288,6 +9304,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" @@ -10051,6 +10068,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -10264,6 +10282,7 @@ "version": "2.15.0", "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", "bin": { "sdp-verify": "checker.js" } @@ -11172,7 +11191,8 @@ "node_modules/unhomoglyph": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", - "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==" + "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==", + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", diff --git a/package.json b/package.json index b489dc2c..baf9ed01 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "jotai": "2.6.0", "linkify-react": "4.1.3", "linkifyjs": "4.1.3", - "matrix-js-sdk": "35.0.0", + "matrix-js-sdk": "37.5.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", diff --git a/src/app/components/SecretStorage.tsx b/src/app/components/SecretStorage.tsx index 55d466d7..9d8628e5 100644 --- a/src/app/components/SecretStorage.tsx +++ b/src/app/components/SecretStorage.tsx @@ -1,7 +1,6 @@ import React, { FormEventHandler, useCallback } from 'react'; import { Box, Text, Button, Spinner, color } from 'folds'; -import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api'; -import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; +import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api'; import { PasswordInput } from './password-input'; import { SecretStorageKeyContent, @@ -29,11 +28,16 @@ export function SecretStorageRecoveryPassphrase({ const [driveKeyState, submitPassphrase] = useAsyncCallback< Uint8Array, Error, - Parameters + Parameters >( useCallback( async (passphrase, salt, iterations, bits) => { - const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits); + const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase( + passphrase, + salt, + iterations, + bits + ); const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); diff --git a/src/app/hooks/useDeviceList.ts b/src/app/hooks/useDeviceList.ts index 5586ae8d..f89cb3d9 100644 --- a/src/app/hooks/useDeviceList.ts +++ b/src/app/hooks/useDeviceList.ts @@ -1,7 +1,7 @@ import { useEffect, useCallback, useMemo } from 'react'; import { IMyDevice } from 'matrix-js-sdk'; import { useQuery } from '@tanstack/react-query'; -import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto'; +import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api'; import { useMatrixClient } from './useMatrixClient'; export const useDeviceListChange = ( diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 1e2248d9..7e1c7153 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -73,7 +73,7 @@ export const login = async ( } const mx = createClient({ baseUrl: url }); - const [err, res] = await to(mx.login(data.type, data)); + const [err, res] = await to(mx.loginRequest(data)); if (err) { if (err.httpStatus === 400) { diff --git a/src/app/state/backupRestore.ts b/src/app/state/backupRestore.ts index 2f86b4d5..ad14e5d7 100644 --- a/src/app/state/backupRestore.ts +++ b/src/app/state/backupRestore.ts @@ -1,5 +1,5 @@ import { atom } from 'jotai'; -import { ImportRoomKeyProgressData } from 'matrix-js-sdk/lib/crypto-api'; +import { ImportRoomKeyProgressData, ImportRoomKeyStage } from 'matrix-js-sdk/lib/crypto-api'; export enum BackupProgressStatus { Idle, @@ -39,22 +39,16 @@ export const backupRestoreProgressAtom = atom< >( (get) => get(baseBackupRestoreProgressAtom), (get, set, progress) => { - if (progress.stage === 'fetch') { + if (progress.stage === ImportRoomKeyStage.Fetch) { set(baseBackupRestoreProgressAtom, { status: BackupProgressStatus.Fetching, }); return; } - if (progress.stage === 'load_keys') { + if (progress.stage === ImportRoomKeyStage.LoadKeys) { const { total, successes, failures } = progress; - if (total === undefined || successes === undefined || failures === undefined) { - // Setting to idle as https://github.com/matrix-org/matrix-js-sdk/issues/4703 - set(baseBackupRestoreProgressAtom, { - status: BackupProgressStatus.Idle, - }); - return; - } + const downloaded = successes + failures; if (downloaded === total) { set(baseBackupRestoreProgressAtom, { diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 7c774cf1..b513e27c 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,12 +1,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; -import { logger } from 'matrix-js-sdk/lib/logger'; import { cryptoCallbacks } from './state/secretStorageKeys'; -if (import.meta.env.PROD) { - logger.disableAll(); -} - type Session = { baseUrl: string; accessToken: string; @@ -38,7 +33,6 @@ export const initClient = async (session: Session): Promise => { await indexedDBStore.startup(); await mx.initRustCrypto(); - mx.setGlobalErrorOnUnknownDevices(false); mx.setMaxListeners(50); return mx; diff --git a/vite.config.js b/vite.config.js index 9c8d88bc..dfa02fc4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,6 +7,8 @@ import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfil import inject from '@rollup/plugin-inject'; import topLevelAwait from 'vite-plugin-top-level-await'; import { VitePWA } from 'vite-plugin-pwa'; +import fs from 'fs'; +import path from 'path'; import buildConfig from './build.config'; const copyFiles = { @@ -39,6 +41,32 @@ const copyFiles = { ], }; +function serverMatrixSdkCryptoWasm(wasmFilePath) { + return { + name: 'vite-plugin-serve-matrix-sdk-crypto-wasm', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === wasmFilePath) { + const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm"); + + if (fs.existsSync(resolvedPath)) { + res.setHeader('Content-Type', 'application/wasm'); + res.setHeader('Cache-Control', 'no-cache'); + + const fileStream = fs.createReadStream(resolvedPath); + fileStream.pipe(res); + } else { + res.writeHead(404); + res.end('File not found'); + } + } else { + next(); + } + }); + }, + }; +} + export default defineConfig({ appType: 'spa', publicDir: false, @@ -46,8 +74,13 @@ export default defineConfig({ server: { port: 8080, host: true, + fs: { + // Allow serving files from one level up to the project root + allow: ['..'], + }, }, plugins: [ + serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), topLevelAwait({ // The export name of top-level await promise for each chunk module promiseExportName: '__tla', From 5964eee833fbee37751e6513614b9437df75ae5d Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 18 May 2025 11:45:12 +0530 Subject: [PATCH 05/79] Release v4.7.0 (#2328) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- src/client/state/cons.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 51500d9b..de50ecae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.6.0", + "version": "4.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.6.0", + "version": "4.7.0", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index baf9ed01..0f2d439b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.6.0", + "version": "4.7.0", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 71891a8d..90ae3dd8 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.6.0 + v4.7.0 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index d2133adc..461b8d0d 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.6.0 + v4.7.0 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index c79229b9..35144ce5 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.6.0', + version: '4.7.0', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From df391968d8d552e1810c2c5eb4e185ead917686f Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 21 May 2025 17:28:13 +0530 Subject: [PATCH 06/79] Fix crash on malformed blurhash (#2331) --- src/app/components/message/content/ImageContent.tsx | 3 ++- src/app/components/message/content/VideoContent.tsx | 3 ++- src/app/utils/blurHash.ts | 12 +++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 69c7ade8..cc0c0c91 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -30,6 +30,7 @@ import { stopPropagation } from '../../../utils/keyboard'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; +import { validBlurHash } from '../../../utils/blurHash'; type RenderViewerProps = { src: string; @@ -77,7 +78,7 @@ export const ImageContent = as<'div', ImageContentProps>( ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]; + const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const [load, setLoad] = useState(false); const [error, setError] = useState(false); diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index f6ddbb5a..0505f204 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -31,6 +31,7 @@ import { mxcUrlToHttp, } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { validBlurHash } from '../../../utils/blurHash'; type RenderVideoProps = { title: string; @@ -68,7 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>( ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]; + const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const [load, setLoad] = useState(false); const [error, setError] = useState(false); diff --git a/src/app/utils/blurHash.ts b/src/app/utils/blurHash.ts index 3fe1ade0..566f6d18 100644 --- a/src/app/utils/blurHash.ts +++ b/src/app/utils/blurHash.ts @@ -1,4 +1,4 @@ -import { encode } from 'blurhash'; +import { encode, isBlurhashValid } from 'blurhash'; export const encodeBlurHash = ( img: HTMLImageElement | HTMLVideoElement, @@ -17,3 +17,13 @@ export const encodeBlurHash = ( const data = context.getImageData(0, 0, canvas.width, canvas.height); return encode(data.data, data.width, data.height, 4, 4); }; + +export const validBlurHash = (hash?: string): string | undefined => { + if (typeof hash === 'string') { + const validity = isBlurhashValid(hash); + + return validity.result ? hash : undefined; + } + + return undefined; +}; From 0d27bde33e98ff516d2a6cbbeec5517fee464c36 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 21 May 2025 17:28:38 +0530 Subject: [PATCH 07/79] Release v4.7.1 (#2332) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- src/client/state/cons.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index de50ecae..5fd5b686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.7.0", + "version": "4.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.7.0", + "version": "4.7.1", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 0f2d439b..983cbe4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.7.0", + "version": "4.7.1", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 90ae3dd8..fbb6e01b 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.7.0 + v4.7.1 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 461b8d0d..99d70647 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.7.0 + v4.7.1 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 35144ce5..ae746f7a 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.7.0', + version: '4.7.1', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From 206ed33516f242decfb488841542ce7566fecd3b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 24 May 2025 20:07:56 +0530 Subject: [PATCH 08/79] Better invites management (#2336) * move block users to account settings * filter invites and add more options * add better rate limit recovery in rateLimitedActions util function --- package-lock.json | 7 + package.json | 1 + src/app/components/page/Page.tsx | 14 + src/app/components/page/style.css.ts | 9 + .../features/message-search/MessageSearch.tsx | 17 +- src/app/features/settings/account/Account.tsx | 399 +---------- .../features/settings/account/ContactInfo.tsx | 45 ++ .../IgnoredUserList.tsx | 17 +- .../features/settings/account/MatrixId.tsx | 33 + src/app/features/settings/account/Profile.tsx | 325 +++++++++ .../settings/notifications/Notifications.tsx | 18 +- src/app/hooks/useReportRoomSupported.ts | 10 + src/app/pages/client/inbox/Inbox.tsx | 2 +- src/app/pages/client/inbox/Invites.tsx | 649 +++++++++++++++--- src/app/plugins/bad-words.ts | 15 + src/app/utils/matrix.ts | 19 +- src/app/utils/room.ts | 32 +- 17 files changed, 1088 insertions(+), 524 deletions(-) create mode 100644 src/app/features/settings/account/ContactInfo.tsx rename src/app/features/settings/{notifications => account}/IgnoredUserList.tsx (91%) create mode 100644 src/app/features/settings/account/MatrixId.tsx create mode 100644 src/app/features/settings/account/Profile.tsx create mode 100644 src/app/hooks/useReportRoomSupported.ts create mode 100644 src/app/plugins/bad-words.ts diff --git a/package-lock.json b/package-lock.json index 5fd5b686..e553add9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/vite-plugin": "3.7.1", "await-to-js": "3.0.0", + "badwords-list": "2.0.1-4", "blurhash": "2.0.4", "browser-encrypt-attachment": "0.3.0", "chroma-js": "3.1.2", @@ -5436,6 +5437,12 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/badwords-list": { + "version": "2.0.1-4", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz", + "integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index 983cbe4f..01ba9647 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/vite-plugin": "3.7.1", "await-to-js": "3.0.0", + "badwords-list": "2.0.1-4", "blurhash": "2.0.4", "browser-encrypt-attachment": "0.3.0", "chroma-js": "3.1.2", diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 55ccecee..a5456385 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
)); +export function PageHeroEmpty({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + export const PageHeroSection = as<'div', ComponentProps>( ({ className, ...props }, ref) => ( {!msgSearchParams.term && status === 'pending' && ( - + } @@ -241,7 +230,7 @@ export function MessageSearch({ subTitle="Find helpful messages in your community by searching with related keywords." /> - + )} {msgSearchParams.term && groups.length === 0 && status === 'success' && ( diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index bfdb0ef5..c4b56e47 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -1,396 +1,10 @@ -import React, { - ChangeEventHandler, - FormEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { - Box, - Text, - IconButton, - Icon, - Icons, - Scroll, - Input, - Avatar, - Button, - Chip, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, - Dialog, - Header, - config, - Spinner, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; +import React from 'react'; +import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; import { Page, PageContent, PageHeader } from '../../../components/page'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; -import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; -import { UserAvatar } from '../../../components/user-avatar'; -import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { nameInitials } from '../../../utils/common'; -import { copyToClipboard } from '../../../utils/dom'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useFilePicker } from '../../../hooks/useFilePicker'; -import { useObjectURL } from '../../../hooks/useObjectURL'; -import { stopPropagation } from '../../../utils/keyboard'; -import { ImageEditor } from '../../../components/image-editor'; -import { ModalWide } from '../../../styles/Modal.css'; -import { createUploadAtom, UploadSuccess } from '../../../state/upload'; -import { CompactUploadCardRenderer } from '../../../components/upload-card'; -import { useCapabilities } from '../../../hooks/useCapabilities'; - -function MatrixId() { - const mx = useMatrixClient(); - const userId = mx.getUserId()!; - - return ( - - Matrix ID - - copyToClipboard(userId)}> - Copy - - } - /> - - - ); -} - -type ProfileProps = { - profile: UserProfile; - userId: string; -}; -function ProfileAvatar({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const capabilities = useCapabilities(); - const [alertRemove, setAlertRemove] = useState(false); - const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; - - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl - ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined; - - const [imageFile, setImageFile] = useState(); - const imageFileURL = useObjectURL(imageFile); - const uploadAtom = useMemo(() => { - if (imageFile) return createUploadAtom(imageFile); - return undefined; - }, [imageFile]); - - const pickFile = useFilePicker(setImageFile, false); - - const handleRemoveUpload = useCallback(() => { - setImageFile(undefined); - }, []); - - const handleUploaded = useCallback( - (upload: UploadSuccess) => { - const { mxc } = upload; - mx.setAvatarUrl(mxc); - handleRemoveUpload(); - }, - [mx, handleRemoveUpload] - ); - - const handleRemoveAvatar = () => { - mx.setAvatarUrl(''); - setAlertRemove(false); - }; - - return ( - - Avatar - - } - after={ - - {nameInitials(defaultDisplayName)}} - /> - - } - > - {uploadAtom ? ( - - - - ) : ( - - - {avatarUrl && ( - - )} - - )} - - {imageFileURL && ( - }> - - - - - - - - - )} - - }> - - setAlertRemove(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - Remove Avatar - - setAlertRemove(false)} radii="300"> - - -
- - - Are you sure you want to remove profile avatar? - - - -
-
-
-
-
- ); -} - -function ProfileDisplayName({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const capabilities = useCapabilities(); - const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; - - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const [displayName, setDisplayName] = useState(defaultDisplayName); - - const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) - ); - const changingDisplayName = changeState.status === AsyncStatus.Loading; - - useEffect(() => { - setDisplayName(defaultDisplayName); - }, [defaultDisplayName]); - - const handleChange: ChangeEventHandler = (evt) => { - const name = evt.currentTarget.value; - setDisplayName(name); - }; - - const handleReset = () => { - setDisplayName(defaultDisplayName); - }; - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (changingDisplayName) return; - - const target = evt.target as HTMLFormElement | undefined; - const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; - const name = displayNameInput?.value; - if (!name) return; - - changeDisplayName(name); - }; - - const hasChanges = displayName !== defaultDisplayName; - return ( - - Display Name - - } - > - - - - - - - ) - } - /> - - - - - - ); -} - -function Profile() { - const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const profile = useUserProfile(userId); - - return ( - - Profile - - - - - - ); -} - -function ContactInformation() { - const mx = useMatrixClient(); - const [threePIdsState, loadThreePIds] = useAsyncCallback( - useCallback(() => mx.getThreePids(), [mx]) - ); - const threePIds = - threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined; - - const emailIds = threePIds?.filter((id) => id.medium === 'email'); - - useEffect(() => { - loadThreePIds(); - }, [loadThreePIds]); - - return ( - - Contact Information - - - - {emailIds?.map((email) => ( - - {email.address} - - ))} - - {/* */} - - - - ); -} +import { MatrixId } from './MatrixId'; +import { Profile } from './Profile'; +import { ContactInformation } from './ContactInfo'; +import { IgnoredUserList } from './IgnoredUserList'; type AccountProps = { requestClose: () => void; @@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) { +
diff --git a/src/app/features/settings/account/ContactInfo.tsx b/src/app/features/settings/account/ContactInfo.tsx new file mode 100644 index 00000000..cfde7e29 --- /dev/null +++ b/src/app/features/settings/account/ContactInfo.tsx @@ -0,0 +1,45 @@ +import React, { useCallback, useEffect } from 'react'; +import { Box, Text, Chip } from 'folds'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; + +export function ContactInformation() { + const mx = useMatrixClient(); + const [threePIdsState, loadThreePIds] = useAsyncCallback( + useCallback(() => mx.getThreePids(), [mx]) + ); + const threePIds = + threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined; + + const emailIds = threePIds?.filter((id) => id.medium === 'email'); + + useEffect(() => { + loadThreePIds(); + }, [loadThreePIds]); + + return ( + + Contact Information + + + + {emailIds?.map((email) => ( + + {email.address} + + ))} + + {/* */} + + + + ); +} diff --git a/src/app/features/settings/notifications/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx similarity index 91% rename from src/app/features/settings/notifications/IgnoredUserList.tsx rename to src/app/features/settings/account/IgnoredUserList.tsx index 0ff3015f..98db9459 100644 --- a/src/app/features/settings/notifications/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { isUserId } from '../../../utils/matrix'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; +import { useAlive } from '../../../hooks/useAlive'; function IgnoreUserInput({ userList }: { userList: string[] }) { const mx = useMatrixClient(); const [userId, setUserId] = useState(''); + const alive = useAlive(); const [ignoreState, ignore] = useAsyncCallback( useCallback( async (uId: string) => { - mx.setIgnoredUsers([...userList, uId]); - setUserId(''); + await mx.setIgnoredUsers([...userList, uId]); }, [mx, userList] ) @@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) { if (!isUserId(uId)) return; - ignore(uId); + ignore(uId).then(() => { + if (alive()) { + setUserId(''); + } + }); }; return ( @@ -129,7 +134,7 @@ export function IgnoredUserList() { return ( - Block Messages + Blocked Users {ignoredUsers.length > 0 && ( - Blocklist + Users {ignoredUsers.map((userId) => ( diff --git a/src/app/features/settings/account/MatrixId.tsx b/src/app/features/settings/account/MatrixId.tsx new file mode 100644 index 00000000..ac9b1fb5 --- /dev/null +++ b/src/app/features/settings/account/MatrixId.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Box, Text, Chip } from 'folds'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { copyToClipboard } from '../../../../util/common'; + +export function MatrixId() { + const mx = useMatrixClient(); + const userId = mx.getUserId()!; + + return ( + + Matrix ID + + copyToClipboard(userId)}> + Copy + + } + /> + + + ); +} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx new file mode 100644 index 00000000..e982a799 --- /dev/null +++ b/src/app/features/settings/account/Profile.tsx @@ -0,0 +1,325 @@ +import React, { + ChangeEventHandler, + FormEventHandler, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { + Box, + Text, + IconButton, + Icon, + Icons, + Input, + Avatar, + Button, + Overlay, + OverlayBackdrop, + OverlayCenter, + Modal, + Dialog, + Header, + config, + Spinner, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { UserAvatar } from '../../../components/user-avatar'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { nameInitials } from '../../../utils/common'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useFilePicker } from '../../../hooks/useFilePicker'; +import { useObjectURL } from '../../../hooks/useObjectURL'; +import { stopPropagation } from '../../../utils/keyboard'; +import { ImageEditor } from '../../../components/image-editor'; +import { ModalWide } from '../../../styles/Modal.css'; +import { createUploadAtom, UploadSuccess } from '../../../state/upload'; +import { CompactUploadCardRenderer } from '../../../components/upload-card'; +import { useCapabilities } from '../../../hooks/useCapabilities'; + +type ProfileProps = { + profile: UserProfile; + userId: string; +}; +function ProfileAvatar({ profile, userId }: ProfileProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const capabilities = useCapabilities(); + const [alertRemove, setAlertRemove] = useState(false); + const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; + + const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; + const avatarUrl = profile.avatarUrl + ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + + const handleUploaded = useCallback( + (upload: UploadSuccess) => { + const { mxc } = upload; + mx.setAvatarUrl(mxc); + handleRemoveUpload(); + }, + [mx, handleRemoveUpload] + ); + + const handleRemoveAvatar = () => { + mx.setAvatarUrl(''); + setAlertRemove(false); + }; + + return ( + + Avatar + + } + after={ + + {nameInitials(defaultDisplayName)}} + /> + + } + > + {uploadAtom ? ( + + + + ) : ( + + + {avatarUrl && ( + + )} + + )} + + {imageFileURL && ( + }> + + + + + + + + + )} + + }> + + setAlertRemove(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + Remove Avatar + + setAlertRemove(false)} radii="300"> + + +
+ + + Are you sure you want to remove profile avatar? + + + +
+
+
+
+
+ ); +} + +function ProfileDisplayName({ profile, userId }: ProfileProps) { + const mx = useMatrixClient(); + const capabilities = useCapabilities(); + const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; + + const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; + const [displayName, setDisplayName] = useState(defaultDisplayName); + + const [changeState, changeDisplayName] = useAsyncCallback( + useCallback((name: string) => mx.setDisplayName(name), [mx]) + ); + const changingDisplayName = changeState.status === AsyncStatus.Loading; + + useEffect(() => { + setDisplayName(defaultDisplayName); + }, [defaultDisplayName]); + + const handleChange: ChangeEventHandler = (evt) => { + const name = evt.currentTarget.value; + setDisplayName(name); + }; + + const handleReset = () => { + setDisplayName(defaultDisplayName); + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (changingDisplayName) return; + + const target = evt.target as HTMLFormElement | undefined; + const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; + const name = displayNameInput?.value; + if (!name) return; + + changeDisplayName(name); + }; + + const hasChanges = displayName !== defaultDisplayName; + return ( + + Display Name + + } + > + + + + + + + ) + } + /> + + + + + + ); +} + +export function Profile() { + const mx = useMatrixClient(); + const userId = mx.getUserId()!; + const profile = useUserProfile(userId); + + return ( + + Profile + + + + + + ); +} diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index aa339a03..095a9bba 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification'; import { AllMessagesNotifications } from './AllMessages'; import { SpecialMessagesNotifications } from './SpecialMessages'; import { KeywordMessagesNotifications } from './KeywordMessages'; -import { IgnoredUserList } from './IgnoredUserList'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; type NotificationsProps = { requestClose: () => void; @@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) { - + + Block Messages + + Block Users" section.'} + /> + +
diff --git a/src/app/hooks/useReportRoomSupported.ts b/src/app/hooks/useReportRoomSupported.ts new file mode 100644 index 00000000..198172cd --- /dev/null +++ b/src/app/hooks/useReportRoomSupported.ts @@ -0,0 +1,10 @@ +import { useSpecVersions } from './useSpecVersions'; + +export const useReportRoomSupported = (): boolean => { + const { versions, unstable_features: unstableFeatures } = useSpecVersions(); + + // report room is introduced in spec version 1.13 + const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13'); + + return supported; +}; diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 686296b7..67d6021a 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -32,7 +32,7 @@ function InvitesNavItem() { - Invitations + Invites {inviteCount > 0 && } diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 8dcfa1c2..63fd21e1 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Avatar, + Badge, Box, Button, + Chip, Icon, IconButton, Icons, @@ -16,56 +18,129 @@ import { config, } from 'folds'; import { useAtomValue } from 'jotai'; +import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; -import { MatrixError, Room } from 'matrix-js-sdk'; -import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; -import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList'; +import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk'; +import { + Page, + PageContent, + PageContentCenter, + PageHeader, + PageHero, + PageHeroEmpty, + PageHeroSection, +} from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { allInvitesAtom } from '../../../state/room-list/inviteList'; -import { mDirectAtom } from '../../../state/mDirectList'; import { SequenceCard } from '../../../components/sequence-card'; import { + bannedInRooms, + getCommonRooms, getDirectRoomAvatarUrl, getMemberDisplayName, getRoomAvatarUrl, + getStateEvent, isDirectInvite, + isSpace, } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix'; +import { + addRoomIdToMDirect, + getMxIdLocalPart, + guessDmRoomUserId, + rateLimitedActions, +} from '../../../utils/matrix'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { useRoomTopic } from '../../../hooks/useRoomMeta'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { StateEvent } from '../../../../types/matrix/room'; +import { testBadWords } from '../../../plugins/bad-words'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; +import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported'; const COMPACT_CARD_WIDTH = 548; -type InviteCardProps = { +type InviteData = { room: Room; - userId: string; - direct?: boolean; - compact?: boolean; - onNavigate: (roomId: string) => void; + roomId: string; + roomName: string; + roomAvatar?: string; + roomTopic?: string; + roomAlias?: string; + + senderId: string; + senderName: string; + inviteTs?: number; + + isSpace: boolean; + isDirect: boolean; + isEncrypted: boolean; }; -function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); + +const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => { + const userId = mx.getSafeUserId(); + const direct = isDirectInvite(room, userId); + + const roomAvatar = direct + ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) + : getRoomAvatarUrl(mx, room, 96, useAuthentication); const roomName = room.name || room.getCanonicalAlias() || room.roomId; + const roomTopic = + getStateEvent(room, StateEvent.RoomTopic)?.getContent()?.topic ?? + undefined; + const member = room.getMember(userId); const memberEvent = member?.events.member; - const memberTs = memberEvent?.getTs() ?? 0; + const senderId = memberEvent?.getSender(); const senderName = senderId ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId : undefined; + const inviteTs = memberEvent?.getTs() ?? 0; - const topic = useRoomTopic(room); + return { + room, + roomId: room.roomId, + roomAvatar, + roomName, + roomTopic, + roomAlias: room.getCanonicalAlias() ?? undefined, + + senderId: senderId ?? 'Unknown', + senderName: senderName ?? 'Unknown', + inviteTs, + + isSpace: isSpace(room), + isDirect: direct, + isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption), + }; +}; + +const hasBadWords = (invite: InviteData): boolean => + testBadWords(invite.roomName) || + testBadWords(invite.roomTopic ?? '') || + testBadWords(invite.senderName) || + testBadWords(invite.senderId); + +type NavigateHandler = (roomId: string, space: boolean) => void; + +type InviteCardProps = { + invite: InviteData; + compact?: boolean; + onNavigate: NavigateHandler; + hideAvatar: boolean; +}; +function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps) { + const mx = useMatrixClient(); + const userId = mx.getSafeUserId(); const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); @@ -73,17 +148,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro const [joinState, join] = useAsyncCallback( useCallback(async () => { - const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined; + const dmUserId = isDirectInvite(invite.room, userId) + ? guessDmRoomUserId(invite.room, userId) + : undefined; - await mx.joinRoom(room.roomId); + await mx.joinRoom(invite.roomId); if (dmUserId) { - await addRoomIdToMDirect(mx, room.roomId, dmUserId); + await addRoomIdToMDirect(mx, invite.roomId, dmUserId); } - onNavigate(room.roomId); - }, [mx, room, userId, onNavigate]) + onNavigate(invite.roomId, invite.isSpace); + }, [mx, invite, userId, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( - useCallback(() => mx.leave(room.roomId), [mx, room]) + useCallback(() => mx.leave(invite.roomId), [mx, invite]) ); const joining = @@ -95,28 +172,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro - - - - Invited by {senderName} - + {(invite.isEncrypted || invite.isDirect || invite.isSpace) && ( + + {invite.isEncrypted && ( + + + Encrypted + + + )} + {invite.isDirect && ( + + + Direct Message + + + )} + {invite.isSpace && ( + + + Space + + + )} - - - + )} ( - {nameInitials(roomName)} + {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)} )} /> @@ -125,9 +217,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro - {roomName} + {invite.roomName} - {topic && ( + {invite.roomTopic && ( - {topic} + {invite.roomTopic} )} }> @@ -149,8 +241,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro }} > @@ -173,6 +265,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro onClick={leave} size="300" variant="Secondary" + radii="300" fill="Soft" disabled={joining || leaving} before={leaving ? : undefined} @@ -182,28 +275,392 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro + + + + From: {invite.senderId} + + + {invite.inviteTs && ( + + + )} + ); } +enum InviteFilter { + Known, + Unknown, + Spam, +} +type InviteFiltersProps = { + filter: InviteFilter; + onFilter: (filter: InviteFilter) => void; + knownInvites: InviteData[]; + unknownInvites: InviteData[]; + spamInvites: InviteData[]; +}; +function InviteFilters({ + filter, + onFilter, + knownInvites, + unknownInvites, + spamInvites, +}: InviteFiltersProps) { + const isKnown = filter === InviteFilter.Known; + const isUnknown = filter === InviteFilter.Unknown; + const isSpam = filter === InviteFilter.Spam; + + return ( + + onFilter(InviteFilter.Known)} + before={isKnown && } + after={ + knownInvites.length > 0 && ( + + {knownInvites.length} + + ) + } + > + Primary + + onFilter(InviteFilter.Unknown)} + before={isUnknown && } + after={ + unknownInvites.length > 0 && ( + + {unknownInvites.length} + + ) + } + > + Public + + onFilter(InviteFilter.Spam)} + before={isSpam && } + after={ + spamInvites.length > 0 && ( + + {spamInvites.length} + + ) + } + > + Spam + + + ); +} + +type KnownInvitesProps = { + invites: InviteData[]; + handleNavigate: NavigateHandler; + compact: boolean; +}; +function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) { + return ( + + Primary + {invites.length > 0 ? ( + + {invites.map((invite) => ( + + ))} + + ) : ( + + + } + title="No Invites" + subTitle="When someone you share a room with sends you an invite, it’ll show up here." + /> + + + )} + + ); +} + +type UnknownInvitesProps = { + invites: InviteData[]; + handleNavigate: NavigateHandler; + compact: boolean; +}; +function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProps) { + const mx = useMatrixClient(); + + const [declineAllStatus, declineAll] = useAsyncCallback( + useCallback(async () => { + const roomIds = invites.map((invite) => invite.roomId); + + await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId)); + }, [mx, invites]) + ); + + const declining = declineAllStatus.status === AsyncStatus.Loading; + + return ( + + + Public + + } + disabled={declining} + radii="Pill" + > + Decline All + + + + {invites.length > 0 ? ( + + {invites.map((invite) => ( + + ))} + + ) : ( + + + } + title="No Invites" + subTitle="Invites from people outside your rooms will appear here." + /> + + + )} + + ); +} + +type SpamInvitesProps = { + invites: InviteData[]; + handleNavigate: NavigateHandler; + compact: boolean; +}; +function SpamInvites({ invites, handleNavigate, compact }: SpamInvitesProps) { + const mx = useMatrixClient(); + const [showInvites, setShowInvites] = useState(false); + + const reportRoomSupported = useReportRoomSupported(); + + const [declineAllStatus, declineAll] = useAsyncCallback( + useCallback(async () => { + const roomIds = invites.map((invite) => invite.roomId); + + await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId)); + }, [mx, invites]) + ); + + const [reportAllStatus, reportAll] = useAsyncCallback( + useCallback(async () => { + const roomIds = invites.map((invite) => invite.roomId); + + await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite')); + }, [mx, invites]) + ); + + const ignoredUsers = useIgnoredUsers(); + const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter( + (user) => !ignoredUsers.includes(user) + ); + const [blockAllStatus, blockAll] = useAsyncCallback( + useCallback( + () => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]), + [mx, ignoredUsers, unignoredUsers] + ) + ); + + const declining = declineAllStatus.status === AsyncStatus.Loading; + const reporting = reportAllStatus.status === AsyncStatus.Loading; + const blocking = blockAllStatus.status === AsyncStatus.Loading; + const loading = blocking || reporting || declining; + + return ( + + Spam + {invites.length > 0 ? ( + + + + } + title={`${invites.length} Spam Invites`} + subTitle="Some of the following invites may contain harmful content or have been sent by banned users." + > + + + {reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && ( + + )} + {unignoredUsers.length > 0 && ( + + )} + + + + + + + + + {showInvites && + invites.map((invite) => ( + + ))} + + ) : ( + + + } + title="No Spam Invites" + subTitle="Invites detected as spam appear here." + /> + + + )} + + ); +} + export function Invites() { const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const mDirects = useAtomValue(mDirectAtom); - const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects); - const spaceInvites = useSpaceInvites(mx, allInvitesAtom); - const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects); + const useAuthentication = useMediaAuthentication(); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const allRooms = useAtomValue(allRoomsAtom); + const allInviteIds = useAtomValue(allInvitesAtom); + + const [filter, setFilter] = useState(InviteFilter.Known); + + const invitesData = allInviteIds + .map((inviteId) => mx.getRoom(inviteId)) + .filter((inviteRoom) => !!inviteRoom) + .map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication)); + + const [knownInvites, unknownInvites, spamInvites] = useMemo(() => { + const known: InviteData[] = []; + const unknown: InviteData[] = []; + const spam: InviteData[] = []; + invitesData.forEach((invite) => { + if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) { + spam.push(invite); + return; + } + + if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) { + unknown.push(invite); + return; + } + + known.push(invite); + }); + + return [known, unknown, spam]; + }, [mx, allRooms, invitesData]); + const containerRef = useRef(null); const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH); useElementSizeObserver( @@ -212,21 +669,12 @@ export function Invites() { ); const screenSize = useScreenSizeContext(); - const { navigateRoom, navigateSpace } = useRoomNavigate(); - - const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => { - const room = mx.getRoom(roomId); - if (!room) return null; - return ( - - ); + const handleNavigate = (roomId: string, space: boolean) => { + if (space) { + navigateSpace(roomId); + return; + } + navigateRoom(roomId); }; return ( @@ -247,7 +695,7 @@ export function Invites() { {screenSize !== ScreenSize.Mobile && } - Invitations + Invites @@ -258,47 +706,40 @@ export function Invites() { - {directInvites.length > 0 && ( - - Direct Messages - - {directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))} - - + + + Filter + + + {filter === InviteFilter.Known && ( + )} - {spaceInvites.length > 0 && ( - - Spaces - - {spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))} - - + + {filter === InviteFilter.Unknown && ( + )} - {roomInvites.length > 0 && ( - - Rooms - - {roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))} - - + + {filter === InviteFilter.Spam && ( + )} - {directInvites.length === 0 && - spaceInvites.length === 0 && - roomInvites.length === 0 && ( -
- - No Pending Invitations - - You don't have any new pending invitations to display yet. - - -
- )}
diff --git a/src/app/plugins/bad-words.ts b/src/app/plugins/bad-words.ts new file mode 100644 index 00000000..a7ca468f --- /dev/null +++ b/src/app/plugins/bad-words.ts @@ -0,0 +1,15 @@ +import * as badWords from 'badwords-list'; +import { sanitizeForRegex } from '../utils/regex'; + +const additionalBadWords: string[] = ['Torture', 'T0rture']; + +const fullBadWordList = additionalBadWords.concat( + badWords.array.filter((word) => !additionalBadWords.includes(word)) +); + +export const BAD_WORDS_REGEX = new RegExp( + `(\\b|_)(${fullBadWordList.map((word) => sanitizeForRegex(word)).join('|')})(\\b|_)`, + 'g' +); + +export const testBadWords = (str: string): boolean => !!str.toLowerCase().match(BAD_WORDS_REGEX); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 75430c20..810f7209 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -304,6 +304,14 @@ export const rateLimitedActions = async ( maxRetryCount?: number ) => { let retryCount = 0; + + let actionInterval = 0; + + const sleepForMs = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + const performAction = async (dataItem: T) => { const [err] = await to(callback(dataItem)); @@ -312,10 +320,9 @@ export const rateLimitedActions = async ( return; } - const waitMS = err.getRetryAfterMs() ?? 200; - await new Promise((resolve) => { - setTimeout(resolve, waitMS); - }); + const waitMS = err.getRetryAfterMs() ?? 3000; + actionInterval = waitMS + 500; + await sleepForMs(waitMS); retryCount += 1; await performAction(dataItem); @@ -327,5 +334,9 @@ export const rateLimitedActions = async ( retryCount = 0; // eslint-disable-next-line no-await-in-loop await performAction(dataItem); + if (actionInterval > 0) { + // eslint-disable-next-line no-await-in-loop + await sleepForMs(actionInterval); + } } }; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 3bf8cd5a..79dcff9e 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -19,6 +19,7 @@ import { import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { + Membership, MessageEvent, NotificationType, RoomToParents, @@ -171,7 +172,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat } if (!roomPushRule) { - const overrideRules = mx.getAccountData('m.push_rules')?.getContent() + const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent() ?.global?.override; if (!overrideRules) return NotificationType.Default; @@ -443,3 +444,32 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions = return mMentions; }; + +export const getCommonRooms = ( + mx: MatrixClient, + rooms: string[], + otherUserId: string +): string[] => { + const commonRooms: string[] = []; + + rooms.forEach((roomId) => { + const room = mx.getRoom(roomId); + if (!room || room.getMyMembership() !== Membership.Join) return; + + const common = room.hasMembershipState(otherUserId, Membership.Join); + if (common) { + commonRooms.push(roomId); + } + }); + + return commonRooms; +}; + +export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean => + rooms.some((roomId) => { + const room = mx.getRoom(roomId); + if (!room || room.getMyMembership() !== Membership.Join) return false; + + const banned = room.hasMembershipState(otherUserId, Membership.Ban); + return banned; + }); From 59a007419feae2adf70ef8c4683db31c2d45e76e Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 24 May 2025 21:19:35 +0530 Subject: [PATCH 09/79] hide decline all public invite button when no invite --- src/app/pages/client/inbox/Invites.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 63fd21e1..84c37f47 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -439,15 +439,17 @@ function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProp Public - } - disabled={declining} - radii="Pill" - > - Decline All - + {invites.length > 0 && ( + } + disabled={declining} + radii="Pill" + > + Decline All + + )} {invites.length > 0 ? ( From c51ba9670e22760a739ad6e26496e79fb846c990 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 24 May 2025 21:22:39 +0530 Subject: [PATCH 10/79] Release v4.8.0 (#2337) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- src/client/state/cons.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index e553add9..306de799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.7.1", + "version": "4.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.7.1", + "version": "4.8.0", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 01ba9647..8772462a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.7.1", + "version": "4.8.0", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index fbb6e01b..30f4b3ca 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.7.1 + v4.8.0 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 99d70647..88d38981 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.7.1 + v4.8.0 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index ae746f7a..3e9306b2 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.7.1', + version: '4.8.0', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From 83057ebbd465842cdcbf1320a96afd2ac09ae483 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Sun, 25 May 2025 15:51:19 +0530 Subject: [PATCH 11/79] Fix additional spam string matching (#2339) --- src/app/plugins/bad-words.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/plugins/bad-words.ts b/src/app/plugins/bad-words.ts index a7ca468f..bb6073d8 100644 --- a/src/app/plugins/bad-words.ts +++ b/src/app/plugins/bad-words.ts @@ -1,7 +1,7 @@ import * as badWords from 'badwords-list'; import { sanitizeForRegex } from '../utils/regex'; -const additionalBadWords: string[] = ['Torture', 'T0rture']; +const additionalBadWords: string[] = ['torture', 't0rture']; const fullBadWordList = additionalBadWords.concat( badWords.array.filter((word) => !additionalBadWords.includes(word)) From a23279e63369876c25409f9adbbd67f4c7a85c75 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 26 May 2025 14:21:27 +0530 Subject: [PATCH 12/79] Fix rate limit when reordering in space lobby (#2254) * move can drop lobby item logic to hook * add comment * resolve rate limit when reordering space children --- src/app/features/lobby/Lobby.tsx | 425 +++++++++++------- src/app/features/lobby/SpaceHierarchy.tsx | 8 +- .../space-add-existing/SpaceAddExisting.jsx | 12 +- src/app/utils/matrix.ts | 12 +- 4 files changed, 270 insertions(+), 187 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 757db239..069e925e 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -1,5 +1,5 @@ import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react'; -import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds'; +import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; @@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories'; import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix'; import { getSpaceRoomPath } from '../../pages/pathUtils'; import { StateEvent } from '../../../types/matrix/room'; import { CanDropCallback, useDnDMonitor } from './DnD'; @@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents'; import { AccountDataEvent } from '../../../types/matrix/accountData'; import { useRoomMembers } from '../../hooks/useRoomMembers'; import { SpaceHierarchy } from './SpaceHierarchy'; +import { useGetRoom } from '../../hooks/useGetRoom'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; + +const useCanDropLobbyItem = ( + space: Room, + roomsPowerLevels: Map, + getRoom: (roomId: string) => Room | undefined, + canEditSpaceChild: (powerLevels: IPowerLevels) => boolean +): CanDropCallback => { + const mx = useMatrixClient(); + + const canDropSpace: CanDropCallback = useCallback( + (item, container) => { + if (!('space' in container.item)) { + // can not drop around rooms. + // space can only be drop around other spaces + return false; + } + + const containerSpaceId = space.roomId; + + if ( + getRoom(containerSpaceId) === undefined || + !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) + ) { + return false; + } + + return true; + }, + [space, roomsPowerLevels, getRoom, canEditSpaceChild] + ); + + const canDropRoom: CanDropCallback = useCallback( + (item, container) => { + const containerSpaceId = + 'space' in container.item ? container.item.roomId : container.item.parentId; + + const draggingOutsideSpace = item.parentId !== containerSpaceId; + const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted; + + // check and do not allow restricted room to be dragged outside + // current space if can't change `m.room.join_rules` `content.allow` + if (draggingOutsideSpace && restrictedItem) { + const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; + const userPLInItem = powerLevelAPI.getPowerLevel( + itemPowerLevel, + mx.getUserId() ?? undefined + ); + const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent( + itemPowerLevel, + StateEvent.RoomJoinRules, + userPLInItem + ); + if (!canChangeJoinRuleAllow) { + return false; + } + } + + if ( + getRoom(containerSpaceId) === undefined || + !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) + ) { + return false; + } + return true; + }, + [mx, getRoom, canEditSpaceChild, roomsPowerLevels] + ); + + const canDrop: CanDropCallback = useCallback( + (item, container): boolean => { + if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) { + // can not drop before or after itself + return false; + } + + // if we are dragging a space + if ('space' in item) { + return canDropSpace(item, container); + } + + return canDropRoom(item, container); + }, + [canDropSpace, canDropRoom] + ); + + return canDrop; +}; export function Lobby() { const navigate = useNavigate(); @@ -92,15 +181,7 @@ export function Lobby() { useCallback((w, height) => setHeroSectionHeight(height), []) ); - const getRoom = useCallback( - (rId: string) => { - if (allJoinedRooms.has(rId)) { - return mx.getRoom(rId) ?? undefined; - } - return undefined; - }, - [mx, allJoinedRooms] - ); + const getRoom = useGetRoom(allJoinedRooms); const canEditSpaceChild = useCallback( (powerLevels: IPowerLevels) => @@ -150,180 +231,155 @@ export function Lobby() { ) ); - const canDrop: CanDropCallback = useCallback( - (item, container): boolean => { - const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted; - if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) { - // can not drop before or after itself - return false; - } - - if ('space' in item) { - if (!('space' in container.item)) return false; - const containerSpaceId = space.roomId; - - if ( - getRoom(containerSpaceId) === undefined || - !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) - ) { - return false; - } - - return true; - } - - const containerSpaceId = - 'space' in container.item ? container.item.roomId : container.item.parentId; - - const dropOutsideSpace = item.parentId !== containerSpaceId; - - if (dropOutsideSpace && restrictedItem) { - // do not allow restricted room to drop outside - // current space if can't change join rule allow - const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; - const userPLInItem = powerLevelAPI.getPowerLevel( - itemPowerLevel, - mx.getUserId() ?? undefined - ); - const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent( - itemPowerLevel, - StateEvent.RoomJoinRules, - userPLInItem - ); - if (!canChangeJoinRuleAllow) { - return false; - } - } - - if ( - getRoom(containerSpaceId) === undefined || - !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) - ) { - return false; - } - return true; - }, - [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx] + const canDrop: CanDropCallback = useCanDropLobbyItem( + space, + roomsPowerLevels, + getRoom, + canEditSpaceChild ); - const reorderSpace = useCallback( - (item: HierarchyItemSpace, containerItem: HierarchyItem) => { - if (!item.parentId) return; + const [reorderSpaceState, reorderSpace] = useAsyncCallback( + useCallback( + async (item: HierarchyItemSpace, containerItem: HierarchyItem) => { + if (!item.parentId) return; - const itemSpaces: HierarchyItemSpace[] = hierarchy - .map((i) => i.space) - .filter((i) => i.roomId !== item.roomId); + const itemSpaces: HierarchyItemSpace[] = hierarchy + .map((i) => i.space) + .filter((i) => i.roomId !== item.roomId); - const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId); - const insertIndex = beforeIndex + 1; + const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId); + const insertIndex = beforeIndex + 1; - itemSpaces.splice(insertIndex, 0, { - ...item, - content: { ...item.content, order: undefined }, - }); + itemSpaces.splice(insertIndex, 0, { + ...item, + content: { ...item.content, order: undefined }, + }); - const currentOrders = itemSpaces.map((i) => { - if (typeof i.content.order === 'string' && lex.has(i.content.order)) { - return i.content.order; - } - return undefined; - }); + const currentOrders = itemSpaces.map((i) => { + if (typeof i.content.order === 'string' && lex.has(i.content.order)) { + return i.content.order; + } + return undefined; + }); - const newOrders = orderKeys(lex, currentOrders); + const newOrders = orderKeys(lex, currentOrders); - newOrders?.forEach((orderKey, index) => { - const itm = itemSpaces[index]; - if (!itm || !itm.parentId) return; - const parentPL = roomsPowerLevels.get(itm.parentId); - const canEdit = parentPL && canEditSpaceChild(parentPL); - if (canEdit && orderKey !== currentOrders[index]) { - mx.sendStateEvent( - itm.parentId, - StateEvent.SpaceChild as any, - { ...itm.content, order: orderKey }, - itm.roomId - ); - } - }); - }, - [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild] - ); + const reorders = newOrders + ?.map((orderKey, index) => ({ + item: itemSpaces[index], + orderKey, + })) + .filter((reorder, index) => { + if (!reorder.item.parentId) return false; + const parentPL = roomsPowerLevels.get(reorder.item.parentId); + const canEdit = parentPL && canEditSpaceChild(parentPL); + return canEdit && reorder.orderKey !== currentOrders[index]; + }); - const reorderRoom = useCallback( - (item: HierarchyItem, containerItem: HierarchyItem): void => { - const itemRoom = mx.getRoom(item.roomId); - if (!item.parentId) { - return; - } - const containerParentId: string = - 'space' in containerItem ? containerItem.roomId : containerItem.parentId; - const itemContent = item.content; - - if (item.parentId !== containerParentId) { - mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); - } - - if ( - itemRoom && - itemRoom.getJoinRule() === JoinRule.Restricted && - item.parentId !== containerParentId - ) { - // change join rule allow parameter when dragging - // restricted room from one space to another - const joinRuleContent = getStateEvent( - itemRoom, - StateEvent.RoomJoinRules - )?.getContent(); - - if (joinRuleContent) { - const allow = - joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? []; - allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); - mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { - ...joinRuleContent, - allow, + if (reorders) { + await rateLimitedActions(reorders, async (reorder) => { + if (!reorder.item.parentId) return; + await mx.sendStateEvent( + reorder.item.parentId, + StateEvent.SpaceChild as any, + { ...reorder.item.content, order: reorder.orderKey }, + reorder.item.roomId + ); }); } - } - - const itemSpaces = Array.from( - hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? [] - ); - - const beforeItem: HierarchyItem | undefined = - 'space' in containerItem ? undefined : containerItem; - const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId); - const insertIndex = beforeIndex + 1; - - itemSpaces.splice(insertIndex, 0, { - ...item, - parentId: containerParentId, - content: { ...itemContent, order: undefined }, - }); - - const currentOrders = itemSpaces.map((i) => { - if (typeof i.content.order === 'string' && lex.has(i.content.order)) { - return i.content.order; - } - return undefined; - }); - - const newOrders = orderKeys(lex, currentOrders); - - newOrders?.forEach((orderKey, index) => { - const itm = itemSpaces[index]; - if (itm && orderKey !== currentOrders[index]) { - mx.sendStateEvent( - containerParentId, - StateEvent.SpaceChild as any, - { ...itm.content, order: orderKey }, - itm.roomId - ); - } - }); - }, - [mx, hierarchy, lex] + }, + [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild] + ) ); + const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading; + + const [reorderRoomState, reorderRoom] = useAsyncCallback( + useCallback( + async (item: HierarchyItem, containerItem: HierarchyItem) => { + const itemRoom = mx.getRoom(item.roomId); + if (!item.parentId) { + return; + } + const containerParentId: string = + 'space' in containerItem ? containerItem.roomId : containerItem.parentId; + const itemContent = item.content; + + // remove from current space + if (item.parentId !== containerParentId) { + mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); + } + + if ( + itemRoom && + itemRoom.getJoinRule() === JoinRule.Restricted && + item.parentId !== containerParentId + ) { + // change join rule allow parameter when dragging + // restricted room from one space to another + const joinRuleContent = getStateEvent( + itemRoom, + StateEvent.RoomJoinRules + )?.getContent(); + + if (joinRuleContent) { + const allow = + joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? + []; + allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); + mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { + ...joinRuleContent, + allow, + }); + } + } + + const itemSpaces = Array.from( + hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? [] + ); + + const beforeItem: HierarchyItem | undefined = + 'space' in containerItem ? undefined : containerItem; + const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId); + const insertIndex = beforeIndex + 1; + + itemSpaces.splice(insertIndex, 0, { + ...item, + parentId: containerParentId, + content: { ...itemContent, order: undefined }, + }); + + const currentOrders = itemSpaces.map((i) => { + if (typeof i.content.order === 'string' && lex.has(i.content.order)) { + return i.content.order; + } + return undefined; + }); + + const newOrders = orderKeys(lex, currentOrders); + + const reorders = newOrders + ?.map((orderKey, index) => ({ + item: itemSpaces[index], + orderKey, + })) + .filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]); + + if (reorders) { + await rateLimitedActions(reorders, async (reorder) => { + await mx.sendStateEvent( + containerParentId, + StateEvent.SpaceChild as any, + { ...reorder.item.content, order: reorder.orderKey }, + reorder.item.roomId + ); + }); + } + }, + [mx, hierarchy, lex] + ) + ); + const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading; + const reordering = reorderingRoom || reorderingSpace; useDnDMonitor( scrollRef, @@ -449,6 +505,7 @@ export function Lobby() { draggingItem={draggingItem} onDragging={setDraggingItem} canDrop={canDrop} + disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} pinned={sidebarSpaces.has(item.space.roomId)} @@ -460,6 +517,28 @@ export function Lobby() { ); })}
+ {reordering && ( + + } + > + Reordering + + + )} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 2c43282f..a152bc19 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -31,6 +31,7 @@ type SpaceHierarchyProps = { draggingItem?: HierarchyItem; onDragging: (item?: HierarchyItem) => void; canDrop: CanDropCallback; + disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; pinned: boolean; @@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef( draggingItem, onDragging, canDrop, + disabledReorder, nextSpaceId, getRoom, pinned, @@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef( handleClose={handleClose} getRoom={getRoom} canEditChild={canEditSpaceChild(spacePowerLevels)} - canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false} + canReorder={ + parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false + } options={ parentId && parentPowerLevels && ( @@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef( dm={mDirects.has(roomItem.roomId)} onOpen={onOpenRoom} getRoom={getRoom} - canReorder={canEditSpaceChild(spacePowerLevels)} + canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder} options={ { setProcess(`Adding ${selected.length} items...`); - const promises = selected.map((rId) => { + await rateLimitedActions(selected, async (rId) => { const room = mx.getRoom(rId); const via = getViaServers(room); if (via.length === 0) { via.push(getIdServer(rId)); } - return mx.sendStateEvent( + await mx.sendStateEvent( roomId, 'm.space.child', { @@ -87,9 +89,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) { ); }); - mountStore.setItem(true); - await Promise.allSettled(promises); - if (mountStore.getItem() !== true) return; + if (!alive()) return; const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs]; const allIds = roomIds.filter( diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 810f7209..a495e8d5 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -300,7 +300,7 @@ export const downloadEncryptedMedia = async ( export const rateLimitedActions = async ( data: T[], - callback: (item: T) => Promise, + callback: (item: T, index: number) => Promise, maxRetryCount?: number ) => { let retryCount = 0; @@ -312,8 +312,8 @@ export const rateLimitedActions = async ( setTimeout(resolve, ms); }); - const performAction = async (dataItem: T) => { - const [err] = await to(callback(dataItem)); + const performAction = async (dataItem: T, index: number) => { + const [err] = await to(callback(dataItem, index)); if (err?.httpStatus === 429) { if (retryCount === maxRetryCount) { @@ -321,11 +321,11 @@ export const rateLimitedActions = async ( } const waitMS = err.getRetryAfterMs() ?? 3000; - actionInterval = waitMS + 500; + actionInterval = waitMS * 1.5; await sleepForMs(waitMS); retryCount += 1; - await performAction(dataItem); + await performAction(dataItem, index); } }; @@ -333,7 +333,7 @@ export const rateLimitedActions = async ( const dataItem = data[i]; retryCount = 0; // eslint-disable-next-line no-await-in-loop - await performAction(dataItem); + await performAction(dataItem, i); if (actionInterval > 0) { // eslint-disable-next-line no-await-in-loop await sleepForMs(actionInterval); From e6f4eeca8edc85ab64179e545b4e2e8c15763633 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 27 May 2025 14:10:27 +0530 Subject: [PATCH 13/79] Update folds to v2.2.0 (#2341) --- package-lock.json | 19 ++++++++++--------- package.json | 2 +- src/app/features/settings/Settings.tsx | 2 +- src/app/pages/client/explore/Explore.tsx | 8 ++------ src/app/pages/client/explore/Server.tsx | 2 +- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 306de799..6d82c219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "file-saver": "2.0.5", "flux": "4.0.3", "focus-trap-react": "10.0.2", - "folds": "2.1.0", + "folds": "2.2.0", "formik": "2.4.6", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", @@ -7265,15 +7265,16 @@ } }, "node_modules/folds": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz", - "integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz", + "integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==", + "license": "Apache-2.0", "peerDependencies": { - "@vanilla-extract/css": "^1.9.2", - "@vanilla-extract/recipes": "^0.3.0", - "classnames": "^2.3.2", - "react": "^17.0.0", - "react-dom": "^17.0.0" + "@vanilla-extract/css": "1.9.2", + "@vanilla-extract/recipes": "0.3.0", + "classnames": "2.3.2", + "react": "17.0.0", + "react-dom": "17.0.0" } }, "node_modules/for-each": { diff --git a/package.json b/package.json index 8772462a..b1b14e55 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "file-saver": "2.0.5", "flux": "4.0.3", "focus-trap-react": "10.0.2", - "folds": "2.1.0", + "folds": "2.2.0", "formik": "2.4.6", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 0ff1f13e..5e1a20f4 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -71,7 +71,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] => { page: SettingsPages.DevicesPage, name: 'Devices', - icon: Icons.Category, + icon: Icons.Monitor, }, { page: SettingsPages.EmojisStickersPage, diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index 420e1a16..dae83166 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -209,7 +209,7 @@ export function Explore() { @@ -243,11 +243,7 @@ export function Explore() { - + diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 1f493df1..48f267cc 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -507,7 +507,7 @@ export function PublicRooms() { )} - {screenSize !== ScreenSize.Mobile && } + {screenSize !== ScreenSize.Mobile && } {server} From 91632aa1932b14b1a30df8fd44bdbff238699ac3 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Jun 2025 10:14:17 +0530 Subject: [PATCH 14/79] Fix space navigation & view space timeline dev-option (#2358) * fix inaccessible space on alias change * fix new room in space open in home * allow opening space timeline * hide event timeline feature behind dev tool * add navToActivePath to clear cache function --- src/app/hooks/useRoomNavigate.ts | 16 ++++++-- src/app/pages/client/sidebar/SpaceTabs.tsx | 5 ++- src/app/pages/client/space/RoomProvider.tsx | 45 ++++++++++++++++----- src/app/pages/client/space/Space.tsx | 20 +++++++++ src/app/state/navToActivePath.ts | 8 +++- src/client/initMatrix.ts | 2 + 6 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 0f9f365c..e626c06b 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -13,6 +13,8 @@ import { getOrphanParents } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { mDirectAtom } from '../state/mDirectList'; import { useSelectedSpace } from './router/useSelectedSpace'; +import { settingsAtom } from '../state/settings'; +import { useSetting } from '../state/hooks/settings'; export const useRoomNavigate = () => { const navigate = useNavigate(); @@ -20,6 +22,7 @@ export const useRoomNavigate = () => { const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); const spaceSelectedId = useSelectedSpace(); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const navigateSpace = useCallback( (roomId: string) => { @@ -32,15 +35,22 @@ export const useRoomNavigate = () => { const navigateRoom = useCallback( (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - const orphanParents = getOrphanParents(roomToParents, roomId); + const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId( mx, spaceSelectedId && orphanParents.includes(spaceSelectedId) ? spaceSelectedId - : orphanParents[0] + : orphanParents[0] // TODO: better orphan parent selection. ); + + if (openSpaceTimeline) { + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomId, eventId), opts); + return; + } + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } @@ -52,7 +62,7 @@ export const useRoomNavigate = () => { navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); }, - [mx, navigate, spaceSelectedId, roomToParents, mDirects] + [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); return { diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 5b47cb52..011741ee 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -744,13 +744,14 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { const targetSpaceId = target.getAttribute('data-id'); if (!targetSpaceId) return; + const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); if (screenSize === ScreenSize.Mobile) { - navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + navigate(spacePath); return; } const activePath = navToActivePath.get(targetSpaceId); - if (activePath) { + if (activePath && activePath.pathname.startsWith(spacePath)) { navigate(joinPathComponent(activePath)); return; } diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index a9632137..0fd52ab6 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -1,21 +1,24 @@ import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useSpace } from '../../../hooks/useSpace'; -import { getAllParents } from '../../../utils/room'; +import { getAllParents, getSpaceChildren } from '../../../utils/room'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { mDirectAtom } from '../../../state/mDirectList'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); const space = useSpace(); - const roomToParents = useAtomValue(roomToParentsAtom); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); const allRooms = useAtomValue(allRoomsAtom); @@ -24,12 +27,36 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); - if ( - !room || - room.isSpaceRoom() || - !allRooms.includes(room.roomId) || - !getAllParents(roomToParents, room.roomId).has(space.roomId) - ) { + if (!room || !allRooms.includes(room.roomId)) { + // room is not joined + return ( + + ); + } + + if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) { + // allow to view space timeline + return ( + + {children} + + ); + } + + if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) { + if (getSpaceChildren(space).includes(room.roomId)) { + // fill missing roomToParent mapping + setRoomToParents({ + type: 'PUT', + parent: space.roomId, + children: [room.roomId], + }); + } + return ( (({ room, requestClose }, ref) => { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const openSpaceSettings = useOpenSpaceSettings(); + const { navigateRoom } = useRoomNavigate(); const allChild = useSpaceChildren( allRoomsAtom, @@ -118,6 +121,11 @@ const SpaceMenu = forwardRef(({ room, requestClo requestClose(); }; + const handleOpenTimeline = () => { + navigateRoom(room.roomId); + requestClose(); + }; + return ( @@ -168,6 +176,18 @@ const SpaceMenu = forwardRef(({ room, requestClo Space Settings + {developerTools && ( + } + radii="300" + > + + Event Timeline + + + )} diff --git a/src/app/state/navToActivePath.ts b/src/app/state/navToActivePath.ts index 80869146..af90c914 100644 --- a/src/app/state/navToActivePath.ts +++ b/src/app/state/navToActivePath.ts @@ -9,6 +9,8 @@ import { const NAV_TO_ACTIVE_PATH = 'navToActivePath'; +const getStoreKey = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`; + type NavToActivePath = Map; type NavToActivePathAction = @@ -25,7 +27,7 @@ type NavToActivePathAction = export type NavToActivePathAtom = WritableAtom; export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => { - const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`; + const storeKey = getStoreKey(userId); const baseNavToActivePathAtom = atomWithLocalStorage( storeKey, @@ -64,3 +66,7 @@ export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => return navToActivePathAtom; }; + +export const clearNavToActivePathStore = (userId: string) => { + localStorage.removeItem(getStoreKey(userId)); +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b513e27c..b80a080f 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,6 +1,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; import { cryptoCallbacks } from './state/secretStorageKeys'; +import { clearNavToActivePathStore } from '../app/state/navToActivePath'; type Session = { baseUrl: string; @@ -46,6 +47,7 @@ export const startClient = async (mx: MatrixClient) => { export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); + clearNavToActivePathStore(mx.getSafeUserId()); await mx.store.deleteAllData(); window.location.reload(); }; From 44347db6e4f53cfe71f81d10891d3e6d43852d37 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:17:46 +0530 Subject: [PATCH 15/79] Add allow from currently selected space if no m.space.parent found (#2359) --- .../common-settings/general/RoomJoinRules.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index ebd4cad5..c0d62a6a 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { color, Text } from 'folds'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; +import { useAtomValue } from 'jotai'; import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { ExtendedJoinRules, @@ -20,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { getStateEvents } from '../../../utils/room'; +import { + useRecursiveChildSpaceScopeFactory, + useSpaceChildren, +} from '../../../state/hooks/roomList'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; type RestrictedRoomAllowContent = { room_id: string; @@ -36,7 +43,11 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const allowKnockRestricted = roomVersion >= 10; const allowRestricted = roomVersion >= 8; const allowKnock = roomVersion >= 7; + + const roomIdToParents = useAtomValue(roomToParentsAtom); const space = useSpaceOptionally(); + const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents); + const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope); const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); const canEdit = powerLevelAPI.canSendStateEvent( @@ -74,9 +85,22 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { async (joinRule: ExtendedJoinRules) => { const allow: RestrictedRoomAllowContent[] = []; if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') { - const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) => - event.getStateKey() - ); + const roomParents = roomIdToParents.get(room.roomId); + + const parents = getStateEvents(room, StateEvent.SpaceParent) + .map((event) => event.getStateKey()) + .filter((parentId) => typeof parentId === 'string') + .filter((parentId) => roomParents?.has(parentId)); + + if (parents.length === 0 && space && roomParents) { + // if no m.space.parent found + // find parent in current space + const selectedParents = subspaces.filter((rId) => roomParents.has(rId)); + if (roomParents.has(space.roomId)) { + selectedParents.push(space.roomId); + } + selectedParents.forEach((pId) => parents.push(pId)); + } parents.forEach((parentRoomId) => { if (!parentRoomId) return; allow.push({ @@ -92,7 +116,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { if (allow.length > 0) c.allow = allow; await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); }, - [mx, room] + [mx, room, space, subspaces, roomIdToParents] ) ); From 3ed826087727c01074302b5663c78f856f6968a8 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:18:55 +0530 Subject: [PATCH 16/79] Release v4.8.1 (#2360) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- src/client/state/cons.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d82c219..7dd2bf0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.8.0", + "version": "4.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.8.0", + "version": "4.8.1", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index b1b14e55..3c1cef8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.8.0", + "version": "4.8.1", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 30f4b3ca..ff2fdb9b 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.8.0 + v4.8.1 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 88d38981..645753ff 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.8.0 + v4.8.1 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 3e9306b2..1cb8b102 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.8.0', + version: '4.8.1', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From 87ce209050472d51961e1a403dbd63ec0043e5d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:29:55 +1000 Subject: [PATCH 17/79] Bump actions/setup-node from 4.3.0 to 4.4.0 (#2307) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.3.0 to 4.4.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4.3.0...v4.4.0) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 4.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/netlify-dev.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 441da0de..450e4e29 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Setup node - uses: actions/setup-node@v4.3.0 + uses: actions/setup-node@v4.4.0 with: node-version: 20.12.2 cache: 'npm' diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index 34308c21..66cd5ad5 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Setup node - uses: actions/setup-node@v4.3.0 + uses: actions/setup-node@v4.4.0 with: node-version: 20.12.2 cache: 'npm' diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 44205ff2..b11da5be 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Setup node - uses: actions/setup-node@v4.3.0 + uses: actions/setup-node@v4.4.0 with: node-version: 20.12.2 cache: 'npm' From ba72925d53dac5e4a06d85dfaef68ee2c8b1caf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:52:03 +1000 Subject: [PATCH 18/79] Bump docker/build-push-action from 6.15.0 to 6.18.0 (#2351) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.15.0 to 6.18.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.15.0...v6.18.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 6.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-pr.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 4e88c78d..398785ab 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Build Docker image - uses: docker/build-push-action@v6.15.0 + uses: docker/build-push-action@v6.18.0 with: context: . push: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index b11da5be..0a758c51 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -90,7 +90,7 @@ jobs: ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@v6.15.0 + uses: docker/build-push-action@v6.18.0 with: context: . platforms: linux/amd64,linux/arm64 From 05e83eabef9b72ad36849f29d62ce8ac103a8308 Mon Sep 17 00:00:00 2001 From: Priyansh <157942154+Priyansh1547@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:50:28 +0530 Subject: [PATCH 19/79] Fix auto focus in "Join with Address" text input (#2317) --- src/app/organisms/join-alias/JoinAlias.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/organisms/join-alias/JoinAlias.jsx b/src/app/organisms/join-alias/JoinAlias.jsx index 99cf6e6e..d4e313af 100644 --- a/src/app/organisms/join-alias/JoinAlias.jsx +++ b/src/app/organisms/join-alias/JoinAlias.jsx @@ -75,7 +75,7 @@ function JoinAliasContent({ term, requestClose }) { return (
- + {error && ( {error} From 461e730c345370fe9afa422127c9b112fef31288 Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Sat, 28 Jun 2025 12:35:59 +0200 Subject: [PATCH 20/79] Make "View Source" a developer tool (#2368) --- src/app/features/room/RoomTimeline.tsx | 10 ++++++++++ src/app/features/room/message/Message.tsx | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 05caf4b0..773e115b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -448,6 +448,7 @@ export function RoomTimeline({ const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const ignoredUsersList = useIgnoredUsers(); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); @@ -1065,6 +1066,7 @@ export function RoomTimeline({ ) } hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} powerLevelTag={getPowerLevelTag(senderPowerLevel)} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} @@ -1146,6 +1148,7 @@ export function RoomTimeline({ ) } hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} powerLevelTag={getPowerLevelTag(senderPowerLevel)} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} @@ -1247,6 +1250,7 @@ export function RoomTimeline({ ) } hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} powerLevelTag={getPowerLevelTag(senderPowerLevel)} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} @@ -1292,6 +1296,7 @@ export function RoomTimeline({ messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} > ; legacyUsernameColor?: boolean; @@ -703,6 +704,7 @@ export const Message = as<'div', MessageProps>( reply, reactions, hideReadReceipts, + showDeveloperTools, powerLevelTag, accessibleTagColors, legacyUsernameColor, @@ -1026,7 +1028,13 @@ export const Message = as<'div', MessageProps>( onClose={closeMenu} /> )} - + {showDeveloperTools && ( + + )} {canPinEvent && ( @@ -1101,6 +1109,7 @@ export type EventProps = { canDelete?: boolean; messageSpacing: MessageSpacing; hideReadReceipts?: boolean; + showDeveloperTools?: boolean; }; export const Event = as<'div', EventProps>( ( @@ -1112,6 +1121,7 @@ export const Event = as<'div', EventProps>( canDelete, messageSpacing, hideReadReceipts, + showDeveloperTools, children, ...props }, @@ -1188,7 +1198,13 @@ export const Event = as<'div', EventProps>( onClose={closeMenu} /> )} - + {showDeveloperTools && ( + + )} {((!mEvent.isRedacted() && canDelete && !stateEvent) || From 77ab37f637c3f7d597c4ae3660fc5dedaa6a3e71 Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Sat, 28 Jun 2025 16:45:21 +0200 Subject: [PATCH 21/79] Fix focus behaviour when opening single-purpose features (#2349) * Improve focus behaviour on search boxes and chats * Implemented MR #2317 * Fix crash if canMessage is false * Prepare for PR #2335 * disable autofocus on message field --- src/app/features/message-search/SearchInput.tsx | 1 + src/app/organisms/invite-user/InviteUser.jsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx index db646c26..533eb5fd 100644 --- a/src/app/features/message-search/SearchInput.tsx +++ b/src/app/features/message-search/SearchInput.tsx @@ -29,6 +29,7 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset ref={searchInputRef} style={{ paddingRight: config.space.S300 }} name="searchInput" + autoFocus size="500" variant="Background" placeholder="Search for keyword" diff --git a/src/app/organisms/invite-user/InviteUser.jsx b/src/app/organisms/invite-user/InviteUser.jsx index c5bade69..271c22a9 100644 --- a/src/app/organisms/invite-user/InviteUser.jsx +++ b/src/app/organisms/invite-user/InviteUser.jsx @@ -273,7 +273,7 @@ function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) { searchUser(usernameRef.current.value); }} > - + From ebe5beba1de7aab9b8bee50438d407d07bd37e6f Mon Sep 17 00:00:00 2001 From: RGBCube Date: Sun, 29 Jun 2025 10:43:47 +0000 Subject: [PATCH 22/79] Add support for more code highlight (#2355) --- src/app/plugins/react-prism/ReactPrism.tsx | 313 ++++++++++++++++++++- 1 file changed, 301 insertions(+), 12 deletions(-) diff --git a/src/app/plugins/react-prism/ReactPrism.tsx b/src/app/plugins/react-prism/ReactPrism.tsx index f93c6ef1..ab2e9320 100644 --- a/src/app/plugins/react-prism/ReactPrism.tsx +++ b/src/app/plugins/react-prism/ReactPrism.tsx @@ -2,18 +2,307 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react'; import Prism from 'prismjs'; -import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-typescript'; -import 'prismjs/components/prism-css'; -import 'prismjs/components/prism-sass'; -import 'prismjs/components/prism-swift'; -import 'prismjs/components/prism-rust'; -import 'prismjs/components/prism-go'; -import 'prismjs/components/prism-c'; -import 'prismjs/components/prism-cpp'; -import 'prismjs/components/prism-java'; -import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-abap.js'; +import 'prismjs/components/prism-abnf.js'; +import 'prismjs/components/prism-actionscript.js'; +import 'prismjs/components/prism-ada.js'; +import 'prismjs/components/prism-agda.js'; +import 'prismjs/components/prism-al.js'; +import 'prismjs/components/prism-antlr4.js'; +import 'prismjs/components/prism-apacheconf.js'; +import 'prismjs/components/prism-apex.js'; +import 'prismjs/components/prism-apl.js'; +import 'prismjs/components/prism-applescript.js'; +import 'prismjs/components/prism-aql.js'; +import 'prismjs/components/prism-arff.js'; +import 'prismjs/components/prism-armasm.js'; +import 'prismjs/components/prism-arturo.js'; +import 'prismjs/components/prism-asciidoc.js'; +import 'prismjs/components/prism-asm6502.js'; +import 'prismjs/components/prism-asmatmel.js'; +import 'prismjs/components/prism-aspnet.js'; +import 'prismjs/components/prism-autohotkey.js'; +import 'prismjs/components/prism-autoit.js'; +import 'prismjs/components/prism-avisynth.js'; +import 'prismjs/components/prism-avro-idl.js'; +import 'prismjs/components/prism-awk.js'; +import 'prismjs/components/prism-bash.js'; +import 'prismjs/components/prism-basic.js'; +import 'prismjs/components/prism-batch.js'; +import 'prismjs/components/prism-bbcode.js'; +import 'prismjs/components/prism-bbj.js'; +import 'prismjs/components/prism-bicep.js'; +import 'prismjs/components/prism-birb.js'; +import 'prismjs/components/prism-bnf.js'; +import 'prismjs/components/prism-bqn.js'; +import 'prismjs/components/prism-brainfuck.js'; +import 'prismjs/components/prism-brightscript.js'; +import 'prismjs/components/prism-bro.js'; +import 'prismjs/components/prism-bsl.js'; +import 'prismjs/components/prism-c.js'; +import 'prismjs/components/prism-cfscript.js'; +import 'prismjs/components/prism-cil.js'; +import 'prismjs/components/prism-cilkc.js'; +import 'prismjs/components/prism-cilkcpp.js'; +import 'prismjs/components/prism-clike.js'; +import 'prismjs/components/prism-clojure.js'; +import 'prismjs/components/prism-cmake.js'; +import 'prismjs/components/prism-cobol.js'; +import 'prismjs/components/prism-coffeescript.js'; +import 'prismjs/components/prism-concurnas.js'; +import 'prismjs/components/prism-cooklang.js'; +import 'prismjs/components/prism-coq.js'; +import 'prismjs/components/prism-cpp.js'; +import 'prismjs/components/prism-csharp.js'; +import 'prismjs/components/prism-cshtml.js'; +import 'prismjs/components/prism-csp.js'; +import 'prismjs/components/prism-css-extras.js'; +import 'prismjs/components/prism-css.js'; +import 'prismjs/components/prism-csv.js'; +import 'prismjs/components/prism-cue.js'; +import 'prismjs/components/prism-cypher.js'; +import 'prismjs/components/prism-d.js'; +import 'prismjs/components/prism-dart.js'; +import 'prismjs/components/prism-dataweave.js'; +import 'prismjs/components/prism-dax.js'; +import 'prismjs/components/prism-dhall.js'; +import 'prismjs/components/prism-diff.js'; +import 'prismjs/components/prism-dns-zone-file.js'; +import 'prismjs/components/prism-docker.js'; +import 'prismjs/components/prism-dot.js'; +import 'prismjs/components/prism-ebnf.js'; +import 'prismjs/components/prism-editorconfig.js'; +import 'prismjs/components/prism-eiffel.js'; +import 'prismjs/components/prism-ejs.js'; +import 'prismjs/components/prism-elixir.js'; +import 'prismjs/components/prism-elm.js'; +import 'prismjs/components/prism-erb.js'; +import 'prismjs/components/prism-erlang.js'; +import 'prismjs/components/prism-etlua.js'; +import 'prismjs/components/prism-excel-formula.js'; +import 'prismjs/components/prism-factor.js'; +import 'prismjs/components/prism-false.js'; +import 'prismjs/components/prism-firestore-security-rules.js'; +import 'prismjs/components/prism-flow.js'; +import 'prismjs/components/prism-fortran.js'; +import 'prismjs/components/prism-fsharp.js'; +import 'prismjs/components/prism-ftl.js'; +import 'prismjs/components/prism-gap.js'; +import 'prismjs/components/prism-gcode.js'; +import 'prismjs/components/prism-gdscript.js'; +import 'prismjs/components/prism-gedcom.js'; +import 'prismjs/components/prism-gettext.js'; +import 'prismjs/components/prism-gherkin.js'; +import 'prismjs/components/prism-git.js'; +import 'prismjs/components/prism-glsl.js'; +import 'prismjs/components/prism-gml.js'; +import 'prismjs/components/prism-gn.js'; +import 'prismjs/components/prism-go-module.js'; +import 'prismjs/components/prism-go.js'; +import 'prismjs/components/prism-gradle.js'; +import 'prismjs/components/prism-graphql.js'; +import 'prismjs/components/prism-groovy.js'; +import 'prismjs/components/prism-haml.js'; +import 'prismjs/components/prism-handlebars.js'; +import 'prismjs/components/prism-haskell.js'; +import 'prismjs/components/prism-haxe.js'; +import 'prismjs/components/prism-hcl.js'; +import 'prismjs/components/prism-hlsl.js'; +import 'prismjs/components/prism-hoon.js'; +import 'prismjs/components/prism-hpkp.js'; +import 'prismjs/components/prism-hsts.js'; +import 'prismjs/components/prism-http.js'; +import 'prismjs/components/prism-ichigojam.js'; +import 'prismjs/components/prism-icon.js'; +import 'prismjs/components/prism-icu-message-format.js'; +import 'prismjs/components/prism-idris.js'; +import 'prismjs/components/prism-iecst.js'; +import 'prismjs/components/prism-ignore.js'; +import 'prismjs/components/prism-inform7.js'; +import 'prismjs/components/prism-ini.js'; +import 'prismjs/components/prism-io.js'; +import 'prismjs/components/prism-j.js'; +import 'prismjs/components/prism-java.js'; +import 'prismjs/components/prism-javadoclike.js'; +import 'prismjs/components/prism-javascript.js'; +import 'prismjs/components/prism-javastacktrace.js'; +import 'prismjs/components/prism-jexl.js'; +import 'prismjs/components/prism-jolie.js'; +import 'prismjs/components/prism-jq.js'; +import 'prismjs/components/prism-js-extras.js'; +import 'prismjs/components/prism-js-templates.js'; +import 'prismjs/components/prism-json.js'; +import 'prismjs/components/prism-json5.js'; +import 'prismjs/components/prism-jsonp.js'; +import 'prismjs/components/prism-jsstacktrace.js'; +import 'prismjs/components/prism-jsx.js'; +import 'prismjs/components/prism-julia.js'; +import 'prismjs/components/prism-keepalived.js'; +import 'prismjs/components/prism-keyman.js'; +import 'prismjs/components/prism-kotlin.js'; +import 'prismjs/components/prism-kumir.js'; +import 'prismjs/components/prism-kusto.js'; +import 'prismjs/components/prism-latex.js'; +import 'prismjs/components/prism-latte.js'; +import 'prismjs/components/prism-less.js'; +import 'prismjs/components/prism-lilypond.js'; +import 'prismjs/components/prism-linker-script.js'; +import 'prismjs/components/prism-liquid.js'; +import 'prismjs/components/prism-lisp.js'; +import 'prismjs/components/prism-livescript.js'; +import 'prismjs/components/prism-llvm.js'; +import 'prismjs/components/prism-log.js'; +import 'prismjs/components/prism-lolcode.js'; +import 'prismjs/components/prism-lua.js'; +import 'prismjs/components/prism-magma.js'; +import 'prismjs/components/prism-makefile.js'; +import 'prismjs/components/prism-markdown.js'; +import 'prismjs/components/prism-markup-templating.js'; +import 'prismjs/components/prism-markup.js'; +import 'prismjs/components/prism-mata.js'; +import 'prismjs/components/prism-matlab.js'; +import 'prismjs/components/prism-maxscript.js'; +import 'prismjs/components/prism-mel.js'; +import 'prismjs/components/prism-mermaid.js'; +import 'prismjs/components/prism-metafont.js'; +import 'prismjs/components/prism-mizar.js'; +import 'prismjs/components/prism-mongodb.js'; +import 'prismjs/components/prism-monkey.js'; +import 'prismjs/components/prism-moonscript.js'; +import 'prismjs/components/prism-n1ql.js'; +import 'prismjs/components/prism-n4js.js'; +import 'prismjs/components/prism-nand2tetris-hdl.js'; +import 'prismjs/components/prism-naniscript.js'; +import 'prismjs/components/prism-nasm.js'; +import 'prismjs/components/prism-neon.js'; +import 'prismjs/components/prism-nevod.js'; +import 'prismjs/components/prism-nginx.js'; +import 'prismjs/components/prism-nim.js'; +import 'prismjs/components/prism-nix.js'; +import 'prismjs/components/prism-nsis.js'; +import 'prismjs/components/prism-objectivec.js'; +import 'prismjs/components/prism-ocaml.js'; +import 'prismjs/components/prism-odin.js'; +import 'prismjs/components/prism-opencl.js'; +import 'prismjs/components/prism-openqasm.js'; +import 'prismjs/components/prism-oz.js'; +import 'prismjs/components/prism-parigp.js'; +import 'prismjs/components/prism-parser.js'; +import 'prismjs/components/prism-pascal.js'; +import 'prismjs/components/prism-pascaligo.js'; +import 'prismjs/components/prism-pcaxis.js'; +import 'prismjs/components/prism-peoplecode.js'; +import 'prismjs/components/prism-perl.js'; +import 'prismjs/components/prism-php-extras.js'; +import 'prismjs/components/prism-php.js'; +import 'prismjs/components/prism-phpdoc.js'; +import 'prismjs/components/prism-plant-uml.js'; +import 'prismjs/components/prism-powerquery.js'; +import 'prismjs/components/prism-powershell.js'; +import 'prismjs/components/prism-processing.js'; +import 'prismjs/components/prism-prolog.js'; +import 'prismjs/components/prism-promql.js'; +import 'prismjs/components/prism-properties.js'; +import 'prismjs/components/prism-protobuf.js'; +import 'prismjs/components/prism-psl.js'; +import 'prismjs/components/prism-pug.js'; +import 'prismjs/components/prism-puppet.js'; +import 'prismjs/components/prism-pure.js'; +import 'prismjs/components/prism-purebasic.js'; +import 'prismjs/components/prism-purescript.js'; +import 'prismjs/components/prism-python.js'; +import 'prismjs/components/prism-q.js'; +import 'prismjs/components/prism-qml.js'; +import 'prismjs/components/prism-qore.js'; +import 'prismjs/components/prism-qsharp.js'; +import 'prismjs/components/prism-r.js'; +import 'prismjs/components/prism-reason.js'; +import 'prismjs/components/prism-regex.js'; +import 'prismjs/components/prism-rego.js'; +import 'prismjs/components/prism-renpy.js'; +import 'prismjs/components/prism-rescript.js'; +import 'prismjs/components/prism-rest.js'; +import 'prismjs/components/prism-rip.js'; +import 'prismjs/components/prism-roboconf.js'; +import 'prismjs/components/prism-robotframework.js'; +import 'prismjs/components/prism-ruby.js'; +import 'prismjs/components/prism-rust.js'; +import 'prismjs/components/prism-sas.js'; +import 'prismjs/components/prism-sass.js'; +import 'prismjs/components/prism-scala.js'; +import 'prismjs/components/prism-scheme.js'; +import 'prismjs/components/prism-scss.js'; +import 'prismjs/components/prism-shell-session.js'; +import 'prismjs/components/prism-smali.js'; +import 'prismjs/components/prism-smalltalk.js'; +import 'prismjs/components/prism-smarty.js'; +import 'prismjs/components/prism-sml.js'; +import 'prismjs/components/prism-solidity.js'; +import 'prismjs/components/prism-solution-file.js'; +import 'prismjs/components/prism-soy.js'; +import 'prismjs/components/prism-splunk-spl.js'; +import 'prismjs/components/prism-sqf.js'; +import 'prismjs/components/prism-sql.js'; +import 'prismjs/components/prism-squirrel.js'; +import 'prismjs/components/prism-stan.js'; +import 'prismjs/components/prism-stata.js'; +import 'prismjs/components/prism-stylus.js'; +import 'prismjs/components/prism-supercollider.js'; +import 'prismjs/components/prism-swift.js'; +import 'prismjs/components/prism-systemd.js'; +import 'prismjs/components/prism-t4-templating.js'; +import 'prismjs/components/prism-t4-vb.js'; +import 'prismjs/components/prism-tap.js'; +import 'prismjs/components/prism-tcl.js'; +import 'prismjs/components/prism-textile.js'; +import 'prismjs/components/prism-toml.js'; +import 'prismjs/components/prism-tremor.js'; +import 'prismjs/components/prism-tsx.js'; +import 'prismjs/components/prism-tt2.js'; +import 'prismjs/components/prism-turtle.js'; +import 'prismjs/components/prism-twig.js'; +import 'prismjs/components/prism-typescript.js'; +import 'prismjs/components/prism-typoscript.js'; +import 'prismjs/components/prism-unrealscript.js'; +import 'prismjs/components/prism-uorazor.js'; +import 'prismjs/components/prism-uri.js'; +import 'prismjs/components/prism-v.js'; +import 'prismjs/components/prism-vala.js'; +import 'prismjs/components/prism-vbnet.js'; +import 'prismjs/components/prism-velocity.js'; +import 'prismjs/components/prism-verilog.js'; +import 'prismjs/components/prism-vhdl.js'; +import 'prismjs/components/prism-vim.js'; +import 'prismjs/components/prism-visual-basic.js'; +import 'prismjs/components/prism-warpscript.js'; +import 'prismjs/components/prism-wasm.js'; +import 'prismjs/components/prism-web-idl.js'; +import 'prismjs/components/prism-wgsl.js'; +import 'prismjs/components/prism-wiki.js'; +import 'prismjs/components/prism-wolfram.js'; +import 'prismjs/components/prism-wren.js'; +import 'prismjs/components/prism-xeora.js'; +import 'prismjs/components/prism-xml-doc.js'; +import 'prismjs/components/prism-xojo.js'; +import 'prismjs/components/prism-xquery.js'; +import 'prismjs/components/prism-yaml.js'; +import 'prismjs/components/prism-yang.js'; +import 'prismjs/components/prism-zig.js'; +import 'prismjs/components/prism-arduino.js'; + +// Broken: +// +// import 'prismjs/components/prism-bison.js'; +// import 'prismjs/components/prism-chaiscript.js'; +// import 'prismjs/components/prism-core.js'; +// import 'prismjs/components/prism-crystal.js'; +// import 'prismjs/components/prism-django.js'; +// import 'prismjs/components/prism-javadoc.js'; +// import 'prismjs/components/prism-jsdoc.js'; +// import 'prismjs/components/prism-plsql.js'; +// import 'prismjs/components/prism-racket.js'; +// import 'prismjs/components/prism-sparql.js'; +// import 'prismjs/components/prism-t4-cs.js'; import './ReactPrism.css'; // using classNames .prism-dark .prism-light from ReactPrism.css From 87fc490c3bf15fe98cf946325dfa835541c93da7 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 5 Jul 2025 17:01:15 +0530 Subject: [PATCH 23/79] Fix new direct message showing with room (#2386) as we were mutating the content of m.direct the sdk was comparing old value with new one and preventing update if found equal --- src/client/action/room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/action/room.js b/src/client/action/room.js index 90b74810..767914b5 100644 --- a/src/client/action/room.js +++ b/src/client/action/room.js @@ -12,7 +12,7 @@ function addRoomToMDirect(mx, roomId, userId) { const mDirectsEvent = mx.getAccountData('m.direct'); let userIdToRoomIds = {}; - if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = mDirectsEvent.getContent(); + if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); // remove it from the lists of any others users // (it can only be a DM room for one person) From 54ba1096d7e1a1ce4e8f34e0065726b6164cff60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Jul 2025 21:38:01 +1000 Subject: [PATCH 24/79] Bump nginx from 1.27.4-alpine to 1.29.0-alpine (#2382) Bumps nginx from 1.27.4-alpine to 1.29.0-alpine. --- updated-dependencies: - dependency-name: nginx dependency-version: 1.29.0-alpine dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index abb65ee5..718fed72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN npm run build ## App -FROM nginx:1.27.4-alpine +FROM nginx:1.29.0-alpine COPY --from=builder /src/dist /app COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf From 3fd8a18157f61c9560323c7f4c54fc690b98332e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Jul 2025 21:49:14 +1000 Subject: [PATCH 25/79] Bump dawidd6/action-download-artifact from 9 to 11 (#2364) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 9 to 11. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/07ab29fd4a977ae4d2b275087cf67563dfdf0295...ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '11' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index 9c0bea78..b330c3c1 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Download pr number - uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} @@ -24,7 +24,7 @@ jobs: id: pr run: echo "id=$(> $GITHUB_OUTPUT - name: Download artifact - uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} From d0a7ef31bc3041f981994d78272509dc41da7cf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Jul 2025 21:51:29 +1000 Subject: [PATCH 26/79] Bump softprops/action-gh-release from 2.2.1 to 2.3.2 (#2363) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.2.1 to 2.3.2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda...72f2c25fcb47643c292f7107632f7a47c1df5cd8) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.3.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/prod-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 0a758c51..d4a814b0 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -52,7 +52,7 @@ jobs: gpg --export | xxd -p echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz - name: Upload tagged release - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 with: files: | cinny-${{ steps.vars.outputs.tag }}.tar.gz From c757b8967fcfc4c06b070e1f52266fa95dc1c4ac Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 5 Jul 2025 21:52:35 +1000 Subject: [PATCH 27/79] Update dependency vite to v5.4.19 [SECURITY] (#2326) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7dd2bf0a..3fc29010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "prettier": "2.8.1", "sass": "1.56.2", "typescript": "4.9.4", - "vite": "5.4.15", + "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", "vite-plugin-top-level-await": "1.4.4" @@ -11331,9 +11331,9 @@ } }, "node_modules/vite": { - "version": "5.4.15", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", - "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", diff --git a/package.json b/package.json index 3c1cef8c..81d0e20a 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "prettier": "2.8.1", "sass": "1.56.2", "typescript": "4.9.4", - "vite": "5.4.15", + "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", "vite-plugin-top-level-await": "1.4.4" 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 28/79] 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 29/79] 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 30/79] 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 31/79] 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 32/79] 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 33/79] 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 34/79] 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 35/79] 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 36/79] 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 37/79] 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 => { From 9183fd66b2dc50cc113d387ca246eb45b96c448d Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:13:00 +0300 Subject: [PATCH 38/79] Add settings to enable 24-hour time format and customizable date format (#2347) * Add setting to enable 24-hour time format * added hour24Clock to TimeProps * Add incomplete dateFormatString setting * Move 24-hour toggle to Appearance * Add "Date & Time" subheading, cleanup after merge * Add setting for date formatting * Fix minor formatting and naming issues * Document functions * adress most comments * add hint for date formatting * add support for 24hr time to TimePicker * prevent overflow on small displays --- src/app/atoms/time/Time.jsx | 33 +- src/app/components/message/Time.tsx | 26 +- src/app/components/room-intro/RoomIntro.tsx | 6 +- src/app/components/time-date/TimePicker.tsx | 117 +++--- .../features/message-search/MessageSearch.tsx | 5 + .../message-search/SearchResultGroup.tsx | 10 +- src/app/features/room/RoomTimeline.tsx | 51 ++- .../features/room/jump-to-time/JumpToTime.tsx | 6 +- src/app/features/room/message/Message.tsx | 11 +- .../room/room-pin-menu/RoomPinMenu.tsx | 9 +- .../features/settings/devices/DeviceTile.tsx | 8 +- src/app/features/settings/general/General.tsx | 361 +++++++++++++++++- src/app/hooks/useDateFormat.ts | 34 ++ src/app/pages/client/inbox/Invites.tsx | 66 +++- src/app/pages/client/inbox/Notifications.tsx | 14 +- src/app/state/settings.ts | 7 + src/app/utils/time.ts | 9 +- 17 files changed, 691 insertions(+), 82 deletions(-) create mode 100644 src/app/hooks/useDateFormat.ts diff --git a/src/app/atoms/time/Time.jsx b/src/app/atoms/time/Time.jsx index 750b958f..d7bbe439 100644 --- a/src/app/atoms/time/Time.jsx +++ b/src/app/atoms/time/Time.jsx @@ -4,10 +4,25 @@ import PropTypes from 'prop-types'; import dateFormat from 'dateformat'; import { isInSameDay } from '../../../util/common'; -function Time({ timestamp, fullTime }) { +/** + * Renders a formatted timestamp. + * + * Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true. + * For older messages, it shows the date and time. + * + * @param {number} timestamp - The timestamp to display. + * @param {boolean} [fullTime=false] - If true, always show the full date and time. + * @param {boolean} hour24Clock - Whether to use 24-hour time format. + * @param {string} dateFormatString - Format string for the date part. + * @returns {JSX.Element} A