diff --git a/src/app/organisms/profile-viewer/ProfileViewer.jsx b/src/app/organisms/profile-viewer/ProfileViewer.jsx index 26e3968..fec9189 100644 --- a/src/app/organisms/profile-viewer/ProfileViewer.jsx +++ b/src/app/organisms/profile-viewer/ProfileViewer.jsx @@ -11,7 +11,7 @@ import { selectRoom, openReusableContextMenu } from '../../../client/action/navi import * as roomActions from '../../../client/action/room'; import { - getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices + getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices, } from '../../../util/matrixUtil'; import { getEventCords } from '../../../util/common'; import colorMXID from '../../../util/colorMXID'; @@ -209,19 +209,18 @@ function ProfileFooter({ roomId, userId, onRequestClose }) { }; const toggleIgnore = async () => { - const ignoredUsers = mx.getIgnoredUsers(); - const uIndex = ignoredUsers.indexOf(userId); - if (uIndex >= 0) { - if (uIndex === -1) return; - ignoredUsers.splice(uIndex, 1); - } else ignoredUsers.push(userId); + const isIgnored = mx.getIgnoredUsers().includes(userId); try { setIsIgnoring(true); - await mx.setIgnoredUsers(ignoredUsers); + if (isIgnored) { + await roomActions.unignore([userId]); + } else { + await roomActions.ignore([userId]); + } if (isMountedRef.current === false) return; - setIsUserIgnored(uIndex < 0); + setIsUserIgnored(!isIgnored); setIsIgnoring(false); } catch { setIsIgnoring(false); diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx index 68919aa..8c390a0 100644 --- a/src/app/organisms/room/RoomViewCmdBar.jsx +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -8,13 +8,6 @@ import twemoji from 'twemoji'; import { twemojify } from '../../../util/twemojify'; import initMatrix from '../../../client/initMatrix'; -import { toggleMarkdown } from '../../../client/action/settings'; -import * as roomActions from '../../../client/action/room'; -import { - openCreateRoom, - openPublicRooms, - openInviteUser, -} from '../../../client/action/navigation'; import { getEmojiForCompletion } from '../emoji-board/custom-emoji'; import AsyncSearch from '../../../util/AsyncSearch'; @@ -22,37 +15,7 @@ import Text from '../../atoms/text/Text'; import ScrollView from '../../atoms/scroll/ScrollView'; import FollowingMembers from '../../molecules/following-members/FollowingMembers'; import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent'; - -const commands = [{ - name: 'markdown', - description: 'Toggle markdown for messages.', - exe: () => toggleMarkdown(), -}, { - name: 'startDM', - isOptions: true, - description: 'Start direct message with user. Example: /startDM/@johndoe.matrix.org', - exe: (roomId, searchTerm) => openInviteUser(undefined, searchTerm), -}, { - name: 'createRoom', - description: 'Create new room', - exe: () => openCreateRoom(), -}, { - name: 'join', - isOptions: true, - description: 'Join room with alias. Example: /join/#cinny:matrix.org', - exe: (roomId, searchTerm) => openPublicRooms(searchTerm), -}, { - name: 'leave', - description: 'Leave current room', - exe: (roomId) => { - roomActions.leave(roomId); - }, -}, { - name: 'invite', - isOptions: true, - description: 'Invite user to room. Example: /invite/@johndoe:matrix.org', - exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm), -}]; +import commands from './commands'; function CmdItem({ onClick, children }) { return ( @@ -71,16 +34,16 @@ function renderSuggestions({ prefix, option, suggestions }, fireCmd) { const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?'; return cmds.map((cmd) => ( { fireCmd({ prefix: cmdPrefix, option, - result: cmd, + result: commands[cmd], }); }} > - {`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`} + {`${cmd}${cmd.isOptions ? cmdOptString : ''}`} )); } @@ -209,8 +172,8 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { const mx = initMatrix.matrixClient; const setupSearch = { '/': () => { - asyncSearch.setup(commands, { keys: ['name'], isContain: true }); - setCmd({ prefix, suggestions: commands }); + asyncSearch.setup(Object.keys(commands), { isContain: true }); + setCmd({ prefix, suggestions: Object.keys(commands) }); }, ':': () => { const parentIds = initMatrix.roomList.getAllParentSpaces(roomId); @@ -242,8 +205,9 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { } function fireCmd(myCmd) { if (myCmd.prefix === '/') { - myCmd.result.exe(roomId, myCmd.option); - viewEvent.emit('cmd_fired'); + viewEvent.emit('cmd_fired', { + replace: `/${myCmd.result.name}`, + }); } if (myCmd.prefix === ':') { if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode); diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx index 930eae1..0a0a171 100644 --- a/src/app/organisms/room/RoomViewInput.jsx +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -21,6 +21,7 @@ import ScrollView from '../../atoms/scroll/ScrollView'; import { MessageReply } from '../../molecules/message/Message'; import StickerBoard from '../sticker-board/StickerBoard'; +import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog'; import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg'; @@ -33,6 +34,8 @@ import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg'; import FileIC from '../../../../public/res/ic/outlined/file.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; +import commands from './commands'; + const CMD_REGEX = /(^\/|:|@)(\S*)$/; let isTyping = false; let isCmdActivated = false; @@ -182,30 +185,54 @@ function RoomViewInput({ }; }, [roomId]); - const sendMessage = async () => { - requestAnimationFrame(() => deactivateCmdAndEmit()); - const msgBody = textAreaRef.current.value; + const sendBody = async (body, msgType = 'm.text') => { if (roomsInput.isSending(roomId)) return; - if (msgBody.trim() === '' && attachment === null) return; sendIsTyping(false); - roomsInput.setMessage(roomId, msgBody); + roomsInput.setMessage(roomId, body); if (attachment !== null) { roomsInput.setAttachment(roomId, attachment); } textAreaRef.current.disabled = true; textAreaRef.current.style.cursor = 'not-allowed'; - await roomsInput.sendInput(roomId); + await roomsInput.sendInput(roomId, msgType); textAreaRef.current.disabled = false; textAreaRef.current.style.cursor = 'unset'; focusInput(); textAreaRef.current.value = roomsInput.getMessage(roomId); - viewEvent.emit('message_sent'); textAreaRef.current.style.height = 'unset'; if (replyTo !== null) setReplyTo(null); }; + const processCommand = (cmdBody) => { + const spaceIndex = cmdBody.indexOf(' '); + const cmdName = cmdBody.slice(1, spaceIndex > -1 ? spaceIndex : undefined); + const cmdData = spaceIndex > -1 ? cmdBody.slice(spaceIndex + 1) : ''; + if (!commands[cmdName]) { + confirmDialog('Invalid Command', `"${cmdName}" is not a valid command.`, 'Alright'); + return; + } + if (['me', 'shrug'].includes(cmdName)) { + commands[cmdName].exe(roomId, cmdData, (message, msgType) => sendBody(message, msgType)); + return; + } + commands[cmdName].exe(roomId, cmdData); + }; + + const sendMessage = async () => { + requestAnimationFrame(() => deactivateCmdAndEmit()); + const msgBody = textAreaRef.current.value.trim(); + if (msgBody.startsWith('/')) { + processCommand(msgBody.trim()); + textAreaRef.current.value = ''; + textAreaRef.current.style.height = 'unset'; + return; + } + if (msgBody === '' && attachment === null) return; + sendBody(msgBody, 'm.text'); + }; + const handleSendSticker = async (data) => { roomsInput.sendSticker(roomId, data); }; diff --git a/src/app/organisms/room/commands.jsx b/src/app/organisms/room/commands.jsx new file mode 100644 index 0000000..410fd74 --- /dev/null +++ b/src/app/organisms/room/commands.jsx @@ -0,0 +1,211 @@ +import React from 'react'; +import './commands.scss'; + +import initMatrix from '../../../client/initMatrix'; +import * as roomActions from '../../../client/action/room'; +import { hasDMWith, hasDevices } from '../../../util/matrixUtil'; +import { selectRoom, openReusableDialog } from '../../../client/action/navigation'; + +import Text from '../../atoms/text/Text'; +import SettingTile from '../../molecules/setting-tile/SettingTile'; + +const MXID_REG = /^@\S+:\S+$/; +const ROOM_ID_ALIAS_REG = /^(#|!)\S+:\S+$/; +const ROOM_ID_REG = /^!\S+:\S+$/; +const MXC_REG = /^mxc:\/\/\S+$/; + +export function processMxidAndReason(data) { + let reason; + let idData = data; + const reasonMatch = data.match(/\s-r\s/); + if (reasonMatch) { + idData = data.slice(0, reasonMatch.index); + reason = data.slice(reasonMatch.index + reasonMatch[0].length); + if (reason.trim() === '') reason = undefined; + } + const rawIds = idData.split(' '); + const userIds = rawIds.filter((id) => id.match(MXID_REG)); + return { + userIds, + reason, + }; +} + +const commands = { + me: { + name: 'me', + description: 'Display action', + exe: (roomId, data, onSuccess) => { + const body = data.trim(); + if (body === '') return; + onSuccess(body, 'm.emote'); + }, + }, + shrug: { + name: 'shrug', + description: 'Send ¯\\_(ツ)_/¯ as message', + exe: (roomId, data, onSuccess) => onSuccess( + `¯\\_(ツ)_/¯${data.trim() !== '' ? ` ${data}` : ''}`, + 'm.text', + ), + }, + help: { + name: 'help', + description: 'View all commands', + // eslint-disable-next-line no-use-before-define + exe: () => openHelpDialog(), + }, + startdm: { + name: 'startdm', + description: 'Start DM with user. Example: /startdm userId1 userId2', + exe: async (roomId, data) => { + const mx = initMatrix.matrixClient; + const rawIds = data.split(' '); + const userIds = rawIds.filter((id) => id.match(MXID_REG) && id !== mx.getUserId()); + if (userIds.length === 0) return; + if (userIds.length === 1) { + const dmRoomId = hasDMWith(userIds[0]); + if (dmRoomId) { + selectRoom(dmRoomId); + return; + } + } + const devices = await Promise.all(userIds.map(hasDevices)); + const isEncrypt = devices.every((hasDevice) => hasDevice); + const result = await roomActions.createDM(userIds, isEncrypt); + selectRoom(result.room_id); + }, + }, + join: { + name: 'join', + description: 'Join room with alias. Example: /join alias1 alias2', + exe: (roomId, data) => { + const rawIds = data.split(' '); + const roomIds = rawIds.filter((id) => id.match(ROOM_ID_ALIAS_REG)); + roomIds.map((id) => roomActions.join(id)); + }, + }, + leave: { + name: 'leave', + description: 'Leave current room.', + exe: (roomId, data) => { + if (data.trim() === '') { + roomActions.leave(roomId); + return; + } + const rawIds = data.split(' '); + const roomIds = rawIds.filter((id) => id.match(ROOM_ID_REG)); + roomIds.map((id) => roomActions.leave(id)); + }, + }, + invite: { + name: 'invite', + description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]', + exe: (roomId, data) => { + const { userIds, reason } = processMxidAndReason(data); + userIds.map((id) => roomActions.invite(roomId, id, reason)); + }, + }, + disinvite: { + name: 'disinvite', + description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]', + exe: (roomId, data) => { + const { userIds, reason } = processMxidAndReason(data); + userIds.map((id) => roomActions.kick(roomId, id, reason)); + }, + }, + kick: { + name: 'kick', + description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]', + exe: (roomId, data) => { + const { userIds, reason } = processMxidAndReason(data); + userIds.map((id) => roomActions.kick(roomId, id, reason)); + }, + }, + ban: { + name: 'ban', + description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]', + exe: (roomId, data) => { + const { userIds, reason } = processMxidAndReason(data); + userIds.map((id) => roomActions.ban(roomId, id, reason)); + }, + }, + unban: { + name: 'unban', + description: 'Unban user from room. Example: /unban userId1 userId2', + exe: (roomId, data) => { + const rawIds = data.split(' '); + const userIds = rawIds.filter((id) => id.match(MXID_REG)); + userIds.map((id) => roomActions.unban(roomId, id)); + }, + }, + ignore: { + name: 'ignore', + description: 'Ignore user. Example: /ignore userId1 userId2', + exe: (roomId, data) => { + const rawIds = data.split(' '); + const userIds = rawIds.filter((id) => id.match(MXID_REG)); + if (userIds.length > 0) roomActions.ignore(userIds); + }, + }, + unignore: { + name: 'unignore', + description: 'Unignore user. Example: /unignore userId1 userId2', + exe: (roomId, data) => { + const rawIds = data.split(' '); + const userIds = rawIds.filter((id) => id.match(MXID_REG)); + if (userIds.length > 0) roomActions.unignore(userIds); + }, + }, + myroomnick: { + name: 'myroomnick', + description: 'Change my room nick', + exe: (roomId, data) => { + const nick = data.trim(); + if (nick === '') return; + roomActions.setMyRoomNick(roomId, nick); + }, + }, + myroomavatar: { + name: 'myroomavatar', + description: 'Change my room avatar. Example /myroomavatar mxc://xyzabc', + exe: (roomId, data) => { + if (data.match(MXC_REG)) { + roomActions.setMyRoomAvatar(roomId, data); + } + }, + }, + converttodm: { + name: 'converttodm', + description: 'Convert room to direct message', + exe: (roomId) => { + roomActions.convertToDm(roomId); + }, + }, + converttoroom: { + name: 'converttoroom', + description: 'Convert direct message to room', + exe: (roomId) => { + roomActions.convertToRoom(roomId); + }, + }, +}; + +function openHelpDialog() { + openReusableDialog( + Commands, + () => ( +
+ {Object.keys(commands).map((cmdName) => ( + {commands[cmdName].description}} + /> + ))} +
+ ), + ); +} + +export default commands; diff --git a/src/app/organisms/room/commands.scss b/src/app/organisms/room/commands.scss new file mode 100644 index 0000000..6283937 --- /dev/null +++ b/src/app/organisms/room/commands.scss @@ -0,0 +1,10 @@ +.commands-dialog { + & > * { + padding: var(--sp-tight) var(--sp-normal); + border-bottom: 1px solid var(--bg-surface-border); + &:last-child { + border-bottom: none; + margin-bottom: var(--sp-extra-loose); + } + } +} \ No newline at end of file diff --git a/src/client/action/room.js b/src/client/action/room.js index 1fe721d..a0a7525 100644 --- a/src/client/action/room.js +++ b/src/client/action/room.js @@ -6,7 +6,7 @@ import { getIdServer } from '../../util/matrixUtil'; /** * https://github.com/matrix-org/matrix-react-sdk/blob/1e6c6e9d800890c732d60429449bc280de01a647/src/Rooms.js#L73 * @param {string} roomId Id of room to add - * @param {string} userId User id to which dm + * @param {string} userId User id to which dm || undefined to remove * @returns {Promise} A promise */ function addRoomToMDirect(roomId, userId) { @@ -79,13 +79,23 @@ function guessDMRoomTargetId(room, myUserId) { return oldestMember.userId; } +function convertToDm(roomId) { + const mx = initMatrix.matrixClient; + const room = mx.getRoom(roomId); + return addRoomToMDirect(roomId, guessDMRoomTargetId(room, mx.getUserId())); +} + +function convertToRoom(roomId) { + return addRoomToMDirect(roomId, undefined); +} + /** * * @param {string} roomId * @param {boolean} isDM * @param {string[]} via */ -async function join(roomIdOrAlias, isDM, via) { +async function join(roomIdOrAlias, isDM = false, via = undefined) { const mx = initMatrix.matrixClient; const roomIdParts = roomIdOrAlias.split(':'); const viaServers = via || [roomIdParts[1]]; @@ -150,10 +160,10 @@ async function create(options, isDM = false) { } } -async function createDM(userId, isEncrypted = true) { +async function createDM(userIdOrIds, isEncrypted = true) { const options = { is_direct: true, - invite: [userId], + invite: Array.isArray(userIdOrIds) ? userIdOrIds : [userIdOrIds], visibility: 'private', preset: 'trusted_private_chat', initial_state: [], @@ -262,10 +272,10 @@ async function createRoom(opts) { return result; } -async function invite(roomId, userId) { +async function invite(roomId, userId, reason) { const mx = initMatrix.matrixClient; - const result = await mx.invite(roomId, userId); + const result = await mx.invite(roomId, userId, undefined, reason); return result; } @@ -290,6 +300,21 @@ async function unban(roomId, userId) { return result; } +async function ignore(userIds) { + const mx = initMatrix.matrixClient; + + let ignoredUsers = mx.getIgnoredUsers().concat(userIds); + ignoredUsers = [...new Set(ignoredUsers)]; + await mx.setIgnoredUsers(ignoredUsers); +} + +async function unignore(userIds) { + const mx = initMatrix.matrixClient; + + const ignoredUsers = mx.getIgnoredUsers(); + await mx.setIgnoredUsers(ignoredUsers.filter((id) => !userIds.includes(id))); +} + async function setPowerLevel(roomId, userId, powerLevel) { const mx = initMatrix.matrixClient; const room = mx.getRoom(roomId); @@ -300,9 +325,37 @@ async function setPowerLevel(roomId, userId, powerLevel) { return result; } +async function setMyRoomNick(roomId, nick) { + const mx = initMatrix.matrixClient; + const room = mx.getRoom(roomId); + const mEvent = room.currentState.getStateEvents('m.room.member', mx.getUserId()); + const content = mEvent?.getContent(); + if (!content) return; + await mx.sendStateEvent(roomId, 'm.room.member', { + ...content, + displayname: nick, + }, mx.getUserId()); +} + +async function setMyRoomAvatar(roomId, mxc) { + const mx = initMatrix.matrixClient; + const room = mx.getRoom(roomId); + const mEvent = room.currentState.getStateEvents('m.room.member', mx.getUserId()); + const content = mEvent?.getContent(); + if (!content) return; + await mx.sendStateEvent(roomId, 'm.room.member', { + ...content, + avatar_url: mxc, + }, mx.getUserId()); +} + export { + convertToDm, + convertToRoom, join, leave, createDM, createRoom, invite, kick, ban, unban, + ignore, unignore, setPowerLevel, + setMyRoomNick, setMyRoomAvatar, }; diff --git a/src/client/state/RoomList.js b/src/client/state/RoomList.js index b24379b..a157048 100644 --- a/src/client/state/RoomList.js +++ b/src/client/state/RoomList.js @@ -257,10 +257,10 @@ class RoomList extends EventEmitter { const latestMDirects = this.getMDirects(); latestMDirects.forEach((directId) => { - const myRoom = this.matrixClient.getRoom(directId); if (this.mDirects.has(directId)) return; this.mDirects.add(directId); + const myRoom = this.matrixClient.getRoom(directId); if (myRoom === null) return; if (myRoom.getMyMembership() === 'join') { this.directs.add(directId); @@ -268,6 +268,19 @@ class RoomList extends EventEmitter { this.emit(cons.events.roomList.ROOMLIST_UPDATED); } }); + + [...this.directs].forEach((directId) => { + if (latestMDirects.has(directId)) return; + this.mDirects.delete(directId); + + const myRoom = this.matrixClient.getRoom(directId); + if (myRoom === null) return; + if (myRoom.getMyMembership() === 'join') { + this.directs.delete(directId); + this.rooms.add(directId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + } + }); }); this.matrixClient.on('Room.name', (room) => { diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 03dd074..4277b2f 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -274,7 +274,7 @@ class RoomsInput extends EventEmitter { return this.roomIdToInput.get(roomId)?.isSending || false; } - async sendInput(roomId) { + async sendInput(roomId, msgType) { const room = this.matrixClient.getRoom(roomId); const input = this.getInput(roomId); input.isSending = true; @@ -288,7 +288,7 @@ class RoomsInput extends EventEmitter { const rawMessage = input.message; let content = { body: rawMessage, - msgtype: 'm.text', + msgtype: msgType ?? 'm.text', }; // Apply formatting if relevant @@ -459,12 +459,14 @@ class RoomsInput extends EventEmitter { const room = this.matrixClient.getRoom(roomId); const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; + const msgtype = mEvent.getWireContent().msgtype ?? 'm.text'; + const content = { body: ` * ${editedBody}`, - msgtype: 'm.text', + msgtype, 'm.new_content': { body: editedBody, - msgtype: 'm.text', + msgtype, }, 'm.relates_to': { event_id: mEvent.getId(),