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 1/3] 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 2/3] 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 3/3] 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);