From f73dc05e259cd16145304db0c40415df63d70e3c Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:23:32 +0530 Subject: [PATCH 001/138] add order algorithm in search result --- .../autocomplete/EmoticonAutocomplete.tsx | 4 +- src/app/components/emoji-board/EmojiBoard.tsx | 56 ++++++------ src/app/hooks/useAsyncSearch.ts | 89 ++++++++++++++++--- 3 files changed, 107 insertions(+), 42 deletions(-) diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index 591f1bff..9479a698 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -64,9 +64,7 @@ export function EmoticonAutocomplete({ }, [imagePacks]); const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS); - const autoCompleteEmoticon = (result ? result.items : recentEmoji).sort((a, b) => - a.shortcode.localeCompare(b.shortcode) - ); + const autoCompleteEmoticon = result ? result.items : recentEmoji; useEffect(() => { if (query.text) search(query.text); diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index f3bd551f..77e56a91 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -471,36 +471,34 @@ export function SearchEmojiGroup({ return ( {tab === EmojiBoardTab.Emoji - ? searchResult - .sort((a, b) => a.shortcode.localeCompare(b.shortcode)) - .map((emoji) => - 'unicode' in emoji ? ( - - {emoji.unicode} - - ) : ( - - {emoji.body - - ) + ? searchResult.map((emoji) => + 'unicode' in emoji ? ( + + {emoji.unicode} + + ) : ( + + {emoji.body + ) + ) : searchResult.map((emoji) => 'unicode' in emoji ? null : ( = export type SearchResetHandler = () => void; +const performMatch = ( + target: string | string[], + query: string, + options?: UseAsyncSearchOptions +): string | undefined => { + if (Array.isArray(target)) { + const matchTarget = target.find((i) => + matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions) + ); + return matchTarget ? normalize(matchTarget, options?.normalizeOptions) : undefined; + } + + const normalizedTargetStr = normalize(target, options?.normalizeOptions); + const matches = matchQuery(normalizedTargetStr, query, options?.matchOptions); + return matches ? normalizedTargetStr : undefined; +}; + +export const orderSearchItems = ( + query: string, + items: TSearchItem[], + getItemStr: SearchItemStrGetter, + options?: UseAsyncSearchOptions +): TSearchItem[] => { + const orderedItems: TSearchItem[] = Array.from(items); + + // we will consider "_" as word boundary char. + // because in more use-cases it is used. (like: emojishortcode) + const boundaryRegex = new RegExp(`(\\b|_)${query}`); + const perfectBoundaryRegex = new RegExp(`(\\b|_)${query}(\\b|_)`); + + orderedItems.sort((i1, i2) => { + const str1 = performMatch(getItemStr(i1, query), query, options); + const str2 = performMatch(getItemStr(i2, query), query, options); + + if (str1 === undefined && str2 === undefined) return 0; + if (str1 === undefined) return 1; + if (str2 === undefined) return -1; + + let points1 = 0; + let points2 = 0; + + // short string should score more + const pointsToSmallStr = (points: number) => { + if (str1.length < str2.length) points1 += points; + else if (str2.length < str1.length) points2 += points; + }; + pointsToSmallStr(1); + + // closes query match should score more + const indexIn1 = str1.indexOf(query); + const indexIn2 = str2.indexOf(query); + if (indexIn1 < indexIn2) points1 += 2; + else if (indexIn2 < indexIn1) points2 += 2; + else pointsToSmallStr(2); + + // query match word start on boundary should score more + const boundaryIn1 = str1.match(boundaryRegex); + const boundaryIn2 = str2.match(boundaryRegex); + if (boundaryIn1 && boundaryIn2) pointsToSmallStr(4); + else if (boundaryIn1) points1 += 4; + else if (boundaryIn2) points2 += 4; + + // query match word start and end on boundary should score more + const perfectBoundaryIn1 = str1.match(perfectBoundaryRegex); + const perfectBoundaryIn2 = str2.match(perfectBoundaryRegex); + if (perfectBoundaryIn1 && perfectBoundaryIn2) pointsToSmallStr(8); + else if (perfectBoundaryIn1) points1 += 8; + else if (perfectBoundaryIn2) points2 += 8; + + return points2 - points1; + }); + + return orderedItems; +}; + export const useAsyncSearch = ( list: TSearchItem[], getItemStr: SearchItemStrGetter, @@ -40,21 +115,15 @@ export const useAsyncSearch = ( const handleMatch: MatchHandler = (item, query) => { const itemStr = getItemStr(item, query); - if (Array.isArray(itemStr)) - return !!itemStr.find((i) => - matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions) - ); - return matchQuery( - normalize(itemStr, options?.normalizeOptions), - query, - options?.matchOptions - ); + + const strWithMatch = performMatch(itemStr, query, options); + return typeof strWithMatch === 'string'; }; const handleResult: ResultHandler = (results, query) => setResult({ query, - items: [...results], + items: orderSearchItems(query, results, getItemStr, options), }); return AsyncSearch(list, handleMatch, handleResult, options); From 2e0c7c4406d8cf6aab795c86bcf868415436f31e Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 19 Feb 2025 22:07:33 +1100 Subject: [PATCH 002/138] Fix link visible inside spoiler (#2215) * hide links in spoiler * prevent link click inside spoiler --- src/index.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/index.scss b/src/index.scss index 563f611f..14bf4749 100644 --- a/src/index.scss +++ b/src/index.scss @@ -427,6 +427,12 @@ a { text-decoration: underline; } } + +[data-mx-spoiler][aria-pressed='true'] a { + color: transparent; + pointer-events: none; +} + b { font-weight: var(--fw-medium); } From b3979b31c70266d115ae74d701d07929f935f2bf Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 19 Feb 2025 22:08:58 +1100 Subject: [PATCH 003/138] fix room activity indicator appearing on self typing (#2217) --- src/app/features/room-nav/RoomNavItem.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 19d04f35..ef59bf98 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -182,7 +182,9 @@ export function RoomNavItem({ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); - const typingMember = useRoomTypingMember(room.roomId); + const typingMember = useRoomTypingMember(room.roomId).filter( + (receipt) => receipt.userId !== mx.getUserId() + ); const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); @@ -219,7 +221,9 @@ export function RoomNavItem({ ( From d8d4bce287445e92ebab184aa5f9ae348bf34972 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 19 Feb 2025 22:13:29 +1100 Subject: [PATCH 004/138] add button to select all room pack as global pack (#2218) --- .../settings/emojis-stickers/GlobalPacks.tsx | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index 3413ec49..a9288728 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -79,6 +79,28 @@ function GlobalPackSelector({ }); }; + const addSelected = (adds: PackAddress[]) => { + setSelected((addresses) => { + const newAddresses = Array.from(addresses); + adds.forEach((address) => { + if (newAddresses.find((addr) => packAddressEqual(addr, address))) { + return; + } + newAddresses.push(address); + }); + return newAddresses; + }); + }; + + const removeSelected = (adds: PackAddress[]) => { + setSelected((addresses) => { + const newAddresses = addresses.filter( + (addr) => !adds.find((address) => packAddressEqual(addr, address)) + ); + return newAddresses; + }); + }; + const hasSelected = selected.length > 0; return ( @@ -115,9 +137,35 @@ function GlobalPackSelector({ {Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => { const room = mx.getRoom(roomId); if (!room) return null; + const roomPackAddresses = roomPacks + .map((pack) => pack.address) + .filter((addr) => addr !== undefined); + const allSelected = roomPackAddresses.every((addr) => + selected.find((address) => packAddressEqual(addr, address)) + ); + return ( - {room.name} + + + {room.name} + + + { + if (allSelected) { + removeSelected(roomPackAddresses); + return; + } + addSelected(roomPackAddresses); + }} + > + {allSelected ? 'Unselect All' : 'Select All'} + + + {roomPacks.map((pack) => { const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon); const avatarUrl = avatarMxc @@ -126,7 +174,7 @@ function GlobalPackSelector({ const { address } = pack; if (!address) return null; - const added = selected.find((addr) => packAddressEqual(addr, address)); + const added = !!selected.find((addr) => packAddressEqual(addr, address)); return ( } after={ - toggleSelect(address)} /> + toggleSelect(address)} + /> } /> From 9fe67da98ba7dbc389a30c4b82df2e05925448b3 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:30:54 +1100 Subject: [PATCH 005/138] sanitize string before used in regex to prevent crash (#2219) --- src/app/components/editor/output.ts | 3 ++- src/app/hooks/useAsyncSearch.ts | 5 +++-- src/app/plugins/react-custom-html-parser.tsx | 4 ++-- src/app/utils/regex.ts | 6 ++++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index d6136d99..c5ecc6de 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -5,6 +5,7 @@ import { BlockType } from './types'; import { CustomElement } from './slate'; import { parseBlockMD, parseInlineMD } from '../../plugins/markdown'; import { findAndReplace } from '../../utils/findAndReplace'; +import { sanitizeForRegex } from '../../utils/regex'; export type OutputOptions = { allowTextFormatting?: boolean; @@ -179,7 +180,7 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo export const trimCustomHtml = (customHtml: string) => customHtml.replace(/$/g, '').trim(); export const trimCommand = (cmdName: string, str: string) => { - const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`); + const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`); const match = str.match(cmdRegX); if (!match) return str; diff --git a/src/app/hooks/useAsyncSearch.ts b/src/app/hooks/useAsyncSearch.ts index 3fe7ee58..3852d3b9 100644 --- a/src/app/hooks/useAsyncSearch.ts +++ b/src/app/hooks/useAsyncSearch.ts @@ -10,6 +10,7 @@ import { matchQuery, ResultHandler, } from '../utils/AsyncSearch'; +import { sanitizeForRegex } from '../utils/regex'; export type UseAsyncSearchOptions = AsyncSearchOption & { matchOptions?: MatchQueryOption; @@ -55,8 +56,8 @@ export const orderSearchItems = ( // we will consider "_" as word boundary char. // because in more use-cases it is used. (like: emojishortcode) - const boundaryRegex = new RegExp(`(\\b|_)${query}`); - const perfectBoundaryRegex = new RegExp(`(\\b|_)${query}(\\b|_)`); + const boundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}`); + const perfectBoundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}(\\b|_)`); orderedItems.sort((i1, i2) => { const str1 = performMatch(getItemStr(i1, query), query, options); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 6a4d0530..cd683e36 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -21,7 +21,7 @@ import { mxcUrlToHttp, } from '../utils/matrix'; import { getMemberDisplayName } from '../utils/room'; -import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex'; +import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { findAndReplace } from '../utils/findAndReplace'; import { @@ -171,7 +171,7 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] => ); export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => { - const pattern = highlights.join('|'); + const pattern = highlights.map(sanitizeForRegex).join('|'); if (!pattern) return undefined; return new RegExp(pattern, 'gi'); }; diff --git a/src/app/utils/regex.ts b/src/app/utils/regex.ts index d7169062..0b98b0e2 100644 --- a/src/app/utils/regex.ts +++ b/src/app/utils/regex.ts @@ -1,3 +1,9 @@ +/** + * https://www.npmjs.com/package/escape-string-regexp + */ +export const sanitizeForRegex = (unsafeText: string): string => + unsafeText.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); + export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(? Date: Thu, 20 Feb 2025 18:32:44 +1100 Subject: [PATCH 006/138] fix autocomplete menu flickering issue (#2220) --- .../editor/autocomplete/AutocompleteMenu.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx index 5d2d917c..452fa1b4 100644 --- a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -5,6 +5,7 @@ import { Header, Menu, Scroll, config } from 'folds'; import * as css from './AutocompleteMenu.css'; import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard'; +import { useAlive } from '../../../hooks/useAlive'; type AutocompleteMenuProps = { requestClose: () => void; @@ -12,13 +13,22 @@ type AutocompleteMenuProps = { children: ReactNode; }; export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) { + const alive = useAlive(); + + const handleDeactivate = () => { + if (alive()) { + // The component is unmounted so we will not call for `requestClose` + requestClose(); + } + }; + return (
requestClose(), + onPostDeactivate: handleDeactivate, returnFocusOnDeactivate: false, clickOutsideDeactivates: true, allowOutsideClick: true, From 1b200eb676d2ffe457899cd4993cceba397fe74b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:14:38 +1100 Subject: [PATCH 007/138] Improve search result counts (#2221) * remove limit from emoji autocomplete * remove search limit from user mention * remove limit from room mention autocomplete * increase user search limit to 1000 * better search string selection for emoticons --- .../autocomplete/EmoticonAutocomplete.tsx | 20 ++++++-------- .../autocomplete/RoomMentionAutocomplete.tsx | 3 +-- .../autocomplete/UserMentionAutocomplete.tsx | 4 +-- src/app/components/emoji-board/EmojiBoard.tsx | 20 ++++++-------- src/app/features/room/MembersDrawer.tsx | 27 ++++++++++--------- src/app/plugins/utils.ts | 19 +++++++++++++ 6 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 src/app/plugins/utils.ts diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index 9479a698..cc0dff19 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -6,11 +6,7 @@ import { Room } from 'matrix-js-sdk'; import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteMenu } from './AutocompleteMenu'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { - SearchItemStrGetter, - UseAsyncSearchOptions, - useAsyncSearch, -} from '../../../hooks/useAsyncSearch'; +import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -20,6 +16,7 @@ import { useKeyDown } from '../../../hooks/useKeyDown'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji'; +import { getEmoticonSearchStr } from '../../../plugins/utils'; type EmoticonCompleteHandler = (key: string, shortcode: string) => void; @@ -33,16 +30,11 @@ type EmoticonAutocompleteProps = { }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 20, matchOptions: { contain: true, }, }; -const getEmoticonStr: SearchItemStrGetter = (emoticon) => [ - `:${emoticon.shortcode}:`, -]; - export function EmoticonAutocomplete({ imagePackRooms, editor, @@ -63,8 +55,12 @@ export function EmoticonAutocomplete({ ); }, [imagePacks]); - const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS); - const autoCompleteEmoticon = result ? result.items : recentEmoji; + const [result, search, resetSearch] = useAsyncSearch( + searchList, + getEmoticonSearchStr, + SEARCH_OPTIONS + ); + const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji; useEffect(() => { if (query.text) search(query.text); diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 049be94a..cc431f58 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -65,7 +65,6 @@ type RoomMentionAutocompleteProps = { }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 20, matchOptions: { contain: true, }, @@ -97,7 +96,7 @@ export function RoomMentionAutocomplete({ SEARCH_OPTIONS ); - const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20); + const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20); useEffect(() => { if (query.text) search(query.text); diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 88ac9f39..d6c0f302 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -74,7 +74,7 @@ const withAllowedMembership = (member: RoomMember): boolean => member.membership === Membership.Knock; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 20, + limit: 1000, matchOptions: { contain: true, }, @@ -97,7 +97,7 @@ export function UserMentionAutocomplete({ const members = useRoomMembers(mx, roomId); const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); - const autoCompleteMembers = (result ? result.items : members.slice(0, 20)).filter( + const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter( withAllowedMembership ); diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 77e56a91..28735081 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -50,6 +50,7 @@ import { addRecentEmoji } from '../../plugins/recent-emoji'; import { mobileOrTablet } from '../../utils/user-agent'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji'; +import { getEmoticonSearchStr } from '../../plugins/utils'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -636,15 +637,8 @@ export const NativeEmojiGroups = memo( ) ); -const getSearchListItemStr = (item: PackImageReader | IEmoji) => { - const shortcode = `:${item.shortcode}:`; - if ('body' in item) { - return [shortcode, item.body ?? '']; - } - return shortcode; -}; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 26, + limit: 1000, matchOptions: { contain: true, }, @@ -696,10 +690,12 @@ export function EmojiBoard({ const [result, search, resetSearch] = useAsyncSearch( searchList, - getSearchListItemStr, + getEmoticonSearchStr, SEARCH_OPTIONS ); + const searchedItems = result?.items.slice(0, 100); + const handleOnChange: ChangeEventHandler = useDebounce( useCallback( (evt) => { @@ -920,13 +916,13 @@ export function EmojiBoard({ direction="Column" gap="200" > - {result && ( + {searchedItems && ( )} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index a4305e45..df8008ca 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -156,7 +156,7 @@ export type MembersFilterOptions = { }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 100, + limit: 1000, matchOptions: { contain: true, }, @@ -428,8 +428,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { }} after={} > - {`${result.items.length || 'No'} ${result.items.length === 1 ? 'Result' : 'Results' - }`} + {`${result.items.length || 'No'} ${ + result.items.length === 1 ? 'Result' : 'Results' + }`} ) } @@ -485,15 +486,17 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const member = tagOrMember; const name = getName(member); const avatarMxcUrl = member.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) : undefined; + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; return ( = (item) => { + const shortcode = `:${item.shortcode}:`; + if (item instanceof PackImageReader) { + if (item.body) { + return [shortcode, item.body]; + } + return shortcode; + } + + const names = [shortcode, item.label]; + if (Array.isArray(item.shortcodes)) { + return names.concat(item.shortcodes); + } + return names; +}; From 59e8d6625527678b053831c1b650e295771d9785 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:15:47 +1100 Subject: [PATCH 008/138] Add email notification toggle (#2223) * refactor system notification to dedicated file * add hook for email notification status * add toogle for email notifications in settings --- .../settings/notifications/Notifications.tsx | 74 +------- .../notifications/SystemNotification.tsx | 158 ++++++++++++++++++ src/app/hooks/useEmailNotifications.ts | 55 ++++++ 3 files changed, 215 insertions(+), 72 deletions(-) create mode 100644 src/app/features/settings/notifications/SystemNotification.tsx create mode 100644 src/app/hooks/useEmailNotifications.ts diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 88e16d29..aa339a03 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -1,82 +1,12 @@ import React from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, color } from 'folds'; +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 { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; +import { SystemNotification } from './SystemNotification'; import { AllMessagesNotifications } from './AllMessages'; import { SpecialMessagesNotifications } from './SpecialMessages'; import { KeywordMessagesNotifications } from './KeywordMessages'; import { IgnoredUserList } from './IgnoredUserList'; -function SystemNotification() { - const notifPermission = usePermissionState('notifications', getNotificationState()); - const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); - const [isNotificationSounds, setIsNotificationSounds] = useSetting( - settingsAtom, - 'isNotificationSounds' - ); - - const requestNotificationPermission = () => { - window.Notification.requestPermission(); - }; - - return ( - - System - - - {'Notification' in window - ? 'Notification permission is blocked. Please allow notification permission from browser address bar.' - : 'Notifications are not supported by the system.'} - - ) : ( - Show desktop notifications when message arrive. - ) - } - after={ - notifPermission === 'prompt' ? ( - - ) : ( - - ) - } - /> - - - } - /> - - - ); -} - type NotificationsProps = { requestClose: () => void; }; diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx new file mode 100644 index 00000000..e0df06df --- /dev/null +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -0,0 +1,158 @@ +import React, { useCallback } from 'react'; +import { Box, Text, Switch, Button, color, Spinner } from 'folds'; +import { IPusherRequest } from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; +import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; + +function EmailNotification() { + const mx = useMatrixClient(); + const [result, refreshResult] = useEmailNotifications(); + + const [setState, setEnable] = useAsyncCallback( + useCallback( + async (email: string, enable: boolean) => { + if (enable) { + await mx.setPusher({ + kind: 'email', + app_id: 'm.email', + pushkey: email, + app_display_name: 'Email Notifications', + device_display_name: email, + lang: 'en', + data: { + brand: 'Cinny', + }, + append: true, + }); + return; + } + await mx.setPusher({ + pushkey: email, + app_id: 'm.email', + kind: null, + } as unknown as IPusherRequest); + }, + [mx] + ) + ); + + const handleChange = (value: boolean) => { + if (result && result.email) { + setEnable(result.email, value).then(() => { + refreshResult(); + }); + } + }; + + return ( + + {result && !result.email && ( + + Your account does not have any email attached. + + )} + {result && result.email && <>Send notification to your email. {`("${result.email}")`}} + {result === null && ( + + Unexpected Error! + + )} + {result === undefined && 'Send notification to your email.'} + + } + after={ + <> + {setState.status !== AsyncStatus.Loading && + typeof result === 'object' && + result?.email && } + {(setState.status === AsyncStatus.Loading || result === undefined) && ( + + )} + + } + /> + ); +} + +export function SystemNotification() { + const notifPermission = usePermissionState('notifications', getNotificationState()); + const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [isNotificationSounds, setIsNotificationSounds] = useSetting( + settingsAtom, + 'isNotificationSounds' + ); + + const requestNotificationPermission = () => { + window.Notification.requestPermission(); + }; + + return ( + + System + + + {'Notification' in window + ? 'Notification permission is blocked. Please allow notification permission from browser address bar.' + : 'Notifications are not supported by the system.'} + + ) : ( + Show desktop notifications when message arrive. + ) + } + after={ + notifPermission === 'prompt' ? ( + + ) : ( + + ) + } + /> + + + } + /> + + + + + + ); +} diff --git a/src/app/hooks/useEmailNotifications.ts b/src/app/hooks/useEmailNotifications.ts new file mode 100644 index 00000000..58639394 --- /dev/null +++ b/src/app/hooks/useEmailNotifications.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { AsyncStatus, useAsyncCallbackValue } from './useAsyncCallback'; +import { useMatrixClient } from './useMatrixClient'; + +type RefreshHandler = () => void; + +type EmailNotificationResult = { + enabled: boolean; + email?: string; +}; + +export const useEmailNotifications = (): [ + EmailNotificationResult | undefined | null, + RefreshHandler +] => { + const mx = useMatrixClient(); + + const [emailState, refresh] = useAsyncCallbackValue( + useCallback(async () => { + const tpIDs = (await mx.getThreePids())?.threepids; + const emailAddresses = tpIDs.filter((id) => id.medium === 'email').map((id) => id.address); + if (emailAddresses.length === 0) + return { + enabled: false, + }; + + const pushers = (await mx.getPushers())?.pushers; + const emailPusher = pushers.find( + (pusher) => pusher.app_id === 'm.email' && emailAddresses.includes(pusher.pushkey) + ); + + if (emailPusher?.pushkey) { + return { + enabled: true, + email: emailPusher.pushkey, + }; + } + + return { + enabled: false, + email: emailAddresses[0], + }; + }, [mx]) + ); + + if (emailState.status === AsyncStatus.Success) { + return [emailState.data, refresh]; + } + + if (emailState.status === AsyncStatus.Error) { + return [null, refresh]; + } + + return [undefined, refresh]; +}; From b63868bbb56294cb800af05fc7a6cb975deb3a05 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:18:02 +1100 Subject: [PATCH 009/138] scroll to bottom in unfocused window but stop sending read receipt (#2214) * scroll to bottom in unfocused window but stop sending read receipt * send read-receipt when new message are in view after regaining focus --- src/app/features/room/RoomTimeline.tsx | 87 +++++++++++++++++--------- src/app/hooks/useVirtualPaginator.ts | 32 +++++++--- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 38b67baa..f6854b43 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -586,15 +586,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { + // Check if the document is in focus (user is actively viewing the app), + // and either there are no unread messages or the latest message is from the current user. + // If either condition is met, trigger the markAsRead function to send a read receipt. requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!)); } - if (document.hasFocus()) { - scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = true; - } else if (!unreadInfo) { + if (!document.hasFocus() && !unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } + + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = true; + setTimeline((ct) => ({ ...ct, range: { @@ -613,6 +617,36 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) ); + const handleOpenEvent = useCallback( + async ( + evtId: string, + highlight = true, + onScroll: ((scrolled: boolean) => void) | undefined = undefined + ) => { + const evtTimeline = getEventTimeline(room, evtId); + const absoluteIndex = + evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId); + + if (typeof absoluteIndex === 'number') { + const scrolled = scrollToItem(absoluteIndex, { + behavior: 'smooth', + align: 'center', + stopInView: true, + }); + if (onScroll) onScroll(scrolled); + setFocusItem({ + index: absoluteIndex, + scrollTo: false, + highlight, + }); + } else { + setTimeline(getEmptyTimeline()); + loadEventTimeline(evtId); + } + }, + [room, timeline, scrollToItem, loadEventTimeline] + ); + useLiveTimelineRefresh( room, useCallback(() => { @@ -646,16 +680,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); const tryAutoMarkAsRead = useCallback(() => { - if (!unreadInfo) { + const readUptoEventId = readUptoEventIdRef.current; + if (!readUptoEventId) { requestAnimationFrame(() => markAsRead(mx, room.roomId)); return; } - const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId); + const evtTimeline = getEventTimeline(room, readUptoEventId); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); if (latestTimeline === room.getLiveTimeline()) { requestAnimationFrame(() => markAsRead(mx, room.roomId)); } - }, [mx, room, unreadInfo]); + }, [mx, room]); const debounceSetAtBottom = useDebounce( useCallback((entry: IntersectionObserverEntry) => { @@ -672,7 +707,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry?.isIntersecting && atLiveEndRef.current) { setAtBottom(true); - tryAutoMarkAsRead(); + if (document.hasFocus()) { + tryAutoMarkAsRead(); + } } }, [debounceSetAtBottom, tryAutoMarkAsRead] @@ -691,10 +728,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback( (inFocus) => { if (inFocus && atBottomRef.current) { + if (unreadInfo?.inLiveTimeline) { + handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => { + // the unread event is already in view + // so, try mark as read; + if (!scrolled) { + tryAutoMarkAsRead(); + } + }); + return; + } tryAutoMarkAsRead(); } }, - [tryAutoMarkAsRead] + [tryAutoMarkAsRead, unreadInfo, handleOpenEvent] ) ); @@ -832,27 +879,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli async (evt) => { const targetId = evt.currentTarget.getAttribute('data-event-id'); if (!targetId) return; - const replyTimeline = getEventTimeline(room, targetId); - const absoluteIndex = - replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId); - - if (typeof absoluteIndex === 'number') { - scrollToItem(absoluteIndex, { - behavior: 'smooth', - align: 'center', - stopInView: true, - }); - setFocusItem({ - index: absoluteIndex, - scrollTo: false, - highlight: true, - }); - } else { - setTimeline(getEmptyTimeline()); - loadEventTimeline(targetId); - } + handleOpenEvent(targetId); }, - [room, timeline, scrollToItem, loadEventTimeline] + [handleOpenEvent] ); const handleUserClick: MouseEventHandler = useCallback( diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts index 9ffc7f91..5ad056a6 100644 --- a/src/app/hooks/useVirtualPaginator.ts +++ b/src/app/hooks/useVirtualPaginator.ts @@ -26,8 +26,23 @@ export type ScrollToOptions = { stopInView?: boolean; }; -export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => void; -export type ScrollToItem = (index: number, opts?: ScrollToOptions) => void; +/** + * Scrolls the page to a specified element in the DOM. + * + * @param {HTMLElement} element - The DOM element to scroll to. + * @param {ScrollToOptions} [opts] - Optional configuration for the scroll behavior (e.g., smooth scrolling, alignment). + * @returns {boolean} - Returns `true` if the scroll was successful, otherwise returns `false`. + */ +export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => boolean; + +/** + * Scrolls the page to an item at the specified index within a scrollable container. + * + * @param {number} index - The index of the item to scroll to. + * @param {ScrollToOptions} [opts] - Optional configuration for the scroll behavior (e.g., smooth scrolling, alignment). + * @returns {boolean} - Returns `true` if the scroll was successful, otherwise returns `false`. + */ +export type ScrollToItem = (index: number, opts?: ScrollToOptions) => boolean; type HandleObserveAnchor = (element: HTMLElement | null) => void; @@ -186,10 +201,10 @@ export const useVirtualPaginator = ( const scrollToElement = useCallback( (element, opts) => { const scrollElement = getScrollElement(); - if (!scrollElement) return; + if (!scrollElement) return false; if (opts?.stopInView && isInScrollView(scrollElement, element)) { - return; + return false; } let scrollTo = element.offsetTop; if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) { @@ -207,6 +222,7 @@ export const useVirtualPaginator = ( top: scrollTo - (opts?.offset ?? 0), behavior: opts?.behavior, }); + return true; }, [getScrollElement] ); @@ -215,7 +231,7 @@ export const useVirtualPaginator = ( (index, opts) => { const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current; - if (index < 0 || index >= currentCount) return; + if (index < 0 || index >= currentCount) return false; // index is not in range change range // and trigger scrollToItem in layoutEffect hook if (index < currentRange.start || index >= currentRange.end) { @@ -227,7 +243,7 @@ export const useVirtualPaginator = ( index, opts, }; - return; + return true; } // find target or it's previous rendered element to scroll to @@ -241,9 +257,9 @@ export const useVirtualPaginator = ( top: opts?.offset ?? 0, behavior: opts?.behavior, }); - return; + return true; } - scrollToElement(itemElement, opts); + return scrollToElement(itemElement, opts); }, [getScrollElement, scrollToElement, getItemElement, onRangeChange] ); From 7456c152b78d67e087643463c669364557c9acd8 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:19:24 +1100 Subject: [PATCH 010/138] Escape markdown sequences (#2208) * escape inline markdown character * fix typo * improve document around custom markdown plugin and add escape sequence utils * recover inline escape sequences on edit * remove escape sequences from plain text body * use `s` for strike-through instead of del * escape block markdown sequences * fix remove escape sequence was not removing all slashes from plain text * recover block sequences on edit --- src/app/components/editor/input.ts | 236 ++++++----- src/app/components/editor/output.ts | 23 +- src/app/features/room/RoomInput.tsx | 2 +- .../features/room/message/MessageEditor.tsx | 8 +- src/app/plugins/markdown.ts | 368 ------------------ src/app/plugins/markdown/block/index.ts | 1 + src/app/plugins/markdown/block/parser.ts | 47 +++ src/app/plugins/markdown/block/rules.ts | 100 +++++ src/app/plugins/markdown/block/runner.ts | 25 ++ src/app/plugins/markdown/block/type.ts | 30 ++ src/app/plugins/markdown/index.ts | 3 + src/app/plugins/markdown/inline/index.ts | 1 + src/app/plugins/markdown/inline/parser.ts | 40 ++ src/app/plugins/markdown/inline/rules.ts | 123 ++++++ src/app/plugins/markdown/inline/runner.ts | 62 +++ src/app/plugins/markdown/inline/type.ts | 26 ++ src/app/plugins/markdown/internal/index.ts | 1 + src/app/plugins/markdown/internal/utils.ts | 61 +++ src/app/plugins/markdown/utils.ts | 83 ++++ 19 files changed, 764 insertions(+), 476 deletions(-) delete mode 100644 src/app/plugins/markdown.ts create mode 100644 src/app/plugins/markdown/block/index.ts create mode 100644 src/app/plugins/markdown/block/parser.ts create mode 100644 src/app/plugins/markdown/block/rules.ts create mode 100644 src/app/plugins/markdown/block/runner.ts create mode 100644 src/app/plugins/markdown/block/type.ts create mode 100644 src/app/plugins/markdown/index.ts create mode 100644 src/app/plugins/markdown/inline/index.ts create mode 100644 src/app/plugins/markdown/inline/parser.ts create mode 100644 src/app/plugins/markdown/inline/rules.ts create mode 100644 src/app/plugins/markdown/inline/runner.ts create mode 100644 src/app/plugins/markdown/inline/type.ts create mode 100644 src/app/plugins/markdown/internal/index.ts create mode 100644 src/app/plugins/markdown/internal/utils.ts create mode 100644 src/app/plugins/markdown/utils.ts diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 20c56ed3..ad314add 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -26,48 +26,75 @@ import { testMatrixTo, } from '../../plugins/matrix-to'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { + escapeMarkdownInlineSequences, + escapeMarkdownBlockSequences, +} from '../../plugins/markdown'; -const markNodeToType: Record = { - b: MarkType.Bold, - strong: MarkType.Bold, - i: MarkType.Italic, - em: MarkType.Italic, - u: MarkType.Underline, - s: MarkType.StrikeThrough, - del: MarkType.StrikeThrough, - code: MarkType.Code, - span: MarkType.Spoiler, -}; +type ProcessTextCallback = (text: string) => string; -const elementToTextMark = (node: Element): MarkType | undefined => { - const markType = markNodeToType[node.name]; - if (!markType) return undefined; - - if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) { - return undefined; - } - if ( - markType === MarkType.Code && - node.parent && - 'name' in node.parent && - node.parent.name === 'pre' - ) { - return undefined; - } - return markType; -}; - -const parseNodeText = (node: ChildNode): string => { +const getText = (node: ChildNode): string => { if (isText(node)) { return node.data; } if (isTag(node)) { - return node.children.map((child) => parseNodeText(child)).join(''); + return node.children.map((child) => getText(child)).join(''); } return ''; }; -const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => { +const getInlineNodeMarkType = (node: Element): MarkType | undefined => { + if (node.name === 'b' || node.name === 'strong') { + return MarkType.Bold; + } + + if (node.name === 'i' || node.name === 'em') { + return MarkType.Italic; + } + + if (node.name === 'u') { + return MarkType.Underline; + } + + if (node.name === 's' || node.name === 'del') { + return MarkType.StrikeThrough; + } + + if (node.name === 'code') { + if (node.parent && 'name' in node.parent && node.parent.name === 'pre') { + return undefined; // Don't apply `Code` mark inside a
 tag
+    }
+    return MarkType.Code;
+  }
+
+  if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
+    return MarkType.Spoiler;
+  }
+
+  return undefined;
+};
+
+const getInlineMarkElement = (
+  markType: MarkType,
+  node: Element,
+  getChild: (child: ChildNode) => InlineElement[]
+): InlineElement[] => {
+  const children = node.children.flatMap(getChild);
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    children.unshift({ text: mdSequence });
+    children.push({ text: mdSequence });
+    return children;
+  }
+  children.forEach((child) => {
+    if (Text.isText(child)) {
+      child[markType] = true;
+    }
+  });
+  return children;
+};
+
+const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
   if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
     const { src, alt } = node.attribs;
     if (!src) return undefined;
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
     if (testMatrixTo(href)) {
       const userMention = parseMatrixToUser(href);
       if (userMention) {
-        return createMentionElement(userMention, parseNodeText(node) || userMention, false);
+        return createMentionElement(userMention, getText(node) || userMention, false);
       }
       const roomMention = parseMatrixToRoom(href);
       if (roomMention) {
         return createMentionElement(
           roomMention.roomIdOrAlias,
-          parseNodeText(node) || roomMention.roomIdOrAlias,
+          getText(node) || roomMention.roomIdOrAlias,
           false,
           undefined,
           roomMention.viaServers
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
       if (eventMention) {
         return createMentionElement(
           eventMention.roomIdOrAlias,
-          parseNodeText(node) || eventMention.roomIdOrAlias,
+          getText(node) || eventMention.roomIdOrAlias,
           false,
           eventMention.eventId,
           eventMention.viaServers
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
   return undefined;
 };
 
-const parseInlineNodes = (node: ChildNode): InlineElement[] => {
+const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
   if (isText(node)) {
-    return [{ text: node.data }];
+    return [{ text: processText(node.data) }];
   }
+
   if (isTag(node)) {
-    const markType = elementToTextMark(node);
+    const markType = getInlineNodeMarkType(node);
     if (markType) {
-      const children = node.children.flatMap(parseInlineNodes);
-      if (node.attribs['data-md'] !== undefined) {
-        children.unshift({ text: node.attribs['data-md'] });
-        children.push({ text: node.attribs['data-md'] });
-      } else {
-        children.forEach((child) => {
-          if (Text.isText(child)) {
-            child[markType] = true;
-          }
-        });
-      }
-      return children;
+      return getInlineMarkElement(markType, node, (child) => {
+        if (markType === MarkType.Code) return [{ text: getText(child) }];
+        return getInlineElement(child, processText);
+      });
     }
 
-    const inlineNode = elementToInlineNode(node);
+    const inlineNode = getInlineNonMarkElement(node);
     if (inlineNode) return [inlineNode];
 
     if (node.name === 'a') {
-      const children = node.childNodes.flatMap(parseInlineNodes);
+      const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
       children.unshift({ text: '[' });
       children.push({ text: `](${node.attribs.href})` });
       return children;
     }
 
-    return node.childNodes.flatMap(parseInlineNodes);
+    return node.childNodes.flatMap((child) => getInlineElement(child, processText));
   }
 
   return [];
 };
 
-const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
+const parseBlockquoteNode = (
+  node: Element,
+  processText: ProcessTextCallback
+): BlockQuoteElement[] | ParagraphElement[] => {
   const quoteLines: Array = [];
   let lineHolder: InlineElement[] = [];
 
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
 
   node.children.forEach((child) => {
     if (isText(child)) {
-      lineHolder.push({ text: child.data });
+      lineHolder.push({ text: processText(child.data) });
       return;
     }
     if (isTag(child)) {
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
 
       if (child.name === 'p') {
         appendLine();
-        quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+        quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
         return;
       }
 
-      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(child, processText));
     }
   });
   appendLine();
 
-  if (node.attribs['data-md'] !== undefined) {
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
     return quoteLines.map((lineChildren) => ({
       type: BlockType.Paragraph,
-      children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
+      children: [{ text: `${mdSequence} ` }, ...lineChildren],
     }));
   }
 
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
   ];
 };
 const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
-  const codeLines = parseNodeText(node).trim().split('\n');
+  const codeLines = getText(node).trim().split('\n');
 
-  if (node.attribs['data-md'] !== undefined) {
-    const pLines = codeLines.map((lineText) => ({
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    const pLines = codeLines.map((text) => ({
       type: BlockType.Paragraph,
-      children: [
-        {
-          text: lineText,
-        },
-      ],
+      children: [{ text }],
     }));
     const childCode = node.children[0];
     const className =
       isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
-    const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
-    const suffix = { text: node.attribs['data-md'] };
+    const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
+    const suffix = { text: mdSequence };
     return [
       { type: BlockType.Paragraph, children: [prefix] },
       ...pLines,
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
   return [
     {
       type: BlockType.CodeBlock,
-      children: codeLines.map((lineTxt) => ({
+      children: codeLines.map((text) => ({
         type: BlockType.CodeLine,
-        children: [
-          {
-            text: lineTxt,
-          },
-        ],
+        children: [{ text }],
       })),
     },
   ];
 };
 const parseListNode = (
-  node: Element
+  node: Element,
+  processText: ProcessTextCallback
 ): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
   const listLines: Array = [];
   let lineHolder: InlineElement[] = [];
@@ -247,7 +265,7 @@ const parseListNode = (
 
   node.children.forEach((child) => {
     if (isText(child)) {
-      lineHolder.push({ text: child.data });
+      lineHolder.push({ text: processText(child.data) });
       return;
     }
     if (isTag(child)) {
@@ -259,17 +277,18 @@ const parseListNode = (
 
       if (child.name === 'li') {
         appendLine();
-        listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+        listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
         return;
       }
 
-      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(child, processText));
     }
   });
   appendLine();
 
-  if (node.attribs['data-md'] !== undefined) {
-    const prefix = node.attribs['data-md'] || '-';
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    const prefix = mdSequence || '-';
     const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
     return listLines.map((lineChildren) => ({
       type: BlockType.Paragraph,
@@ -302,17 +321,21 @@ const parseListNode = (
     },
   ];
 };
-const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
-  const children = node.children.flatMap((child) => parseInlineNodes(child));
+const parseHeadingNode = (
+  node: Element,
+  processText: ProcessTextCallback
+): HeadingElement | ParagraphElement => {
+  const children = node.children.flatMap((child) => getInlineElement(child, processText));
 
   const headingMatch = node.name.match(/^h([123456])$/);
   const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
   const level = parseInt(g1AsLevel, 10);
 
-  if (node.attribs['data-md'] !== undefined) {
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
     return {
       type: BlockType.Paragraph,
-      children: [{ text: `${node.attribs['data-md']} ` }, ...children],
+      children: [{ text: `${mdSequence} ` }, ...children],
     };
   }
 
@@ -323,7 +346,11 @@ const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
   };
 };
 
-export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
+export const domToEditorInput = (
+  domNodes: ChildNode[],
+  processText: ProcessTextCallback,
+  processLineStartText: ProcessTextCallback
+): Descendant[] => {
   const children: Descendant[] = [];
 
   let lineHolder: InlineElement[] = [];
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
 
   domNodes.forEach((node) => {
     if (isText(node)) {
-      lineHolder.push({ text: node.data });
+      if (lineHolder.length === 0) {
+        // we are inserting first part of line
+        // it may contain block markdown starting data
+        // that we may need to escape.
+        lineHolder.push({ text: processLineStartText(node.data) });
+        return;
+      }
+      lineHolder.push({ text: processText(node.data) });
       return;
     }
     if (isTag(node)) {
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
         appendLine();
         children.push({
           type: BlockType.Paragraph,
-          children: node.children.flatMap((child) => parseInlineNodes(child)),
+          children: node.children.flatMap((child) => getInlineElement(child, processText)),
         });
         return;
       }
 
       if (node.name === 'blockquote') {
         appendLine();
-        children.push(...parseBlockquoteNode(node));
+        children.push(...parseBlockquoteNode(node, processText));
         return;
       }
       if (node.name === 'pre') {
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
       }
       if (node.name === 'ol' || node.name === 'ul') {
         appendLine();
-        children.push(...parseListNode(node));
+        children.push(...parseListNode(node, processText));
         return;
       }
 
       if (node.name.match(/^h[123456]$/)) {
         appendLine();
-        children.push(parseHeadingNode(node));
+        children.push(parseHeadingNode(node, processText));
         return;
       }
 
-      parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(node, processText));
     }
   });
   appendLine();
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
   return children;
 };
 
-export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
+export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
   const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
 
+  const processText = (partText: string) => {
+    if (!markdown) return partText;
+    return escapeMarkdownInlineSequences(partText);
+  };
+
   const domNodes = parse(sanitizedHtml);
-  const editorNodes = domToEditorInput(domNodes);
+  const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => {
+    if (!markdown) return lineStartText;
+    return escapeMarkdownBlockSequences(lineStartText, processText);
+  });
   return editorNodes;
 };
 
-export const plainToEditorInput = (text: string): Descendant[] => {
+export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
   const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
     const paragraphNode: ParagraphElement = {
       type: BlockType.Paragraph,
       children: [
         {
-          text: lineText,
+          text: markdown
+            ? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
+            : lineText,
         },
       ],
     };
diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts
index c5ecc6de..256bdbd9 100644
--- a/src/app/components/editor/output.ts
+++ b/src/app/components/editor/output.ts
@@ -3,7 +3,12 @@ import { Descendant, Text } from 'slate';
 import { sanitizeText } from '../../utils/sanitize';
 import { BlockType } from './types';
 import { CustomElement } from './slate';
-import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
+import {
+  parseBlockMD,
+  parseInlineMD,
+  unescapeMarkdownBlockSequences,
+  unescapeMarkdownInlineSequences,
+} from '../../plugins/markdown';
 import { findAndReplace } from '../../utils/findAndReplace';
 import { sanitizeForRegex } from '../../utils/regex';
 
@@ -19,7 +24,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
     if (node.bold) string = `${string}`;
     if (node.italic) string = `${string}`;
     if (node.underline) string = `${string}`;
-    if (node.strikeThrough) string = `${string}`;
+    if (node.strikeThrough) string = `${string}`;
     if (node.code) string = `${string}`;
     if (node.spoiler) string = `${string}`;
   }
@@ -102,7 +107,8 @@ export const toMatrixCustomHTML = (
         allowBlockMarkdown: false,
       })
         .replace(/$/, '\n')
-        .replace(/^>/, '>');
+        .replace(/^(\\*)>/, '$1>');
+
       markdownLines += line;
       if (index === targetNodes.length - 1) {
         return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -157,11 +163,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
   }
 };
 
-export const toPlainText = (node: Descendant | Descendant[]): string => {
-  if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
-  if (Text.isText(node)) return node.text;
+export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
+  if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
+  if (Text.isText(node))
+    return isMarkdown
+      ? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
+      : node.text;
 
-  const children = node.children.map((n) => toPlainText(n)).join('');
+  const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
   return elementToPlainText(node, children);
 };
 
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 897cdd48..c4befef6 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -255,7 +255,7 @@ export const RoomInput = forwardRef(
 
       const commandName = getBeginCommand(editor);
 
-      let plainText = toPlainText(editor.children).trim();
+      let plainText = toPlainText(editor.children, isMarkdown).trim();
       let customHtml = trimCustomHtml(
         toMatrixCustomHTML(editor.children, {
           allowTextFormatting: true,
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
index 0c995030..deeb8215 100644
--- a/src/app/features/room/message/MessageEditor.tsx
+++ b/src/app/features/room/message/MessageEditor.tsx
@@ -92,7 +92,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
     const [saveState, save] = useAsyncCallback(
       useCallback(async () => {
-        const plainText = toPlainText(editor.children).trim();
+        const plainText = toPlainText(editor.children, isMarkdown).trim();
         const customHtml = trimCustomHtml(
           toMatrixCustomHTML(editor.children, {
             allowTextFormatting: true,
@@ -192,8 +192,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
       const initialValue =
         typeof customHtml === 'string'
-          ? htmlToEditorInput(customHtml)
-          : plainToEditorInput(typeof body === 'string' ? body : '');
+          ? htmlToEditorInput(customHtml, isMarkdown)
+          : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
 
       Transforms.select(editor, {
         anchor: Editor.start(editor, []),
@@ -202,7 +202,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
       editor.insertFragment(initialValue);
       if (!mobileOrTablet()) ReactEditor.focus(editor);
-    }, [editor, getPrevBodyAndFormattedBody]);
+    }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
 
     useEffect(() => {
       if (saveState.status === AsyncStatus.Success) {
diff --git a/src/app/plugins/markdown.ts b/src/app/plugins/markdown.ts
deleted file mode 100644
index 9b3b82f7..00000000
--- a/src/app/plugins/markdown.ts
+++ /dev/null
@@ -1,368 +0,0 @@
-export type MatchResult = RegExpMatchArray | RegExpExecArray;
-export type RuleMatch = (text: string) => MatchResult | null;
-
-export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
-  text.slice(0, match.index);
-export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
-  text.slice((match.index ?? 0) + match[0].length);
-
-export const replaceMatch = (
-  convertPart: (txt: string) => Array,
-  text: string,
-  match: MatchResult,
-  content: C
-): Array => [
-  ...convertPart(beforeMatch(text, match)),
-  content,
-  ...convertPart(afterMatch(text, match)),
-];
-
-/*
- *****************
- * INLINE PARSER *
- *****************
- */
-
-export type InlineMDParser = (text: string) => string;
-
-export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string;
-
-export type InlineMDRule = {
-  match: RuleMatch;
-  html: InlineMatchConverter;
-};
-
-export type InlineRuleRunner = (
-  parse: InlineMDParser,
-  text: string,
-  rule: InlineMDRule
-) => string | undefined;
-export type InlineRulesRunner = (
-  parse: InlineMDParser,
-  text: string,
-  rules: InlineMDRule[]
-) => string | undefined;
-
-const MIN_ANY = '(.+?)';
-const URL_NEG_LB = '(? text.match(BOLD_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const ITALIC_MD_1 = '*';
-const ITALIC_PREFIX_1 = '\\*';
-const ITALIC_NEG_LA_1 = '(?!\\*)';
-const ITALIC_REG_1 = new RegExp(
-  `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`
-);
-const ItalicRule1: InlineMDRule = {
-  match: (text) => text.match(ITALIC_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const ITALIC_MD_2 = '_';
-const ITALIC_PREFIX_2 = '_';
-const ITALIC_NEG_LA_2 = '(?!_)';
-const ITALIC_REG_2 = new RegExp(
-  `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`
-);
-const ItalicRule2: InlineMDRule = {
-  match: (text) => text.match(ITALIC_REG_2),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const UNDERLINE_MD_1 = '__';
-const UNDERLINE_PREFIX_1 = '_{2}';
-const UNDERLINE_NEG_LA_1 = '(?!_)';
-const UNDERLINE_REG_1 = new RegExp(
-  `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
-);
-const UnderlineRule: InlineMDRule = {
-  match: (text) => text.match(UNDERLINE_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const STRIKE_MD_1 = '~~';
-const STRIKE_PREFIX_1 = '~{2}';
-const STRIKE_NEG_LA_1 = '(?!~)';
-const STRIKE_REG_1 = new RegExp(
-  `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`
-);
-const StrikeRule: InlineMDRule = {
-  match: (text) => text.match(STRIKE_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const CODE_MD_1 = '`';
-const CODE_PREFIX_1 = '`';
-const CODE_NEG_LA_1 = '(?!`)';
-const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
-const CodeRule: InlineMDRule = {
-  match: (text) => text.match(CODE_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${g2}`;
-  },
-};
-
-const SPOILER_MD_1 = '||';
-const SPOILER_PREFIX_1 = '\\|{2}';
-const SPOILER_NEG_LA_1 = '(?!\\|)';
-const SPOILER_REG_1 = new RegExp(
-  `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
-);
-const SpoilerRule: InlineMDRule = {
-  match: (text) => text.match(SPOILER_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const LINK_ALT = `\\[${MIN_ANY}\\]`;
-const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
-const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
-const LinkRule: InlineMDRule = {
-  match: (text) => text.match(LINK_REG_1),
-  html: (parse, match) => {
-    const [, g1, g2] = match;
-    return `${parse(g1)}`;
-  },
-};
-
-const runInlineRule: InlineRuleRunner = (parse, text, rule) => {
-  const matchResult = rule.match(text);
-  if (matchResult) {
-    const content = rule.html(parse, matchResult);
-    return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join('');
-  }
-  return undefined;
-};
-
-/**
- * Runs multiple rules at the same time to better handle nested rules.
- * Rules will be run in the order they appear.
- */
-const runInlineRules: InlineRulesRunner = (parse, text, rules) => {
-  const matchResults = rules.map((rule) => rule.match(text));
-
-  let targetRule: InlineMDRule | undefined;
-  let targetResult: MatchResult | undefined;
-
-  for (let i = 0; i < matchResults.length; i += 1) {
-    const currentResult = matchResults[i];
-    if (currentResult && typeof currentResult.index === 'number') {
-      if (
-        !targetResult ||
-        (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index)
-      ) {
-        targetResult = currentResult;
-        targetRule = rules[i];
-      }
-    }
-  }
-
-  if (targetRule && targetResult) {
-    const content = targetRule.html(parse, targetResult);
-    return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join('');
-  }
-  return undefined;
-};
-
-const LeveledRules = [
-  BoldRule,
-  ItalicRule1,
-  UnderlineRule,
-  ItalicRule2,
-  StrikeRule,
-  SpoilerRule,
-  LinkRule,
-];
-
-export const parseInlineMD: InlineMDParser = (text) => {
-  if (text === '') return text;
-  let result: string | undefined;
-  if (!result) result = runInlineRule(parseInlineMD, text, CodeRule);
-
-  if (!result) result = runInlineRules(parseInlineMD, text, LeveledRules);
-
-  return result ?? text;
-};
-
-/*
- ****************
- * BLOCK PARSER *
- ****************
- */
-
-export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string;
-
-export type BlockMatchConverter = (
-  match: MatchResult,
-  parseInline?: (txt: string) => string
-) => string;
-
-export type BlockMDRule = {
-  match: RuleMatch;
-  html: BlockMatchConverter;
-};
-
-export type BlockRuleRunner = (
-  parse: BlockMDParser,
-  text: string,
-  rule: BlockMDRule,
-  parseInline?: (txt: string) => string
-) => string | undefined;
-
-const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m;
-const HeadingRule: BlockMDRule = {
-  match: (text) => text.match(HEADING_REG_1),
-  html: (match, parseInline) => {
-    const [, g1, g2] = match;
-    const level = g1.length;
-    return `${parseInline ? parseInline(g2) : g2}`;
-  },
-};
-
-const CODEBLOCK_MD_1 = '```';
-const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
-const CodeBlockRule: BlockMDRule = {
-  match: (text) => text.match(CODEBLOCK_REG_1),
-  html: (match) => {
-    const [, g1, g2] = match;
-    const classNameAtt = g1 ? ` class="language-${g1}"` : '';
-    return `
${g2}
`; - }, -}; - -const BLOCKQUOTE_MD_1 = '>'; -const QUOTE_LINE_PREFIX = /^> */; -const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; -const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; -const BlockQuoteRule: BlockMDRule = { - match: (text) => text.match(BLOCKQUOTE_REG_1), - html: (match, parseInline) => { - const [blockquoteText] = match; - - const lines = blockquoteText - .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(QUOTE_LINE_PREFIX, ''); - if (parseInline) return `${parseInline(line)}
`; - return `${line}
`; - }) - .join(''); - return `
${lines}
`; - }, -}; - -const ORDERED_LIST_MD_1 = '-'; -const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; -const O_LIST_START = /^([\d])\./; -const O_LIST_TYPE = /^([aAiI])\./; -const O_LIST_TRAILING_NEWLINE = /\n$/; -const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m; -const OrderedListRule: BlockMDRule = { - match: (text) => text.match(ORDERED_LIST_REG_1), - html: (match, parseInline) => { - const [listText] = match; - const [, listStart] = listText.match(O_LIST_START) ?? []; - const [, listType] = listText.match(O_LIST_TYPE) ?? []; - - const lines = listText - .replace(O_LIST_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); - const txt = parseInline ? parseInline(line) : line; - return `
  • ${txt}

  • `; - }) - .join(''); - - const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; - const startAtt = listStart ? ` start="${listStart}"` : ''; - const typeAtt = listType ? ` type="${listType}"` : ''; - return `
      ${lines}
    `; - }, -}; - -const UNORDERED_LIST_MD_1 = '*'; -const U_LIST_ITEM_PREFIX = /^\* */; -const U_LIST_TRAILING_NEWLINE = /\n$/; -const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; -const UnorderedListRule: BlockMDRule = { - match: (text) => text.match(UNORDERED_LIST_REG_1), - html: (match, parseInline) => { - const [listText] = match; - - const lines = listText - .replace(U_LIST_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); - const txt = parseInline ? parseInline(line) : line; - return `
  • ${txt}

  • `; - }) - .join(''); - - return `
      ${lines}
    `; - }, -}; - -const runBlockRule: BlockRuleRunner = (parse, text, rule, parseInline) => { - const matchResult = rule.match(text); - if (matchResult) { - const content = rule.html(matchResult, parseInline); - return replaceMatch((txt) => [parse(txt, parseInline)], text, matchResult, content).join(''); - } - return undefined; -}; - -export const parseBlockMD: BlockMDParser = (text, parseInline) => { - if (text === '') return text; - let result: string | undefined; - - if (!result) result = runBlockRule(parseBlockMD, text, CodeBlockRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, BlockQuoteRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, OrderedListRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, UnorderedListRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, HeadingRule, parseInline); - - // replace \n with
    because want to preserve empty lines - if (!result) { - if (parseInline) { - result = text - .split('\n') - .map((lineText) => parseInline(lineText)) - .join('
    '); - } else { - result = text.replace(/\n/g, '
    '); - } - } - - return result ?? text; -}; diff --git a/src/app/plugins/markdown/block/index.ts b/src/app/plugins/markdown/block/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/src/app/plugins/markdown/block/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/src/app/plugins/markdown/block/parser.ts b/src/app/plugins/markdown/block/parser.ts new file mode 100644 index 00000000..ed16a327 --- /dev/null +++ b/src/app/plugins/markdown/block/parser.ts @@ -0,0 +1,47 @@ +import { replaceMatch } from '../internal'; +import { + BlockQuoteRule, + CodeBlockRule, + ESC_BLOCK_SEQ, + HeadingRule, + OrderedListRule, + UnorderedListRule, +} from './rules'; +import { runBlockRule } from './runner'; +import { BlockMDParser } from './type'; + +/** + * Parses block-level markdown text into HTML using defined block rules. + * + * @param text - The markdown text to be parsed. + * @param parseInline - Optional function to parse inline elements. + * @returns The parsed HTML or the original text if no block-level markdown was found. + */ +export const parseBlockMD: BlockMDParser = (text, parseInline) => { + if (text === '') return text; + let result: string | undefined; + + if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline); + + // replace \n with
    because want to preserve empty lines + if (!result) { + result = text + .split('\n') + .map((lineText) => { + const match = lineText.match(ESC_BLOCK_SEQ); + if (!match) { + return parseInline?.(lineText) ?? lineText; + } + + const [, g1] = match; + return replaceMatch(lineText, match, g1, (t) => [parseInline?.(t) ?? t]).join(''); + }) + .join('
    '); + } + + return result ?? text; +}; diff --git a/src/app/plugins/markdown/block/rules.ts b/src/app/plugins/markdown/block/rules.ts new file mode 100644 index 00000000..f598ee63 --- /dev/null +++ b/src/app/plugins/markdown/block/rules.ts @@ -0,0 +1,100 @@ +import { BlockMDRule } from './type'; + +const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m; +export const HeadingRule: BlockMDRule = { + match: (text) => text.match(HEADING_REG_1), + html: (match, parseInline) => { + const [, g1, g2] = match; + const level = g1.length; + return `${parseInline ? parseInline(g2) : g2}`; + }, +}; + +const CODEBLOCK_MD_1 = '```'; +const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m; +export const CodeBlockRule: BlockMDRule = { + match: (text) => text.match(CODEBLOCK_REG_1), + html: (match) => { + const [, g1, g2] = match; + const classNameAtt = g1 ? ` class="language-${g1}"` : ''; + return `
    ${g2}
    `; + }, +}; + +const BLOCKQUOTE_MD_1 = '>'; +const QUOTE_LINE_PREFIX = /^> */; +const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; +const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; +export const BlockQuoteRule: BlockMDRule = { + match: (text) => text.match(BLOCKQUOTE_REG_1), + html: (match, parseInline) => { + const [blockquoteText] = match; + + const lines = blockquoteText + .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(QUOTE_LINE_PREFIX, ''); + if (parseInline) return `${parseInline(line)}
    `; + return `${line}
    `; + }) + .join(''); + return `
    ${lines}
    `; + }, +}; + +const ORDERED_LIST_MD_1 = '-'; +const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; +const O_LIST_START = /^([\d])\./; +const O_LIST_TYPE = /^([aAiI])\./; +const O_LIST_TRAILING_NEWLINE = /\n$/; +const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m; +export const OrderedListRule: BlockMDRule = { + match: (text) => text.match(ORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + const [, listStart] = listText.match(O_LIST_START) ?? []; + const [, listType] = listText.match(O_LIST_TYPE) ?? []; + + const lines = listText + .replace(O_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; + const startAtt = listStart ? ` start="${listStart}"` : ''; + const typeAtt = listType ? ` type="${listType}"` : ''; + return `
      ${lines}
    `; + }, +}; + +const UNORDERED_LIST_MD_1 = '*'; +const U_LIST_ITEM_PREFIX = /^\* */; +const U_LIST_TRAILING_NEWLINE = /\n$/; +const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; +export const UnorderedListRule: BlockMDRule = { + match: (text) => text.match(UNORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + + const lines = listText + .replace(U_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + return `
      ${lines}
    `; + }, +}; + +export const UN_ESC_BLOCK_SEQ = /^\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +)/; +export const ESC_BLOCK_SEQ = /^\\(\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +))/; diff --git a/src/app/plugins/markdown/block/runner.ts b/src/app/plugins/markdown/block/runner.ts new file mode 100644 index 00000000..1dc8d8b8 --- /dev/null +++ b/src/app/plugins/markdown/block/runner.ts @@ -0,0 +1,25 @@ +import { replaceMatch } from '../internal'; +import { BlockMDParser, BlockMDRule } from './type'; + +/** + * Parses block-level markdown text into HTML using defined block rules. + * + * @param text - The text to parse. + * @param rule - The markdown rule to run. + * @param parse - A function that run the parser on remaining parts.. + * @param parseInline - Optional function to parse inline elements. + * @returns The text with the markdown rule applied or `undefined` if no match is found. + */ +export const runBlockRule = ( + text: string, + rule: BlockMDRule, + parse: BlockMDParser, + parseInline?: (txt: string) => string +): string | undefined => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(matchResult, parseInline); + return replaceMatch(text, matchResult, content, (txt) => [parse(txt, parseInline)]).join(''); + } + return undefined; +}; diff --git a/src/app/plugins/markdown/block/type.ts b/src/app/plugins/markdown/block/type.ts new file mode 100644 index 00000000..0949eb70 --- /dev/null +++ b/src/app/plugins/markdown/block/type.ts @@ -0,0 +1,30 @@ +import { MatchResult, MatchRule } from '../internal'; + +/** + * Type for a function that parses block-level markdown into HTML. + * + * @param text - The markdown text to be parsed. + * @param parseInline - Optional function to parse inline elements. + * @returns The parsed HTML. + */ +export type BlockMDParser = (text: string, parseInline?: (txt: string) => string) => string; + +/** + * Type for a function that converts a block match to output. + * + * @param match - The match result. + * @param parseInline - Optional function to parse inline elements. + * @returns The output string after processing the match. + */ +export type BlockMatchConverter = ( + match: MatchResult, + parseInline?: (txt: string) => string +) => string; + +/** + * Type representing a block-level markdown rule that includes a matching pattern and HTML conversion. + */ +export type BlockMDRule = { + match: MatchRule; // A function that matches a specific markdown pattern. + html: BlockMatchConverter; // A function that converts the match to HTML. +}; diff --git a/src/app/plugins/markdown/index.ts b/src/app/plugins/markdown/index.ts new file mode 100644 index 00000000..4c4e4491 --- /dev/null +++ b/src/app/plugins/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; +export * from './block'; +export * from './inline'; diff --git a/src/app/plugins/markdown/inline/index.ts b/src/app/plugins/markdown/inline/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/src/app/plugins/markdown/inline/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/src/app/plugins/markdown/inline/parser.ts b/src/app/plugins/markdown/inline/parser.ts new file mode 100644 index 00000000..37c71a66 --- /dev/null +++ b/src/app/plugins/markdown/inline/parser.ts @@ -0,0 +1,40 @@ +import { + BoldRule, + CodeRule, + EscapeRule, + ItalicRule1, + ItalicRule2, + LinkRule, + SpoilerRule, + StrikeRule, + UnderlineRule, +} from './rules'; +import { runInlineRule, runInlineRules } from './runner'; +import { InlineMDParser } from './type'; + +const LeveledRules = [ + BoldRule, + ItalicRule1, + UnderlineRule, + ItalicRule2, + StrikeRule, + SpoilerRule, + LinkRule, + EscapeRule, +]; + +/** + * Parses inline markdown text into HTML using defined rules. + * + * @param text - The markdown text to be parsed. + * @returns The parsed HTML or the original text if no markdown was found. + */ +export const parseInlineMD: InlineMDParser = (text) => { + if (text === '') return text; + let result: string | undefined; + if (!result) result = runInlineRule(text, CodeRule, parseInlineMD); + + if (!result) result = runInlineRules(text, LeveledRules, parseInlineMD); + + return result ?? text; +}; diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts new file mode 100644 index 00000000..bc76f60a --- /dev/null +++ b/src/app/plugins/markdown/inline/rules.ts @@ -0,0 +1,123 @@ +import { InlineMDRule } from './type'; + +const MIN_ANY = '(.+?)'; +const URL_NEG_LB = '(? text.match(BOLD_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const ITALIC_MD_1 = '*'; +const ITALIC_PREFIX_1 = `${ESC_NEG_LB}\\*`; +const ITALIC_NEG_LA_1 = '(?!\\*)'; +const ITALIC_REG_1 = new RegExp( + `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` +); +export const ItalicRule1: InlineMDRule = { + match: (text) => text.match(ITALIC_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const ITALIC_MD_2 = '_'; +const ITALIC_PREFIX_2 = `${ESC_NEG_LB}_`; +const ITALIC_NEG_LA_2 = '(?!_)'; +const ITALIC_REG_2 = new RegExp( + `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` +); +export const ItalicRule2: InlineMDRule = { + match: (text) => text.match(ITALIC_REG_2), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const UNDERLINE_MD_1 = '__'; +const UNDERLINE_PREFIX_1 = `${ESC_NEG_LB}_{2}`; +const UNDERLINE_NEG_LA_1 = '(?!_)'; +const UNDERLINE_REG_1 = new RegExp( + `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` +); +export const UnderlineRule: InlineMDRule = { + match: (text) => text.match(UNDERLINE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const STRIKE_MD_1 = '~~'; +const STRIKE_PREFIX_1 = `${ESC_NEG_LB}~{2}`; +const STRIKE_NEG_LA_1 = '(?!~)'; +const STRIKE_REG_1 = new RegExp( + `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` +); +export const StrikeRule: InlineMDRule = { + match: (text) => text.match(STRIKE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const CODE_MD_1 = '`'; +const CODE_PREFIX_1 = `${ESC_NEG_LB}\``; +const CODE_NEG_LA_1 = '(?!`)'; +const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`); +export const CodeRule: InlineMDRule = { + match: (text) => text.match(CODE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${g2}`; + }, +}; + +const SPOILER_MD_1 = '||'; +const SPOILER_PREFIX_1 = `${ESC_NEG_LB}\\|{2}`; +const SPOILER_NEG_LA_1 = '(?!\\|)'; +const SPOILER_REG_1 = new RegExp( + `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` +); +export const SpoilerRule: InlineMDRule = { + match: (text) => text.match(SPOILER_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const LINK_ALT = `\\[${MIN_ANY}\\]`; +const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; +const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); +export const LinkRule: InlineMDRule = { + match: (text) => text.match(LINK_REG_1), + html: (parse, match) => { + const [, g1, g2] = match; + return `${parse(g1)}`; + }, +}; + +export const INLINE_SEQUENCE_SET = '[*_~`|]'; +const ESC_SEQ_1 = `\\\\(${INLINE_SEQUENCE_SET})`; +const ESC_REG_1 = new RegExp(`${URL_NEG_LB}${ESC_SEQ_1}`); +export const EscapeRule: InlineMDRule = { + match: (text) => text.match(ESC_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return g2; + }, +}; diff --git a/src/app/plugins/markdown/inline/runner.ts b/src/app/plugins/markdown/inline/runner.ts new file mode 100644 index 00000000..3a794d25 --- /dev/null +++ b/src/app/plugins/markdown/inline/runner.ts @@ -0,0 +1,62 @@ +import { MatchResult, replaceMatch } from '../internal'; +import { InlineMDParser, InlineMDRule } from './type'; + +/** + * Runs a single markdown rule on the provided text. + * + * @param text - The text to parse. + * @param rule - The markdown rule to run. + * @param parse - A function that run the parser on remaining parts. + * @returns The text with the markdown rule applied or `undefined` if no match is found. + */ +export const runInlineRule = ( + text: string, + rule: InlineMDRule, + parse: InlineMDParser +): string | undefined => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(parse, matchResult); + return replaceMatch(text, matchResult, content, (txt) => [parse(txt)]).join(''); + } + return undefined; +}; + +/** + * Runs multiple rules at the same time to better handle nested rules. + * Rules will be run in the order they appear. + * + * @param text - The text to parse. + * @param rules - The markdown rules to run. + * @param parse - A function that run the parser on remaining parts. + * @returns The text with the markdown rules applied or `undefined` if no match is found. + */ +export const runInlineRules = ( + text: string, + rules: InlineMDRule[], + parse: InlineMDParser +): string | undefined => { + const matchResults = rules.map((rule) => rule.match(text)); + + let targetRule: InlineMDRule | undefined; + let targetResult: MatchResult | undefined; + + for (let i = 0; i < matchResults.length; i += 1) { + const currentResult = matchResults[i]; + if (currentResult && typeof currentResult.index === 'number') { + if ( + !targetResult || + (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index) + ) { + targetResult = currentResult; + targetRule = rules[i]; + } + } + } + + if (targetRule && targetResult) { + const content = targetRule.html(parse, targetResult); + return replaceMatch(text, targetResult, content, (txt) => [parse(txt)]).join(''); + } + return undefined; +}; diff --git a/src/app/plugins/markdown/inline/type.ts b/src/app/plugins/markdown/inline/type.ts new file mode 100644 index 00000000..a65ad276 --- /dev/null +++ b/src/app/plugins/markdown/inline/type.ts @@ -0,0 +1,26 @@ +import { MatchResult, MatchRule } from '../internal'; + +/** + * Type for a function that parses inline markdown into HTML. + * + * @param text - The markdown text to be parsed. + * @returns The parsed HTML. + */ +export type InlineMDParser = (text: string) => string; + +/** + * Type for a function that converts a match to output. + * + * @param parse - The inline markdown parser function. + * @param match - The match result. + * @returns The output string after processing the match. + */ +export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string; + +/** + * Type representing a markdown rule that includes a matching pattern and HTML conversion. + */ +export type InlineMDRule = { + match: MatchRule; // A function that matches a specific markdown pattern. + html: InlineMatchConverter; // A function that converts the match to HTML. +}; diff --git a/src/app/plugins/markdown/internal/index.ts b/src/app/plugins/markdown/internal/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/app/plugins/markdown/internal/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/app/plugins/markdown/internal/utils.ts b/src/app/plugins/markdown/internal/utils.ts new file mode 100644 index 00000000..86bc9d5c --- /dev/null +++ b/src/app/plugins/markdown/internal/utils.ts @@ -0,0 +1,61 @@ +/** + * @typedef {RegExpMatchArray | RegExpExecArray} MatchResult + * + * Represents the result of a regular expression match. + * This type can be either a `RegExpMatchArray` or a `RegExpExecArray`, + * which are returned when performing a match with a regular expression. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match} + */ +export type MatchResult = RegExpMatchArray | RegExpExecArray; + +/** + * @typedef {function(string): MatchResult | null} MatchRule + * + * A function type that takes a string and returns a `MatchResult` or `null` if no match is found. + * + * @param {string} text The string to match against. + * @returns {MatchResult | null} The result of the regular expression match, or `null` if no match is found. + */ +export type MatchRule = (text: string) => MatchResult | null; + +/** + * Returns the part of the text before a match. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @returns A string containing the part of the text before the match. + */ +export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice(0, match.index); + +/** + * Returns the part of the text after a match. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @returns A string containing the part of the text after the match. + */ +export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice((match.index ?? 0) + match[0].length); + +/** + * Replaces a match in the text with a content. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @param content - The content to replace the match with. + * @param processPart - A function to further process remaining parts of the text. + * @returns An array containing the processed parts of the text, including the content. + */ +export const replaceMatch = ( + text: string, + match: MatchResult, + content: C, + processPart: (txt: string) => Array +): Array => [ + ...processPart(beforeMatch(text, match)), + content, + ...processPart(afterMatch(text, match)), +]; diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts new file mode 100644 index 00000000..5ebd958c --- /dev/null +++ b/src/app/plugins/markdown/utils.ts @@ -0,0 +1,83 @@ +import { findAndReplace } from '../../utils/findAndReplace'; +import { ESC_BLOCK_SEQ, UN_ESC_BLOCK_SEQ } from './block/rules'; +import { EscapeRule, INLINE_SEQUENCE_SET } from './inline/rules'; +import { runInlineRule } from './inline/runner'; +import { replaceMatch } from './internal'; + +/** + * Removes escape sequences from markdown inline elements in the given plain-text. + * This function unescapes characters that are escaped with backslashes (e.g., `\*`, `\_`) + * in markdown syntax, returning the original plain-text with markdown characters in effect. + * + * @param text - The input markdown plain-text containing escape characters (e.g., `"some \*italic\*"`) + * @returns The plain-text with markdown escape sequences removed (e.g., `"some *italic*"`) + */ +export const unescapeMarkdownInlineSequences = (text: string): string => + runInlineRule(text, EscapeRule, (t) => { + if (t === '') return t; + return unescapeMarkdownInlineSequences(t); + }) ?? text; + +/** + * Recovers the markdown escape sequences in the given plain-text. + * This function adds backslashes (`\`) before markdown characters that may need escaping + * (e.g., `*`, `_`) to ensure they are treated as literal characters and not part of markdown formatting. + * + * @param text - The input plain-text that may contain markdown sequences (e.g., `"some *italic*"`) + * @returns The plain-text with markdown escape sequences added (e.g., `"some \*italic\*"`) + */ +export const escapeMarkdownInlineSequences = (text: string): string => { + const regex = new RegExp(`(${INLINE_SEQUENCE_SET})`, 'g'); + const parts = findAndReplace( + text, + regex, + (match) => { + const [, g1] = match; + return `\\${g1}`; + }, + (t) => t + ); + + return parts.join(''); +}; + +/** + * Removes escape sequences from markdown block elements in the given plain-text. + * This function unescapes characters that are escaped with backslashes (e.g., `\>`, `\#`) + * in markdown syntax, returning the original plain-text with markdown characters in effect. + * + * @param {string} text - The input markdown plain-text containing escape characters (e.g., `\> block quote`). + * @param {function} processPart - It takes the plain-text as input and returns a modified version of it. + * @returns {string} The plain-text with markdown escape sequences removed and markdown formatting applied. + */ +export const unescapeMarkdownBlockSequences = ( + text: string, + processPart: (text: string) => string +): string => { + const match = text.match(ESC_BLOCK_SEQ); + + if (!match) return processPart(text); + + const [, g1] = match; + return replaceMatch(text, match, g1, (t) => [processPart(t)]).join(''); +}; + +/** + * Escapes markdown block elements by adding backslashes before markdown characters + * (e.g., `\>`, `\#`) that are normally interpreted as markdown syntax. + * + * @param {string} text - The input markdown plain-text that may contain markdown elements (e.g., `> block quote`). + * @param {function} processPart - It takes the plain-text as input and returns a modified version of it. + * @returns {string} The plain-text with markdown escape sequences added, preventing markdown formatting. + */ +export const escapeMarkdownBlockSequences = ( + text: string, + processPart: (text: string) => string +): string => { + const match = text.match(UN_ESC_BLOCK_SEQ); + + if (!match) return processPart(text); + + const [, g1] = match; + return replaceMatch(text, match, `\\${g1}`, (t) => [processPart(t)]).join(''); +}; From f121cc0a240eaf6a056bca0c429bf4574f82fc38 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Fri, 21 Feb 2025 05:22:48 -0300 Subject: [PATCH 011/138] fix space/tab inconsistency (#2180) --- contrib/nginx/cinny.domain.tld.conf | 46 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf index 0ba70f7e..0b6c8aad 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -1,35 +1,35 @@ server { - listen 80; - listen [::]:80; - server_name cinny.domain.tld; + listen 80; + listen [::]:80; + server_name cinny.domain.tld; - location / { - return 301 https://$host$request_uri; - } + location / { + return 301 https://$host$request_uri; + } - location /.well-known/acme-challenge/ { - alias /var/lib/letsencrypt/.well-known/acme-challenge/; - } + location /.well-known/acme-challenge/ { + alias /var/lib/letsencrypt/.well-known/acme-challenge/; + } } server { - listen 443 ssl http2; - listen [::]:443 ssl; - server_name cinny.domain.tld; + listen 443 ssl http2; + listen [::]:443 ssl; + server_name cinny.domain.tld; - location / { - root /opt/cinny/dist/; + location / { + root /opt/cinny/dist/; - rewrite ^/config.json$ /config.json break; - rewrite ^/manifest.json$ /manifest.json break; + rewrite ^/config.json$ /config.json break; + rewrite ^/manifest.json$ /manifest.json break; - rewrite ^.*/olm.wasm$ /olm.wasm break; - rewrite ^/sw.js$ /sw.js break; - rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; + rewrite ^.*/olm.wasm$ /olm.wasm break; + rewrite ^/sw.js$ /sw.js break; + rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; - rewrite ^/public/(.*)$ /public/$1 break; - rewrite ^/assets/(.*)$ /assets/$1 break; + rewrite ^/public/(.*)$ /public/$1 break; + rewrite ^/assets/(.*)$ /assets/$1 break; - rewrite ^(.+)$ /index.html break; - } + rewrite ^(.+)$ /index.html break; + } } From 7c6ab366aff46fa4c10fc8096541bc0085d9f183 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 22 Feb 2025 19:24:33 +1100 Subject: [PATCH 012/138] Fix unknown rooms in space lobby (#2224) * add hook to fetch one level of space hierarchy * add enable param to level hierarchy hook * improve HierarchyItem types * fix type errors in lobby * load space hierarachy per level * fix menu item visibility * fix unknown spaces over federation * show inaccessible rooms only to admins * fix unknown room renders loading content twice * fix unknown room visible to normal user if space all room are unknown * show no rooms card if space does not have any room --- src/app/features/lobby/HierarchyItemMenu.tsx | 4 +- src/app/features/lobby/Lobby.tsx | 238 +++++++------------ src/app/features/lobby/RoomItem.tsx | 146 +++++------- src/app/features/lobby/SpaceHierarchy.tsx | 225 ++++++++++++++++++ src/app/features/lobby/SpaceItem.tsx | 90 +++---- src/app/hooks/useSpaceHierarchy.ts | 162 ++++++++++--- src/app/state/spaceRooms.ts | 29 ++- 7 files changed, 564 insertions(+), 330 deletions(-) create mode 100644 src/app/features/lobby/SpaceHierarchy.tsx diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 30a4f632..d1a7ec6b 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -155,7 +155,7 @@ function SettingsMenuItem({ disabled?: boolean; }) { const handleSettings = () => { - if (item.space) { + if ('space' in item) { openSpaceSettings(item.roomId); } else { toggleRoomSettings(item.roomId); @@ -271,7 +271,7 @@ export function HierarchyItemMenu({ {promptLeave && - (item.space ? ( + ('space' in item ? ( >(() => new Map()); + useElementSizeObserver( useCallback(() => heroSectionRef.current, []), useCallback((w, height) => setHeroSectionHeight(height), []) @@ -107,19 +113,20 @@ export function Lobby() { ); const [draggingItem, setDraggingItem] = useState(); - const flattenHierarchy = useSpaceHierarchy( + const hierarchy = useSpaceHierarchy( space.roomId, spaceRooms, getRoom, useCallback( (childId) => - closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space, + closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || + (draggingItem ? 'space' in draggingItem : false), [closedCategories, space.roomId, draggingItem] ) ); const virtualizer = useVirtualizer({ - count: flattenHierarchy.length, + count: hierarchy.length, getScrollElement: () => scrollRef.current, estimateSize: () => 1, overscan: 2, @@ -129,8 +136,17 @@ export function Lobby() { const roomsPowerLevels = useRoomsPowerLevels( useMemo( - () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[], - [mx, flattenHierarchy] + () => + hierarchy + .flatMap((i) => { + const childRooms = Array.isArray(i.rooms) + ? i.rooms.map((r) => mx.getRoom(r.roomId)) + : []; + + return [mx.getRoom(i.space.roomId), ...childRooms]; + }) + .filter((r) => !!r) as Room[], + [mx, hierarchy] ) ); @@ -142,8 +158,8 @@ export function Lobby() { return false; } - if (item.space) { - if (!container.item.space) return false; + if ('space' in item) { + if (!('space' in container.item)) return false; const containerSpaceId = space.roomId; if ( @@ -156,9 +172,8 @@ export function Lobby() { return true; } - const containerSpaceId = container.item.space - ? container.item.roomId - : container.item.parentId; + const containerSpaceId = + 'space' in container.item ? container.item.roomId : container.item.parentId; const dropOutsideSpace = item.parentId !== containerSpaceId; @@ -192,22 +207,22 @@ export function Lobby() { ); const reorderSpace = useCallback( - (item: HierarchyItem, containerItem: HierarchyItem) => { + (item: HierarchyItemSpace, containerItem: HierarchyItem) => { if (!item.parentId) return; - const childItems = flattenHierarchy - .filter((i) => i.parentId && i.space) + const itemSpaces: HierarchyItemSpace[] = hierarchy + .map((i) => i.space) .filter((i) => i.roomId !== item.roomId); - const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId); + const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId); const insertIndex = beforeIndex + 1; - childItems.splice(insertIndex, 0, { + itemSpaces.splice(insertIndex, 0, { ...item, content: { ...item.content, order: undefined }, }); - const currentOrders = childItems.map((i) => { + const currentOrders = itemSpaces.map((i) => { if (typeof i.content.order === 'string' && lex.has(i.content.order)) { return i.content.order; } @@ -217,21 +232,21 @@ export function Lobby() { const newOrders = orderKeys(lex, currentOrders); newOrders?.forEach((orderKey, index) => { - const itm = childItems[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, + StateEvent.SpaceChild as any, { ...itm.content, order: orderKey }, itm.roomId ); } }); }, - [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild] + [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild] ); const reorderRoom = useCallback( @@ -240,13 +255,12 @@ export function Lobby() { if (!item.parentId) { return; } - const containerParentId: string = containerItem.space - ? containerItem.roomId - : containerItem.parentId; + const containerParentId: string = + 'space' in containerItem ? containerItem.roomId : containerItem.parentId; const itemContent = item.content; if (item.parentId !== containerParentId) { - mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId); + mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); } if ( @@ -265,28 +279,29 @@ export function Lobby() { 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, { + mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { ...joinRuleContent, allow, }); } } - const childItems = flattenHierarchy - .filter((i) => i.parentId === containerParentId && !i.space) - .filter((i) => i.roomId !== item.roomId); + const itemSpaces = Array.from( + hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? [] + ); - const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem; - const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId); + const beforeItem: HierarchyItem | undefined = + 'space' in containerItem ? undefined : containerItem; + const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId); const insertIndex = beforeIndex + 1; - childItems.splice(insertIndex, 0, { + itemSpaces.splice(insertIndex, 0, { ...item, parentId: containerParentId, content: { ...itemContent, order: undefined }, }); - const currentOrders = childItems.map((i) => { + const currentOrders = itemSpaces.map((i) => { if (typeof i.content.order === 'string' && lex.has(i.content.order)) { return i.content.order; } @@ -296,18 +311,18 @@ export function Lobby() { const newOrders = orderKeys(lex, currentOrders); newOrders?.forEach((orderKey, index) => { - const itm = childItems[index]; + const itm = itemSpaces[index]; if (itm && orderKey !== currentOrders[index]) { mx.sendStateEvent( containerParentId, - StateEvent.SpaceChild, + StateEvent.SpaceChild as any, { ...itm.content, order: orderKey }, itm.roomId ); } }); }, - [mx, flattenHierarchy, lex] + [mx, hierarchy, lex] ); useDnDMonitor( @@ -318,7 +333,7 @@ export function Lobby() { if (!canDrop(item, container)) { return; } - if (item.space) { + if ('space' in item) { reorderSpace(item, container.item); } else { reorderRoom(item, container.item); @@ -328,8 +343,16 @@ export function Lobby() { ) ); - const addSpaceRoom = useCallback( - (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }), + const handleSpacesFound = useCallback( + (sItems: IHierarchyRoom[]) => { + setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) }); + setSpacesItem((current) => { + const newItems = produce(current, (draft) => { + sItems.forEach((item) => draft.set(item.room_id, item)); + }); + return current.size === newItems.size ? current : newItems; + }); + }, [setSpaceRooms] ); @@ -394,121 +417,44 @@ export function Lobby() { {vItems.map((vItem) => { - const item = flattenHierarchy[vItem.index]; + const item = hierarchy[vItem.index]; if (!item) return null; - const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; - const userPLInItem = powerLevelAPI.getPowerLevel( - itemPowerLevel, - mx.getUserId() ?? undefined - ); - const canInvite = powerLevelAPI.canDoAction( - itemPowerLevel, - 'invite', - userPLInItem - ); - const isJoined = allJoinedRooms.has(item.roomId); + const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId; - const nextRoomId: string | undefined = - flattenHierarchy[vItem.index + 1]?.roomId; + const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId); - const dragging = - draggingItem?.roomId === item.roomId && - draggingItem.parentId === item.parentId; - - if (item.space) { - const categoryId = makeLobbyCategoryId(space.roomId, item.roomId); - const { parentId } = item; - const parentPowerLevels = parentId - ? roomsPowerLevels.get(parentId) ?? {} - : undefined; - - return ( - - - ) - } - before={item.parentId ? undefined : undefined} - after={ - - } - onDragging={setDraggingItem} - data-dragging={dragging} - /> - - ); - } - - const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {}; - const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1]; - const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1]; return ( - + - } - data-dragging={dragging} + handleClose={handleCategoryClick} + draggingItem={draggingItem} onDragging={setDraggingItem} + canDrop={canDrop} + nextSpaceId={nextSpaceId} + getRoom={getRoom} + pinned={sidebarSpaces.has(item.space.roomId)} + togglePinToSidebar={togglePinToSidebar} + onSpacesFound={handleSpacesFound} + onOpenRoom={handleOpenRoom} /> ); diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index f8db3991..994cda05 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react'; +import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react'; import { Avatar, Badge, @@ -20,23 +20,20 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { millify } from '../../plugins/millify'; -import { - HierarchyRoomSummaryLoader, - LocalRoomSummaryLoader, -} from '../../components/RoomSummaryLoader'; +import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader'; import { UseStateProvider } from '../../components/UseStateProvider'; import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; -import { Membership, RoomType } from '../../../types/matrix/room'; +import { Membership } from '../../../types/matrix/room'; import * as css from './RoomItem.css'; import * as styleCss from './style.css'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { ErrorCode } from '../../cs-errorcode'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; @@ -125,13 +122,11 @@ function RoomProfileLoading() { type RoomProfileErrorProps = { roomId: string; - error: Error; + inaccessibleRoom: boolean; suggested?: boolean; via?: string[]; }; -function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) { - const privateRoom = error.name === ErrorCode.M_FORBIDDEN; - +function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) { return ( @@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro renderFallback={() => ( )} @@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro )} - {privateRoom && ( - <> - - Private Room - - - + {inaccessibleRoom ? ( + + Inaccessible + + ) : ( + + {roomId} + )} - - {roomId} - - {!privateRoom && } + {!inaccessibleRoom && } ); } @@ -288,23 +276,11 @@ function RoomProfile({ ); } -function CallbackOnFoundSpace({ - roomId, - onSpaceFound, -}: { - roomId: string; - onSpaceFound: (roomId: string) => void; -}) { - useEffect(() => { - onSpaceFound(roomId); - }, [roomId, onSpaceFound]); - - return null; -} - type RoomItemCardProps = { item: HierarchyItem; - onSpaceFound: (roomId: string) => void; + loading: boolean; + error: Error | null; + summary: IHierarchyRoom | undefined; dm?: boolean; firstChild?: boolean; lastChild?: boolean; @@ -320,10 +296,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( ( { item, - onSpaceFound, + loading, + error, + summary, dm, - firstChild, - lastChild, onOpen, options, before, @@ -348,8 +324,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( return ( ( name={localSummary.name} topic={localSummary.topic} avatarUrl={ - dm ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication) + dm + ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) + : getRoomAvatarUrl(mx, room, 96, useAuthentication) } memberCount={localSummary.memberCount} suggested={content.suggested} @@ -395,46 +371,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( )} ) : ( - - {(summaryState) => ( - <> - {summaryState.status === AsyncStatus.Loading && } - {summaryState.status === AsyncStatus.Error && ( - - )} - {summaryState.status === AsyncStatus.Success && ( - <> - {summaryState.data.room_type === RoomType.Space && ( - - )} - + {!summary && + (error ? ( + + ) : ( + <> + {loading && } + {!loading && ( + } + via={content.via} /> - - )} - + )} + + ))} + {summary && ( + } + /> )} - + )} {options} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx new file mode 100644 index 00000000..2c43282f --- /dev/null +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -0,0 +1,225 @@ +import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react'; +import { MatrixError, Room } from 'matrix-js-sdk'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { Box, config, Text } from 'folds'; +import { + HierarchyItem, + HierarchyItemRoom, + HierarchyItemSpace, + useFetchSpaceHierarchyLevel, +} from '../../hooks/useSpaceHierarchy'; +import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { SpaceItemCard } from './SpaceItem'; +import { AfterItemDropTarget, CanDropCallback } from './DnD'; +import { HierarchyItemMenu } from './HierarchyItemMenu'; +import { RoomItemCard } from './RoomItem'; +import { RoomType } from '../../../types/matrix/room'; +import { SequenceCard } from '../../components/sequence-card'; + +type SpaceHierarchyProps = { + summary: IHierarchyRoom | undefined; + spaceItem: HierarchyItemSpace; + roomItems?: HierarchyItemRoom[]; + allJoinedRooms: Set; + mDirects: Set; + roomsPowerLevels: Map; + canEditSpaceChild: (powerLevels: IPowerLevels) => boolean; + categoryId: string; + closed: boolean; + handleClose: MouseEventHandler; + draggingItem?: HierarchyItem; + onDragging: (item?: HierarchyItem) => void; + canDrop: CanDropCallback; + nextSpaceId?: string; + getRoom: (roomId: string) => Room | undefined; + pinned: boolean; + togglePinToSidebar: (roomId: string) => void; + onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; + onOpenRoom: MouseEventHandler; +}; +export const SpaceHierarchy = forwardRef( + ( + { + summary, + spaceItem, + roomItems, + allJoinedRooms, + mDirects, + roomsPowerLevels, + canEditSpaceChild, + categoryId, + closed, + handleClose, + draggingItem, + onDragging, + canDrop, + nextSpaceId, + getRoom, + pinned, + togglePinToSidebar, + onOpenRoom, + onSpacesFound, + }, + ref + ) => { + const mx = useMatrixClient(); + + const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true); + + const subspaces = useMemo(() => { + const s: Map = new Map(); + rooms.forEach((r) => { + if (r.room_type === RoomType.Space) { + s.set(r.room_id, r); + } + }); + return s; + }, [rooms]); + + const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {}; + const userPLInSpace = powerLevelAPI.getPowerLevel( + spacePowerLevels, + mx.getUserId() ?? undefined + ); + const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace); + + const draggingSpace = + draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId; + + const { parentId } = spaceItem; + const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined; + + useEffect(() => { + onSpacesFound(Array.from(subspaces.values())); + }, [subspaces, onSpacesFound]); + + let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId)); + if (!canEditSpaceChild(spacePowerLevels)) { + // hide unknown rooms for normal user + childItems = childItems?.filter((i) => { + const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false; + const inaccessibleRoom = !rooms.get(i.roomId) && !fetching && (error ? forbidden : true); + return !inaccessibleRoom; + }); + } + + return ( + + + ) + } + after={ + + } + onDragging={onDragging} + data-dragging={draggingSpace} + /> + {childItems && childItems.length > 0 ? ( + + {childItems.map((roomItem, index) => { + const roomSummary = rooms.get(roomItem.roomId); + + const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {}; + const userPLInRoom = powerLevelAPI.getPowerLevel( + roomPowerLevels, + mx.getUserId() ?? undefined + ); + const canInviteInRoom = powerLevelAPI.canDoAction( + roomPowerLevels, + 'invite', + userPLInRoom + ); + + const lastItem = index === childItems.length; + const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId; + + const roomDragging = + draggingItem?.roomId === roomItem.roomId && + draggingItem.parentId === roomItem.parentId; + + return ( + + } + after={ + + } + data-dragging={roomDragging} + onDragging={onDragging} + /> + ); + })} + + ) : ( + childItems && ( + + + + No Rooms + + + This space does not contains rooms yet. + + + + ) + )} + + ); + } +); diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index deaf9ba5..0a4d9de5 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -19,19 +19,16 @@ import { import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; import { MatrixError, Room } from 'matrix-js-sdk'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { RoomAvatar } from '../../components/room-avatar'; import { nameInitials } from '../../utils/common'; -import { - HierarchyRoomSummaryLoader, - LocalRoomSummaryLoader, -} from '../../components/RoomSummaryLoader'; +import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader'; import { getRoomAvatarUrl } from '../../utils/room'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import * as css from './SpaceItem.css'; import * as styleCss from './style.css'; -import { ErrorCode } from '../../cs-errorcode'; import { useDraggableItem } from './DnD'; import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation'; import { stopPropagation } from '../../utils/keyboard'; @@ -53,18 +50,11 @@ function SpaceProfileLoading() { ); } -type UnknownPrivateSpaceProfileProps = { +type InaccessibleSpaceProfileProps = { roomId: string; - name?: string; - avatarUrl?: string; suggested?: boolean; }; -function UnknownPrivateSpaceProfile({ - roomId, - name, - avatarUrl, - suggested, -}: UnknownPrivateSpaceProfileProps) { +function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) { return ( ( - {nameInitials(name)} + U )} /> @@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({ > - {name || 'Unknown'} + Unknown - Private Space + Inaccessible {suggested && ( @@ -104,20 +92,20 @@ function UnknownPrivateSpaceProfile({ ); } -type UnknownSpaceProfileProps = { +type UnjoinedSpaceProfileProps = { roomId: string; via?: string[]; name?: string; avatarUrl?: string; suggested?: boolean; }; -function UnknownSpaceProfile({ +function UnjoinedSpaceProfile({ roomId, via, name, avatarUrl, suggested, -}: UnknownSpaceProfileProps) { +}: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); const [joinState, join] = useAsyncCallback( @@ -376,6 +364,8 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { } type SpaceItemCardProps = { + summary: IHierarchyRoom | undefined; + loading?: boolean; item: HierarchyItem; joined?: boolean; categoryId: string; @@ -393,6 +383,8 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( ( { className, + summary, + loading, joined, closed, categoryId, @@ -451,37 +443,31 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( } ) : ( - - {(summaryState) => ( - <> - {summaryState.status === AsyncStatus.Loading && } - {summaryState.status === AsyncStatus.Error && - (summaryState.error.name === ErrorCode.M_FORBIDDEN ? ( - - ) : ( - - ))} - {summaryState.status === AsyncStatus.Success && ( - - )} - + <> + {!summary && + (loading ? ( + + ) : ( + + ))} + {summary && ( + )} - + )} {canEditChild && ( diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index c109cc21..ad34e3f4 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -1,6 +1,8 @@ import { atom, useAtom, useAtomValue } from 'jotai'; -import { useCallback, useEffect, useState } from 'react'; -import { Room } from 'matrix-js-sdk'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MatrixError, Room } from 'matrix-js-sdk'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query'; import { useMatrixClient } from './useMatrixClient'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { MSpaceChildContent, StateEvent } from '../../types/matrix/room'; @@ -8,22 +10,24 @@ import { getAllParents, getStateEvents, isValidChild } from '../utils/room'; import { isRoomId } from '../utils/matrix'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { useStateEventCallback } from './useStateEventCallback'; +import { ErrorCode } from '../cs-errorcode'; -export type HierarchyItem = - | { - roomId: string; - content: MSpaceChildContent; - ts: number; - space: true; - parentId?: string; - } - | { - roomId: string; - content: MSpaceChildContent; - ts: number; - space?: false; - parentId: string; - }; +export type HierarchyItemSpace = { + roomId: string; + content: MSpaceChildContent; + ts: number; + space: true; + parentId?: string; +}; + +export type HierarchyItemRoom = { + roomId: string; + content: MSpaceChildContent; + ts: number; + parentId: string; +}; + +export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom; type GetRoomCallback = (roomId: string) => Room | undefined; @@ -35,16 +39,16 @@ const getHierarchySpaces = ( rootSpaceId: string, getRoom: GetRoomCallback, spaceRooms: Set -): HierarchyItem[] => { - const rootSpaceItem: HierarchyItem = { +): HierarchyItemSpace[] => { + const rootSpaceItem: HierarchyItemSpace = { roomId: rootSpaceId, content: { via: [] }, ts: 0, space: true, }; - let spaceItems: HierarchyItem[] = []; + let spaceItems: HierarchyItemSpace[] = []; - const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => { + const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; const space = getRoom(spaceItem.roomId); spaceItems.push(spaceItem); @@ -61,7 +65,7 @@ const getHierarchySpaces = ( // or requesting room summary, we will look it into spaceRooms local // cache which we maintain as we load summary in UI. if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) { - const childItem: HierarchyItem = { + const childItem: HierarchyItemSpace = { roomId: childId, content: childEvent.getContent(), ts: childEvent.getTs(), @@ -85,28 +89,34 @@ const getHierarchySpaces = ( return spaceItems; }; +export type SpaceHierarchy = { + space: HierarchyItemSpace; + rooms?: HierarchyItemRoom[]; +}; const getSpaceHierarchy = ( rootSpaceId: string, spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, closedCategory: (spaceId: string) => boolean -): HierarchyItem[] => { - const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); +): SpaceHierarchy[] => { + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); - const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { + const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => { const space = getRoom(spaceItem.roomId); if (!space || closedCategory(spaceItem.roomId)) { - return [spaceItem]; + return { + space: spaceItem, + }; } const childEvents = getStateEvents(space, StateEvent.SpaceChild); - const childItems: HierarchyItem[] = []; + const childItems: HierarchyItemRoom[] = []; childEvents.forEach((childEvent) => { if (!isValidChild(childEvent)) return; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return; if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return; - const childItem: HierarchyItem = { + const childItem: HierarchyItemRoom = { roomId: childId, content: childEvent.getContent(), ts: childEvent.getTs(), @@ -114,7 +124,11 @@ const getSpaceHierarchy = ( }; childItems.push(childItem); }); - return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)]; + + return { + space: spaceItem, + rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder), + }; }); return hierarchy; @@ -125,7 +139,7 @@ export const useSpaceHierarchy = ( spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, closedCategory: (spaceId: string) => boolean -): HierarchyItem[] => { +): SpaceHierarchy[] => { const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); @@ -163,7 +177,7 @@ const getSpaceJoinedHierarchy = ( excludeRoom: (parentId: string, roomId: string) => boolean, sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[] ): HierarchyItem[] => { - const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const space = getRoom(spaceItem.roomId); @@ -182,14 +196,14 @@ const getSpaceJoinedHierarchy = ( if (joinedRoomEvents.length === 0) return []; - const childItems: HierarchyItem[] = []; + const childItems: HierarchyItemRoom[] = []; joinedRoomEvents.forEach((childEvent) => { const childId = childEvent.getStateKey(); if (!childId) return; if (excludeRoom(space.roomId, childId)) return; - const childItem: HierarchyItem = { + const childItem: HierarchyItemRoom = { roomId: childId, content: childEvent.getContent(), ts: childEvent.getTs(), @@ -251,3 +265,85 @@ export const useSpaceJoinedHierarchy = ( return hierarchy; }; + +// we will paginate until 5000 items +const PER_PAGE_COUNT = 100; +const MAX_AUTO_PAGE_COUNT = 50; +export type FetchSpaceHierarchyLevelData = { + fetching: boolean; + error: Error | null; + rooms: Map; +}; +export const useFetchSpaceHierarchyLevel = ( + roomId: string, + enable: boolean +): FetchSpaceHierarchyLevelData => { + const mx = useMatrixClient(); + const pageNoRef = useRef(0); + + const fetchLevel: QueryFunction< + Awaited>, + string[], + string | undefined + > = useCallback( + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), + [roomId, mx] + ); + + const queryResponse = useInfiniteQuery({ + refetchOnMount: enable, + queryKey: [roomId, 'hierarchy_level'], + initialPageParam: undefined, + queryFn: fetchLevel, + getNextPageParam: (result) => { + if (result.next_batch) return result.next_batch; + return undefined; + }, + retry: 5, + retryDelay: (failureCount, error) => { + if (error instanceof MatrixError && error.errcode === ErrorCode.M_LIMIT_EXCEEDED) { + const { retry_after_ms: delay } = error.data; + if (typeof delay === 'number') { + return delay; + } + } + + return 500 * failureCount; + }, + }); + + const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse; + + useEffect(() => { + if ( + hasNextPage && + pageNoRef.current <= MAX_AUTO_PAGE_COUNT && + !error && + data && + data.pages.length > 0 + ) { + pageNoRef.current += 1; + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, data, error]); + + const rooms: Map = useMemo(() => { + const roomsMap: Map = new Map(); + if (!data) return roomsMap; + + const rms = data.pages.flatMap((result) => result.rooms); + rms.forEach((r) => { + roomsMap.set(r.room_id, r); + }); + + return roomsMap; + }, [data]); + + const fetching = isLoading || isFetchingNextPage; + + return { + fetching, + error, + rooms, + }; +}; diff --git a/src/app/state/spaceRooms.ts b/src/app/state/spaceRooms.ts index 8480498d..94abe2bc 100644 --- a/src/app/state/spaceRooms.ts +++ b/src/app/state/spaceRooms.ts @@ -23,32 +23,37 @@ const baseSpaceRoomsAtom = atomWithLocalStorage>( type SpaceRoomsAction = | { type: 'PUT'; - roomId: string; + roomIds: string[]; } | { type: 'DELETE'; - roomId: string; + roomIds: string[]; }; export const spaceRoomsAtom = atom, [SpaceRoomsAction], undefined>( (get) => get(baseSpaceRoomsAtom), (get, set, action) => { - if (action.type === 'DELETE') { + const current = get(baseSpaceRoomsAtom); + const { type, roomIds } = action; + + if (type === 'DELETE' && roomIds.find((roomId) => current.has(roomId))) { set( baseSpaceRoomsAtom, - produce(get(baseSpaceRoomsAtom), (draft) => { - draft.delete(action.roomId); + produce(current, (draft) => { + roomIds.forEach((roomId) => draft.delete(roomId)); }) ); return; } - if (action.type === 'PUT') { - set( - baseSpaceRoomsAtom, - produce(get(baseSpaceRoomsAtom), (draft) => { - draft.add(action.roomId); - }) - ); + if (type === 'PUT') { + const newEntries = roomIds.filter((roomId) => !current.has(roomId)); + if (newEntries.length > 0) + set( + baseSpaceRoomsAtom, + produce(current, (draft) => { + newEntries.forEach((roomId) => draft.add(roomId)); + }) + ); } } ); From dd4c1a94e6aa704f316891ce58760dee616f8ffb Mon Sep 17 00:00:00 2001 From: Ginger <75683114+gingershaped@users.noreply.github.com> Date: Sat, 22 Feb 2025 03:55:13 -0500 Subject: [PATCH 013/138] Add support for spoilers on images (MSC4193) (#2212) * Add support for MSC4193: Spoilers on Media * Clarify variable names and wording * Restore list atom * Improve spoilered image UX with autoload off * Use `aria-pressed` to indicate attachment spoiler state * Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors --- .../components/message/MsgTypeRenderers.tsx | 6 +++ .../message/content/ImageContent.tsx | 46 ++++++++++++++++-- .../components/message/content/style.css.ts | 7 +++ .../upload-card/UploadCardRenderer.tsx | 48 +++++++++++++++++-- src/app/features/room/RoomInput.tsx | 27 +++++++++-- src/app/features/room/msgContent.ts | 13 +++-- src/app/state/list.ts | 11 ++++- src/app/state/room/roomInputDrafts.ts | 13 +++-- src/types/matrix/common.ts | 6 +++ 9 files changed, 158 insertions(+), 19 deletions(-) diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 6138d0d7..287a5ca4 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -22,6 +22,8 @@ import { IThumbnailContent, IVideoContent, IVideoInfo, + MATRIX_SPOILER_PROPERTY_NAME, + MATRIX_SPOILER_REASON_PROPERTY_NAME, } from '../../../types/matrix/common'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { parseGeoUri, scaleYDimension } from '../../utils/common'; @@ -177,6 +179,8 @@ type RenderImageContentProps = { mimeType?: string; url: string; encInfo?: IEncryptedFile; + markedAsSpoiler?: boolean; + spoilerReason?: string; }; type MImageProps = { content: IImageContent; @@ -204,6 +208,8 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { mimeType: imgInfo?.mimetype, url: mxcUrl, encInfo: content.file, + markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME], + spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], })} diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index d4241b64..69c7ade8 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -3,6 +3,7 @@ import { Badge, Box, Button, + Chip, Icon, Icons, Modal, @@ -51,6 +52,8 @@ export type ImageContentProps = { info?: IImageInfo; encInfo?: EncryptedAttachmentInfo; autoPlay?: boolean; + markedAsSpoiler?: boolean; + spoilerReason?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; }; @@ -64,6 +67,8 @@ export const ImageContent = as<'div', ImageContentProps>( info, encInfo, autoPlay, + markedAsSpoiler, + spoilerReason, renderViewer, renderImage, ...props @@ -77,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>( const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); + const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { @@ -145,7 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>( punch={1} /> )} - {!autoPlay && srcState.status === AsyncStatus.Idle && ( + {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
    - + {hideActivity ? : } ); diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 0a0358e0..18b53ac9 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -1,6 +1,14 @@ +import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +export const RoomViewFollowingPlaceholder = style([ + DefaultReset, + { + height: toRem(28), + }, +]); + export const RoomViewFollowing = recipe({ base: [ DefaultReset, diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx index 58d3f64f..5a96e6ad 100644 --- a/src/app/features/room/RoomViewFollowing.tsx +++ b/src/app/features/room/RoomViewFollowing.tsx @@ -24,6 +24,10 @@ import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; import { EventReaders } from '../../components/event-readers'; import { stopPropagation } from '../../utils/keyboard'; +export function RoomViewFollowingPlaceholder() { + return
    ; +} + export type RoomViewFollowingProps = { room: Room; }; diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 7ee1d302..deac935e 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -33,7 +33,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { StateEvent } from '../../../types/matrix/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoom } from '../../hooks/useRoom'; -import { useSetSetting } from '../../state/hooks/settings'; +import { useSetSetting, useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; @@ -64,13 +64,14 @@ type RoomMenuProps = { }; const RoomMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevelsContext(); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const handleMarkAsRead = () => { - markAsRead(mx, room.roomId); + markAsRead(mx, room.roomId, hideActivity); requestClose(); }; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index bde03eb2..d6709a97 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -671,6 +671,7 @@ export type MessageProps = { onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; reactions?: ReactNode; + hideReadReceipts?: boolean; }; export const Message = as<'div', MessageProps>( ( @@ -695,6 +696,7 @@ export const Message = as<'div', MessageProps>( onEditId, reply, reactions, + hideReadReceipts, children, ...props }, @@ -992,11 +994,13 @@ export const Message = as<'div', MessageProps>( )} - + {!hideReadReceipts && ( + + )} {canPinEvent && ( @@ -1071,9 +1075,23 @@ export type EventProps = { highlight: boolean; canDelete?: boolean; messageSpacing: MessageSpacing; + hideReadReceipts?: boolean; }; export const Event = as<'div', EventProps>( - ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => { + ( + { + className, + room, + mEvent, + highlight, + canDelete, + messageSpacing, + hideReadReceipts, + children, + ...props + }, + ref + ) => { const mx = useMatrixClient(); const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); @@ -1138,11 +1156,13 @@ export const Event = as<'div', EventProps>( > - + {!hideReadReceipts && ( + + )} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index d58c99ca..569cd410 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -344,6 +344,7 @@ function Appearance() { function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); return ( @@ -363,6 +364,13 @@ function Editor() { after={} /> + + } + /> + ); } @@ -555,7 +563,13 @@ function Messages() { setMediaAutoLoad(!v)} />} + after={ + setMediaAutoLoad(!v)} + /> + } /> diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index cd19e33e..5e799214 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -45,18 +45,21 @@ import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCatego import { useRoomsUnread } from '../../../state/hooks/unread'; import { markAsRead } from '../../../../client/action/notifications'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type DirectMenuProps = { requestClose: () => void; }; const DirectMenu = forwardRef(({ requestClose }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const orphanRooms = useDirectRooms(); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index f9923f46..fa5e68ab 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -48,18 +48,21 @@ import { useRoomsUnread } from '../../../state/hooks/unread'; import { markAsRead } from '../../../../client/action/notifications'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type HomeMenuProps = { requestClose: () => void; }; const HomeMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 0c832b09..722ce5d3 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -182,6 +182,7 @@ type RoomNotificationsGroupProps = { notifications: INotification[]; mediaAutoLoad?: boolean; urlPreview?: boolean; + hideActivity: boolean; onOpen: (roomId: string, eventId: string) => void; }; function RoomNotificationsGroupComp({ @@ -189,6 +190,7 @@ function RoomNotificationsGroupComp({ notifications, mediaAutoLoad, urlPreview, + hideActivity, onOpen, }: RoomNotificationsGroupProps) { const mx = useMatrixClient(); @@ -362,7 +364,7 @@ function RoomNotificationsGroupComp({ onOpen(room.roomId, eventId); }; const handleMarkAsRead = () => { - markAsRead(mx, room.roomId); + markAsRead(mx, room.roomId, hideActivity); }; return ( @@ -496,6 +498,7 @@ const DEFAULT_REFRESH_MS = 7000; export function Notifications() { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const screenSize = useScreenSizeContext(); @@ -656,6 +659,7 @@ export function Notifications() { notifications={group.notifications} mediaAutoLoad={mediaAutoLoad} urlPreview={urlPreview} + hideActivity={hideActivity} onOpen={navigateRoom} /> diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index 849fc365..bd8090d3 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -23,18 +23,21 @@ import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useDirectRooms } from '../direct/useDirectRooms'; import { markAsRead } from '../../../../client/action/notifications'; import { stopPropagation } from '../../../utils/keyboard'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; type DirectMenuProps = { requestClose: () => void; }; const DirectMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useDirectRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index dcb0a498..c8a80280 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -24,18 +24,21 @@ import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useHomeRooms } from '../home/useHomeRooms'; import { markAsRead } from '../../../../client/action/notifications'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type HomeMenuProps = { requestClose: () => void; }; const HomeMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 343afae4..96e3b9ad 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -88,6 +88,8 @@ import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { getRoomAvatarUrl } from '../../../utils/room'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type SpaceMenuProps = { room: Room; @@ -97,6 +99,7 @@ type SpaceMenuProps = { const SpaceMenu = forwardRef( ({ room, requestClose, onUnpin }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); @@ -110,7 +113,7 @@ const SpaceMenu = forwardRef( const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId)); + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; @@ -227,18 +230,18 @@ const useDraggableItem = ( return !target ? undefined : draggable({ - element: target, - dragHandle, - getInitialData: () => ({ item }), - onDragStart: () => { - setDragging(true); - onDragging?.(item); - }, - onDrop: () => { - setDragging(false); - onDragging?.(undefined); - }, - }); + element: target, + dragHandle, + getInitialData: () => ({ item }), + onDragStart: () => { + setDragging(true); + onDragging?.(item); + }, + onDrop: () => { + setDragging(false); + onDragging?.(undefined); + }, + }); }, [targetRef, dragHandleRef, item, onDragging]); return dragging; @@ -388,9 +391,9 @@ function SpaceTab({ () => folder ? { - folder, - spaceId: space.roomId, - } + folder, + spaceId: space.roomId, + } : space.roomId, [folder, space] ); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index c8e2b783..1714d8ee 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -69,6 +69,8 @@ import { StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type SpaceMenuProps = { room: Room; @@ -76,6 +78,7 @@ type SpaceMenuProps = { }; const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); @@ -89,7 +92,7 @@ const SpaceMenu = forwardRef(({ room, requestClo const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId)); + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 8cb9d958..bf99fe34 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -228,20 +228,18 @@ export const useBindRoomToUnreadAtom = ( useEffect(() => { const handleReceipt = (mEvent: MatrixEvent, room: Room) => { - if (mEvent.getType() === 'm.receipt') { - const myUserId = mx.getUserId(); - if (!myUserId) return; - if (room.isSpaceRoom()) return; - const content = mEvent.getContent(); + const myUserId = mx.getUserId(); + if (!myUserId) return; + if (room.isSpaceRoom()) return; + const content = mEvent.getContent(); - const isMyReceipt = Object.keys(content).find((eventId) => - (Object.keys(content[eventId]) as ReceiptType[]).find( - (receiptType) => content[eventId][receiptType][myUserId] - ) - ); - if (isMyReceipt) { - setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); - } + const isMyReceipt = Object.keys(content).find((eventId) => + (Object.keys(content[eventId]) as ReceiptType[]).find( + (receiptType) => content[eventId][receiptType][myUserId] + ) + ); + if (isMyReceipt) { + setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); } }; mx.on(RoomEvent.Receipt, handleReceipt); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index ac47e78b..9d979195 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -17,6 +17,7 @@ export interface Settings { editorToolbar: boolean; twitterEmoji: boolean; pageZoom: number; + hideActivity: boolean; isPeopleDrawer: boolean; memberSortFilterIndex: number; @@ -45,6 +46,7 @@ const defaultSettings: Settings = { editorToolbar: false, twitterEmoji: false, pageZoom: 100, + hideActivity: false, isPeopleDrawer: true, memberSortFilterIndex: 0, diff --git a/src/app/state/typingMembers.ts b/src/app/state/typingMembers.ts index 55bf8f62..e94ba972 100644 --- a/src/app/state/typingMembers.ts +++ b/src/app/state/typingMembers.ts @@ -2,6 +2,8 @@ import produce from 'immer'; import { atom, useSetAtom } from 'jotai'; import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk'; import { useEffect } from 'react'; +import { useSetting } from './hooks/settings'; +import { settingsAtom } from './settings'; export const TYPING_TIMEOUT_MS = 5000; // 5 seconds @@ -127,12 +129,16 @@ export const useBindRoomIdToTypingMembersAtom = ( typingMembersAtom: typeof roomIdToTypingMembersAtom ) => { const setTypingMembers = useSetAtom(typingMembersAtom); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); useEffect(() => { const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = ( event, member ) => { + if (hideActivity) { + return; + } setTypingMembers({ type: member.typing ? 'PUT' : 'DELETE', roomId: member.roomId, @@ -145,5 +151,5 @@ export const useBindRoomIdToTypingMembersAtom = ( return () => { mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent); }; - }, [mx, setTypingMembers]); + }, [mx, setTypingMembers, hideActivity]); }; diff --git a/src/client/action/notifications.ts b/src/client/action/notifications.ts index 17ea1ed6..a23bd1a4 100644 --- a/src/client/action/notifications.ts +++ b/src/client/action/notifications.ts @@ -1,6 +1,6 @@ -import { MatrixClient } from "matrix-js-sdk"; +import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; -export async function markAsRead(mx: MatrixClient, roomId: string) { +export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { const room = mx.getRoom(roomId); if (!room) return; @@ -19,5 +19,8 @@ export async function markAsRead(mx: MatrixClient, roomId: string) { const latestEvent = getLatestValidEvent(); if (latestEvent === null) return; - await mx.sendReadReceipt(latestEvent); + await mx.sendReadReceipt( + latestEvent, + privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read + ); } From 2b8b0dcffdf57e4f5584c5b87f5b65dcdcb71989 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 27 Feb 2025 19:34:55 +1100 Subject: [PATCH 020/138] open account data in same window instead of popup (#2234) * refactor TextViewer Content component * open account data inside setting window * close account data edit window on cancel when adding new --- .../components/text-viewer/TextViewer.css.ts | 5 +- src/app/components/text-viewer/TextViewer.tsx | 38 +- .../settings/developer-tools/AccountData.tsx | 90 ++++ .../developer-tools/AccountDataEditor.tsx | 447 +++++++++++------- .../settings/developer-tools/DevelopTools.tsx | 232 +-------- 5 files changed, 429 insertions(+), 383 deletions(-) create mode 100644 src/app/features/settings/developer-tools/AccountData.tsx diff --git a/src/app/components/text-viewer/TextViewer.css.ts b/src/app/components/text-viewer/TextViewer.css.ts index 2b79fa64..83ee6058 100644 --- a/src/app/components/text-viewer/TextViewer.css.ts +++ b/src/app/components/text-viewer/TextViewer.css.ts @@ -31,8 +31,11 @@ export const TextViewerContent = style([ export const TextViewerPre = style([ DefaultReset, { - padding: config.space.S600, whiteSpace: 'pre-wrap', wordBreak: 'break-word', }, ]); + +export const TextViewerPrePadding = style({ + padding: config.space.S600, +}); diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index 7829fb35..f39ef953 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -1,5 +1,5 @@ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -import React, { Suspense, lazy } from 'react'; +import React, { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds'; import { ErrorBoundary } from 'react-error-boundary'; @@ -8,6 +8,29 @@ import { copyToClipboard } from '../../utils/dom'; const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); +type TextViewerContentProps = { + text: string; + langName: string; + size?: ComponentProps['size']; +} & HTMLAttributes; +export const TextViewerContent = forwardRef( + ({ text, langName, size, className, ...props }, ref) => ( + + {text}}> + {text}}> + {(codeRef) => {text}} + + + + ) +); + export type TextViewerProps = { name: string; text: string; @@ -43,6 +66,7 @@ export const TextViewer = as<'div', TextViewerProps>( + ( alignItems="Center" > - - {text}}> - {text}}> - {(codeRef) => {text}} - - - + diff --git a/src/app/features/settings/developer-tools/AccountData.tsx b/src/app/features/settings/developer-tools/AccountData.tsx new file mode 100644 index 00000000..743c28b7 --- /dev/null +++ b/src/app/features/settings/developer-tools/AccountData.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useState } from 'react'; +import { Box, Text, Icon, Icons, Chip, Button } 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 { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; + +type AccountDataProps = { + expand: boolean; + onExpandToggle: (expand: boolean) => void; + onSelect: (type: string | null) => void; +}; +export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) { + const mx = useMatrixClient(); + const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values())); + + useAccountDataCallback( + mx, + useCallback( + () => setAccountData(Array.from(mx.store.accountData.values())), + [mx, setAccountData] + ) + ); + + return ( + + Account Data + + onExpandToggle(!expand)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expand ? 'Collapse' : 'Expand'} + + } + /> + {expand && ( + + + Types + + } + onClick={() => onSelect(null)} + > + + Add New + + + {accountData.map((mEvent) => ( + onSelect(mEvent.getType())} + > + + {mEvent.getType()} + + + ))} + + + + )} + + + ); +} diff --git a/src/app/features/settings/developer-tools/AccountDataEditor.tsx b/src/app/features/settings/developer-tools/AccountDataEditor.tsx index 52e9870e..b5ac0f8a 100644 --- a/src/app/features/settings/developer-tools/AccountDataEditor.tsx +++ b/src/app/features/settings/developer-tools/AccountDataEditor.tsx @@ -8,9 +8,7 @@ import React, { useState, } from 'react'; import { - as, Box, - Header, Text, Icon, Icons, @@ -20,6 +18,9 @@ import { TextArea as TextAreaComponent, color, Spinner, + Chip, + Scroll, + config, } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { MatrixError } from 'matrix-js-sdk'; @@ -30,182 +31,302 @@ import { GetTarget } from '../../../plugins/text-area/type'; import { syntaxErrorPosition } from '../../../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { Page, PageHeader } from '../../../components/page'; +import { useAlive } from '../../../hooks/useAlive'; +import { SequenceCard } from '../../../components/sequence-card'; +import { TextViewerContent } from '../../../components/text-viewer'; const EDITOR_INTENT_SPACE_COUNT = 2; +type AccountDataInfo = { + type: string; + content: object; +}; + +type AccountDataEditProps = { + type: string; + defaultContent: string; + onCancel: () => void; + onSave: (info: AccountDataInfo) => void; +}; +function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) { + const mx = useMatrixClient(); + const alive = useAlive(); + + const textAreaRef = useRef(null); + const [jsonError, setJSONError] = useState(); + + const getTarget: GetTarget = useCallback(() => { + const target = textAreaRef.current; + if (!target) throw new Error('TextArea element not found!'); + return target; + }, []); + + const { textArea, operations, intent } = useMemo(() => { + const ta = new TextArea(getTarget); + const op = new TextAreaOperations(getTarget); + return { + textArea: ta, + operations: op, + intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op), + }; + }, [getTarget]); + + const intentHandler = useTextAreaIntentHandler(textArea, operations, intent); + + const handleKeyDown: KeyboardEventHandler = (evt) => { + intentHandler(evt); + if (isKeyHotkey('escape', evt)) { + const cursor = Cursor.fromTextAreaElement(getTarget()); + operations.deselect(cursor); + } + }; + + const [submitState, submit] = useAsyncCallback( + useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx]) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (submitting) return; + + const target = evt.target as HTMLFormElement | undefined; + const typeInput = target?.typeInput as HTMLInputElement | undefined; + const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined; + if (!typeInput || !contentTextArea) return; + + const typeStr = typeInput.value.trim(); + const contentStr = contentTextArea.value.trim(); + + let parsedContent: object; + try { + parsedContent = JSON.parse(contentStr); + } catch (e) { + setJSONError(e as SyntaxError); + return; + } + setJSONError(undefined); + + if ( + !typeStr || + parsedContent === null || + defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) + ) { + return; + } + + submit(typeStr, parsedContent).then(() => { + if (alive()) { + onSave({ + type: typeStr, + content: parsedContent, + }); + } + }); + }; + + useEffect(() => { + if (jsonError) { + const errorPosition = syntaxErrorPosition(jsonError) ?? 0; + const cursor = new Cursor(errorPosition, errorPosition, 'none'); + operations.select(cursor); + getTarget()?.focus(); + } + }, [jsonError, operations, getTarget]); + + return ( + + + Account Data + + + 0 || submitting ? 'SurfaceVariant' : 'Background'} + name="typeInput" + size="400" + radii="300" + readOnly={type.length > 0 || submitting} + defaultValue={type} + required + /> + + + + + + {submitState.status === AsyncStatus.Error && ( + + {submitState.error.message} + + )} + + + + JSON Content + + + {jsonError && ( + + + {jsonError.name}: {jsonError.message} + + + )} + + + ); +} + +type AccountDataViewProps = { + type: string; + defaultContent: string; + onEdit: () => void; +}; +function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) { + return ( + + + + Account Data + + + + + + JSON Content + + + + + + + + ); +} + export type AccountDataEditorProps = { type?: string; - content?: object; requestClose: () => void; }; -export const AccountDataEditor = as<'div', AccountDataEditorProps>( - ({ type, content, requestClose, ...props }, ref) => { - const mx = useMatrixClient(); - const defaultContent = useMemo( - () => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT), - [content] - ); - const textAreaRef = useRef(null); - const [jsonError, setJSONError] = useState(); +export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) { + const mx = useMatrixClient(); - const getTarget: GetTarget = useCallback(() => { - const target = textAreaRef.current; - if (!target) throw new Error('TextArea element not found!'); - return target; - }, []); + const [data, setData] = useState({ + type: type ?? '', + content: mx.getAccountData(type ?? '')?.getContent() ?? {}, + }); - const { textArea, operations, intent } = useMemo(() => { - const ta = new TextArea(getTarget); - const op = new TextAreaOperations(getTarget); - return { - textArea: ta, - operations: op, - intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op), - }; - }, [getTarget]); + const [edit, setEdit] = useState(!type); - const intentHandler = useTextAreaIntentHandler(textArea, operations, intent); + const closeEdit = useCallback(() => { + if (!type) { + requestClose(); + return; + } + setEdit(false); + }, [type, requestClose]); - const handleKeyDown: KeyboardEventHandler = (evt) => { - intentHandler(evt); - if (isKeyHotkey('escape', evt)) { - const cursor = Cursor.fromTextAreaElement(getTarget()); - operations.deselect(cursor); - } - }; + const handleSave = useCallback((info: AccountDataInfo) => { + setData(info); + setEdit(false); + }, []); - const [submitState, submit] = useAsyncCallback( - useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx]) - ); - const submitting = submitState.status === AsyncStatus.Loading; + const contentJSONStr = useMemo( + () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT), + [data.content] + ); - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (submitting) return; - - const target = evt.target as HTMLFormElement | undefined; - const typeInput = target?.typeInput as HTMLInputElement | undefined; - const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined; - if (!typeInput || !contentTextArea) return; - - const typeStr = typeInput.value.trim(); - const contentStr = contentTextArea.value.trim(); - - let parsedContent: object; - try { - parsedContent = JSON.parse(contentStr); - } catch (e) { - setJSONError(e as SyntaxError); - return; - } - setJSONError(undefined); - - if ( - !typeStr || - parsedContent === null || - defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) - ) { - return; - } - - submit(typeStr, parsedContent); - }; - - useEffect(() => { - if (jsonError) { - const errorPosition = syntaxErrorPosition(jsonError) ?? 0; - const cursor = new Cursor(errorPosition, errorPosition, 'none'); - operations.select(cursor); - getTarget()?.focus(); - } - }, [jsonError, operations, getTarget]); - - useEffect(() => { - if (submitState.status === AsyncStatus.Success) { - requestClose(); - } - }, [submitState, requestClose]); - - return ( - -
    - - - - Account Data - - - - - - - + return ( + + + + + } + > + Developer Tools + -
    - - - Type - - - - - - - - {submitState.status === AsyncStatus.Error && ( - - {submitState.error.message} - - )} - - - - JSON Content - - - {jsonError && ( - - - {jsonError.name}: {jsonError.message} - - - )} + + + + + + + {edit ? ( + + ) : ( + setEdit(true)} + /> + )} - ); - } -); + + ); +} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 081a26e3..b66452f5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,26 +1,5 @@ -import React, { MouseEventHandler, useCallback, useState } from 'react'; -import { - Box, - Text, - IconButton, - Icon, - Icons, - Scroll, - Switch, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, - Chip, - Button, - PopOut, - RectCords, - Menu, - config, - MenuItem, -} from 'folds'; -import { MatrixEvent } from 'matrix-js-sdk'; -import FocusTrap from 'focus-trap-react'; +import React, { useState } from 'react'; +import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -28,195 +7,9 @@ import { SettingTile } from '../../../components/setting-tile'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; -import { TextViewer } from '../../../components/text-viewer'; -import { stopPropagation } from '../../../utils/keyboard'; import { AccountDataEditor } from './AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; - -function AccountData() { - const mx = useMatrixClient(); - const [view, setView] = useState(false); - const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values())); - const [selectedEvent, selectEvent] = useState(); - const [menuCords, setMenuCords] = useState(); - const [selectedOption, selectOption] = useState<'edit' | 'inspect'>(); - - useAccountDataCallback( - mx, - useCallback( - () => setAccountData(Array.from(mx.store.accountData.values())), - [mx, setAccountData] - ) - ); - - const handleMenu: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const eventType = target.getAttribute('data-event-type'); - if (eventType) { - const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType); - setMenuCords(evt.currentTarget.getBoundingClientRect()); - selectEvent(mEvent); - } - }; - - const handleMenuClose = () => setMenuCords(undefined); - - const handleEdit = () => { - selectOption('edit'); - setMenuCords(undefined); - }; - const handleInspect = () => { - selectOption('inspect'); - setMenuCords(undefined); - }; - const handleClose = useCallback(() => { - selectEvent(undefined); - selectOption(undefined); - }, []); - - return ( - - Account Data - - setView(!view)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {view ? 'Collapse' : 'Expand'} - - } - /> - {view && ( - - - Types - - } - > - - Add New - - - {accountData.map((mEvent) => ( - - - {mEvent.getType()} - - - ))} - - - - )} - - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - Inspect - - - Edit - - - - - } - /> - - {selectedEvent && selectedOption === 'inspect' && ( - }> - - - - - - - - - )} - {selectedOption === 'edit' && ( - }> - - - - - - - - - )} - - ); -} +import { AccountData } from './AccountData'; type DeveloperToolsProps = { requestClose: () => void; @@ -224,6 +17,17 @@ type DeveloperToolsProps = { export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const mx = useMatrixClient(); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const [expand, setExpend] = useState(false); + const [accountDataType, setAccountDataType] = useState(); + + if (accountDataType !== undefined) { + return ( + setAccountDataType(undefined)} + /> + ); + } return ( @@ -292,7 +96,13 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
    )} - {developerTools && } + {developerTools && ( + + )} From dbadbe34b37920d2dad6d15fbbac1e6d77801046 Mon Sep 17 00:00:00 2001 From: sophie Date: Fri, 28 Feb 2025 07:31:54 +0000 Subject: [PATCH 021/138] add example caddyfile (#2227) --- contrib/caddy/caddyfile | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 contrib/caddy/caddyfile diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile new file mode 100644 index 00000000..e55efe92 --- /dev/null +++ b/contrib/caddy/caddyfile @@ -0,0 +1,6 @@ +cinny.domain.tld { + @nativeRouter not file {path} / + rewrite @nativeRouter {http.matchers.file.relative} + root * /path/to/caddy/dist + file_server +} From 36a8ce5561b90d57680920655892b03277d7a2d5 Mon Sep 17 00:00:00 2001 From: sophie Date: Fri, 28 Feb 2025 07:39:10 +0000 Subject: [PATCH 022/138] make readme easier to read (#2228) --- README.md | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5cee6fa2..427898f9 100644 --- a/README.md +++ b/README.md @@ -19,27 +19,22 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The ## Getting started -* Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken. +The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken. -* You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). +You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). -* To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest). -You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot. -To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice. -You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects. -To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). For example, if you want to deploy on `https://cinny.in/app`, then change `base: '/app'`. +## Self-hosting +To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny). -* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by: - ``` - docker pull ajbura/cinny - ``` - or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by: - ``` - docker pull ghcr.io/cinnyapp/cinny:latest - ``` +* The default homeservers and explore pages are defined in [`config.json`](config.json). -
    -PGP Public Key to verify tarball +* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile). + * If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver. + +* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). + * For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`. + +
    PGP Public Key to verify tarball ``` -----BEGIN PGP PUBLIC KEY BLOCK----- @@ -87,8 +82,8 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
    ## Local development -> We recommend using a version manager as versions change very quickly. You will likely need to switch -between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20). +> [!TIP] +> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20). Execute the following commands to start a development server: ```sh From 5fbd0c13db3a489e8a3e5bb179368d8faeaad39f Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:47:23 +1100 Subject: [PATCH 023/138] Hide existing messages from ignored users (#2236) * add ignored users hook * remove messages from timeline for ignored users --- src/app/features/room/RoomTimeline.tsx | 16 +++++++++++++--- .../settings/notifications/IgnoredUserList.tsx | 16 +++------------- src/app/hooks/useIgnoredUsers.ts | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 src/app/hooks/useIgnoredUsers.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 4bcdadbc..c9da6e2e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -117,6 +117,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -434,6 +435,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + + const ignoredUsersList = useIgnoredUsers(); + const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); + const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const powerLevels = usePowerLevelsContext(); const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } = @@ -1488,6 +1493,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (!mEvent || !mEventId) return null; + const eventSender = mEvent.getSender(); + if (eventSender && ignoredUsersSet.has(eventSender)) { + return null; + } + if (!newDivider && readUptoEventIdRef.current) { newDivider = prevEvent?.getId() === readUptoEventIdRef.current; } @@ -1498,9 +1508,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const collapsed = isPrevRendered && !dayDivider && - (!newDivider || mEvent.getSender() === mx.getUserId()) && + (!newDivider || eventSender === mx.getUserId()) && prevEvent !== undefined && - prevEvent.getSender() === mEvent.getSender() && + prevEvent.getSender() === eventSender && prevEvent.getType() === mEvent.getType() && minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; @@ -1519,7 +1529,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli isPrevRendered = !!eventJSX; const newDividerJSX = - newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? ( + newDivider && eventJSX && eventSender !== mx.getUserId() ? ( diff --git a/src/app/features/settings/notifications/IgnoredUserList.tsx b/src/app/features/settings/notifications/IgnoredUserList.tsx index 49264e57..0ff3015f 100644 --- a/src/app/features/settings/notifications/IgnoredUserList.tsx +++ b/src/app/features/settings/notifications/IgnoredUserList.tsx @@ -1,17 +1,12 @@ -import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react'; import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds'; -import { useAccountData } from '../../../hooks/useAccountData'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; 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'; import { isUserId } from '../../../utils/matrix'; - -type IgnoredUserListContent = { - ignored_users?: Record; -}; +import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; function IgnoreUserInput({ userList }: { userList: string[] }) { const mx = useMatrixClient(); @@ -129,12 +124,7 @@ function IgnoredUserChip({ userId, userList }: { userId: string; userList: strin } export function IgnoredUserList() { - const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList); - const ignoredUsers = useMemo(() => { - const ignoredUsersRecord = - ignoredUserListEvt?.getContent().ignored_users ?? {}; - return Object.keys(ignoredUsersRecord); - }, [ignoredUserListEvt]); + const ignoredUsers = useIgnoredUsers(); return ( diff --git a/src/app/hooks/useIgnoredUsers.ts b/src/app/hooks/useIgnoredUsers.ts new file mode 100644 index 00000000..baf2327a --- /dev/null +++ b/src/app/hooks/useIgnoredUsers.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useAccountData } from './useAccountData'; +import { AccountDataEvent } from '../../types/matrix/accountData'; + +export type IgnoredUserListContent = { + ignored_users?: Record; +}; + +export const useIgnoredUsers = (): string[] => { + const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList); + const ignoredUsers = useMemo(() => { + const ignoredUsersRecord = + ignoredUserListEvt?.getContent().ignored_users ?? {}; + return Object.keys(ignoredUsersRecord); + }, [ignoredUserListEvt]); + + return ignoredUsers; +}; From 1206ffced27a4945aed91a4d51cfe7bd7d8dd0ad Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 1 Mar 2025 18:48:11 +1100 Subject: [PATCH 024/138] Hide deleted events by default (#2237) --- src/app/features/room/RoomTimeline.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c9da6e2e..b0a76505 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1497,6 +1497,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (eventSender && ignoredUsersSet.has(eventSender)) { return null; } + if (mEvent.isRedacted() && !showHiddenEvents) { + return null; + } if (!newDivider && readUptoEventIdRef.current) { newDivider = prevEvent?.getId() === readUptoEventIdRef.current; From 0c5ff656393f377ecbc4ef6c030d6779888a0df0 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:32:13 +1100 Subject: [PATCH 025/138] fix backslash inserted in links upon edit (#2246) * fix backslash appear in url with inline markdown sequences * fix markdown chars not escaping on edit --- src/app/plugins/markdown/inline/rules.ts | 1 + src/app/plugins/markdown/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts index bc76f60a..77bcbd57 100644 --- a/src/app/plugins/markdown/inline/rules.ts +++ b/src/app/plugins/markdown/inline/rules.ts @@ -112,6 +112,7 @@ export const LinkRule: InlineMDRule = { }; export const INLINE_SEQUENCE_SET = '[*_~`|]'; +export const CAP_INLINE_SEQ = `${URL_NEG_LB}${INLINE_SEQUENCE_SET}`; const ESC_SEQ_1 = `\\\\(${INLINE_SEQUENCE_SET})`; const ESC_REG_1 = new RegExp(`${URL_NEG_LB}${ESC_SEQ_1}`); export const EscapeRule: InlineMDRule = { diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts index 5ebd958c..0038df13 100644 --- a/src/app/plugins/markdown/utils.ts +++ b/src/app/plugins/markdown/utils.ts @@ -1,6 +1,6 @@ import { findAndReplace } from '../../utils/findAndReplace'; import { ESC_BLOCK_SEQ, UN_ESC_BLOCK_SEQ } from './block/rules'; -import { EscapeRule, INLINE_SEQUENCE_SET } from './inline/rules'; +import { EscapeRule, CAP_INLINE_SEQ } from './inline/rules'; import { runInlineRule } from './inline/runner'; import { replaceMatch } from './internal'; @@ -27,7 +27,7 @@ export const unescapeMarkdownInlineSequences = (text: string): string => * @returns The plain-text with markdown escape sequences added (e.g., `"some \*italic\*"`) */ export const escapeMarkdownInlineSequences = (text: string): string => { - const regex = new RegExp(`(${INLINE_SEQUENCE_SET})`, 'g'); + const regex = new RegExp(`(${CAP_INLINE_SEQ})`, 'g'); const parts = findAndReplace( text, regex, From 19cf700d613800cf372c77c34dc5a7f710316f43 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:47:28 +1100 Subject: [PATCH 026/138] Release v4.5.0 (#2247) --- 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 7b28bcc2..9eba6bf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.4.0", + "version": "4.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.4.0", + "version": "4.5.0", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 2b11a930..3814943b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.4.0", + "version": "4.5.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 bb434474..6f12658e 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.4.0 + v4.5.0 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 96300a55..74e3bffa 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.4.0 + v4.5.0 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 03fad8e0..b1b9c0cf 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.4.0', + version: '4.5.0', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From 5d00383d710c267bac30a89fc122825af36cc707 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:23:28 +1100 Subject: [PATCH 027/138] fix crash on emoji selection from emojiboard (#2249) * fix crash on emoji select * fix crash in message editor on emoji select --- src/app/features/room/RoomInput.tsx | 9 +++++++-- src/app/features/room/message/MessageEditor.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 97f80595..eb214f62 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -600,8 +600,13 @@ export const RoomInput = forwardRef( onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} requestClose={() => { - setEmojiBoardTab(undefined); - if (!mobileOrTablet()) ReactEditor.focus(editor); + setEmojiBoardTab((t) => { + if (t) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return t; + }); }} /> } diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index dc59dcdf..ac97e2aa 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -305,8 +305,13 @@ export const MessageEditor = as<'div', MessageEditorProps>( onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} requestClose={() => { - setAnchor(undefined); - if (!mobileOrTablet()) ReactEditor.focus(editor); + setAnchor((v) => { + if (v) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return v; + }); }} /> } From 78fa6e3925ce04e13af96d1b0c8df37530db944c Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:33:18 +1100 Subject: [PATCH 028/138] Release v4.5.1 (#2251) --- 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 9eba6bf6..b173bc70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.5.0", + "version": "4.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.5.0", + "version": "4.5.1", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 3814943b..aea713ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.5.0", + "version": "4.5.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 6f12658e..8cd950a4 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.5.0 + v4.5.1 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 74e3bffa..68888a78 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.5.0 + v4.5.1 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index b1b9c0cf..d214ebb7 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.5.0', + version: '4.5.1', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From 6017c0a2fc06e4a157d2bf84e30b7d3ca698fbc7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:56:11 +1100 Subject: [PATCH 029/138] Bump docker/setup-qemu-action from 3.4.0 to 3.6.0 (#2244) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.4.0 to 3.6.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v3.4.0...v3.6.0) --- updated-dependencies: - dependency-name: docker/setup-qemu-action 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 ee0561da..d34fa6d2 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -68,7 +68,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.4.0 + uses: docker/setup-qemu-action@v3.6.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.9.0 - name: Login to Docker Hub From e39cc32df98d6385c88e1ccb6863b306b4469654 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:56:33 +1100 Subject: [PATCH 030/138] Bump docker/build-push-action from 6.13.0 to 6.15.0 (#2243) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.13.0 to 6.15.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.13.0...v6.15.0) --- updated-dependencies: - dependency-name: docker/build-push-action 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 7e8e99f3..4e88c78d 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.13.0 + uses: docker/build-push-action@v6.15.0 with: context: . push: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index d34fa6d2..da5c2ae6 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.13.0 + uses: docker/build-push-action@v6.15.0 with: context: . platforms: linux/amd64,linux/arm64 From a02d7162d9195a248d5303411b2ee6162d91e9d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:57:06 +1100 Subject: [PATCH 031/138] Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#2241) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 60822231..cd07e0c2 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -25,7 +25,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Upload artifact - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: preview path: dist @@ -33,7 +33,7 @@ jobs: - name: Save pr number run: echo ${PR_NUMBER} > ./pr.txt - name: Upload pr number - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pr path: ./pr.txt From 82688c2e13ba066e4b89a37327b009acdb19cc4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:58:18 +1100 Subject: [PATCH 032/138] Bump docker/metadata-action from 5.6.1 to 5.7.0 (#2240) Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.6.1 to 5.7.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/v5.6.1...v5.7.0) --- updated-dependencies: - dependency-name: docker/metadata-action 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 da5c2ae6..f85ade85 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -84,7 +84,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5.6.1 + uses: docker/metadata-action@v5.7.0 with: images: | ${{ secrets.DOCKER_USERNAME }}/cinny From 9bb30fbd924946c1ca487e205dc3bb783b7ba11d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:58:37 +1100 Subject: [PATCH 033/138] Bump docker/setup-buildx-action from 3.9.0 to 3.10.0 (#2242) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.9.0 to 3.10.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3.9.0...v3.10.0) --- updated-dependencies: - dependency-name: docker/setup-buildx-action 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 f85ade85..9a9dd7c5 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -70,7 +70,7 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.9.0 + uses: docker/setup-buildx-action@v3.10.0 - name: Login to Docker Hub uses: docker/login-action@v3.3.0 with: From d8009978e5f931b76ff23f3176fc5df09462132b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:29:23 +1100 Subject: [PATCH 034/138] add option to download audio/video file (#2253) * add option to download audio file * add button to download video --- src/app/components/message/FileHeader.tsx | 79 ++++++++++++++++--- .../components/message/MsgTypeRenderers.tsx | 32 +++++++- 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 947be90e..0248862d 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -1,22 +1,81 @@ -import { Badge, Box, Text, as, toRem } from 'folds'; -import React from 'react'; +import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds'; +import React, { ReactNode, useCallback } from 'react'; +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import FileSaver from 'file-saver'; import { mimeTypeToExt } from '../../utils/mimeTypes'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { + decryptFile, + downloadEncryptedMedia, + downloadMedia, + mxcUrlToHttp, +} from '../../utils/matrix'; const badgeStyles = { maxWidth: toRem(100) }; +type FileDownloadButtonProps = { + filename: string; + url: string; + mimeType: string; + encInfo?: EncryptedAttachmentInfo; +}; +export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const [downloadState, download] = useAsyncCallback( + useCallback(async () => { + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const fileContent = encInfo + ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) + : await downloadMedia(mediaUrl); + + const fileURL = URL.createObjectURL(fileContent); + FileSaver.saveAs(fileURL, filename); + return fileURL; + }, [mx, url, useAuthentication, mimeType, encInfo, filename]) + ); + + const downloading = downloadState.status === AsyncStatus.Loading; + const hasError = downloadState.status === AsyncStatus.Error; + return ( + + {downloading ? ( + + ) : ( + + )} + + ); +} + export type FileHeaderProps = { body: string; mimeType: string; + after?: ReactNode; }; -export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => ( +export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => ( - - - {mimeTypeToExt(mimeType)} + + + + {mimeTypeToExt(mimeType)} + + + + + + {body} - - - {body} - + + {after} )); diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 287a5ca4..cea5220b 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -28,7 +28,7 @@ import { import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; -import { FileHeader } from './FileHeader'; +import { FileHeader, FileDownloadButton } from './FileHeader'; export function MBadEncrypted() { return ( @@ -243,8 +243,24 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400); + const filename = content.filename ?? content.body ?? 'Video'; + return ( + + + } + /> + ; } + const filename = content.filename ?? content.body ?? 'Audio'; return ( - + + } + /> From 00f3df87193950f521079791daf9646dd7da946e Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 12 Mar 2025 22:50:23 +1100 Subject: [PATCH 035/138] Stop showing notification from invite/left rooms (#2267) --- src/app/pages/client/inbox/Notifications.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 722ce5d3..c28b6753 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -26,6 +26,7 @@ import { import { useVirtualizer } from '@tanstack/react-virtual'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; +import { useAtomValue } from 'jotai'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; @@ -82,6 +83,7 @@ import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; type RoomNotificationsGroup = { roomId: string; @@ -94,9 +96,14 @@ type NotificationTimeline = { type LoadTimeline = (from?: string) => Promise; type SilentReloadTimeline = () => Promise; -const groupNotifications = (notifications: INotification[]): RoomNotificationsGroup[] => { +const groupNotifications = ( + notifications: INotification[], + allowRooms: Set +): RoomNotificationsGroup[] => { const groups: RoomNotificationsGroup[] = []; notifications.forEach((notification) => { + if (!allowRooms.has(notification.room_id)) return; + const groupIndex = groups.length - 1; const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex]; if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) { @@ -116,6 +123,9 @@ const useNotificationTimeline = ( onlyHighlight?: boolean ): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => { const mx = useMatrixClient(); + const allRooms = useAtomValue(allRoomsAtom); + const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); + const [notificationTimeline, setNotificationTimeline] = useState({ groups: [], }); @@ -142,7 +152,7 @@ const useNotificationTimeline = ( paginationLimit, onlyHighlight ? 'highlight' : undefined ); - const groups = groupNotifications(data.notifications); + const groups = groupNotifications(data.notifications, allJoinedRooms); setNotificationTimeline((currentTimeline) => { if (currentTimeline.nextToken === from) { @@ -154,7 +164,7 @@ const useNotificationTimeline = ( return currentTimeline; }); }, - [paginationLimit, onlyHighlight, fetchNotifications] + [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms] ); /** @@ -167,12 +177,12 @@ const useNotificationTimeline = ( paginationLimit, onlyHighlight ? 'highlight' : undefined ); - const groups = groupNotifications(data.notifications); + const groups = groupNotifications(data.notifications, allJoinedRooms); setNotificationTimeline({ nextToken: data.next_token, groups, }); - }, [paginationLimit, onlyHighlight, fetchNotifications]); + }, [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]); return [notificationTimeline, loadTimeline, silentReloadTimeline]; }; From 286983c833f9d2a3e6b26f2d49ab4c944616aa37 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 19 Mar 2025 23:14:54 +1100 Subject: [PATCH 036/138] New room settings, add customizable power levels and dev tools (#2222) * WIP - add room settings dialog * join rule setting - WIP * show emojis & stickers in room settings - WIP * restyle join rule switcher * Merge branch 'dev' into new-room-settings * add join rule hook * open room settings from global state * open new room settings from all places * rearrange settings menu item * add option for creating new image pack * room devtools - WIP * render room state events as list * add option to open state event * add option to edit state event * refactor text area code editor into hook * add option to send message and state event * add cutout card component * add hook for room account data * display room account data - WIP * refactor global account data editor component * add account data editor in room * fix font style in devtool * show state events in compact form * add option to delete room image pack * add server badge component * add member tile component * render members in room settings * add search in room settings member * add option to reset member search * add filter in room members * fix member virtual item key * remove color from serve badge in room members * show room in settings * fix loading indicator position * power level tags in room setting - WIP * generate fallback tag in backward compatible way * add color picker * add powers editor - WIP * add props to stop adding emoji to recent usage * add beta feature notice badge * add types for power level tag icon * refactor image pack rooms code to hook * option for adding new power levels tags * remove console log * refactor power icon * add option to edit power level tags * remove power level from powers pill * fix power level labels * add option to delete power levels * fix long power level name shrinks power integer * room permissions - WIP * add power level selector component * add room permissions * move user default permission setting to other group * add power permission peek menu * fix weigh of power switch text * hide above for max power in permission switcher * improve beta badge description * render room profile in room settings * add option to edit room profile * make room topic input text area * add option to enable room encryption in room settings * add option to change message history visibility * add option to change join rule * add option for addresses in room settings * close encryption dialog after enabling --- package-lock.json | 11 + package.json | 1 + .../AccountDataEditor.tsx | 106 ++-- src/app/components/BetaNoticeBadge.tsx | 25 + src/app/components/HexColorPickerPopOut.tsx | 59 ++ src/app/components/JoinRulesSwitcher.tsx | 138 +++++ src/app/components/MemberSortMenu.tsx | 45 ++ src/app/components/MembershipFilterMenu.tsx | 49 ++ .../components/cutout-card/CutoutCard.css.ts | 8 + src/app/components/cutout-card/CutoutCard.tsx | 15 + src/app/components/cutout-card/index.ts | 1 + src/app/components/emoji-board/EmojiBoard.tsx | 6 +- src/app/components/member-tile/MemberTile.tsx | 53 ++ src/app/components/member-tile/index.ts | 1 + src/app/components/member-tile/style.css.ts | 32 + src/app/components/power/PowerColorBadge.tsx | 21 + src/app/components/power/PowerIcon.tsx | 15 + src/app/components/power/PowerSelector.tsx | 94 +++ src/app/components/power/index.ts | 3 + src/app/components/power/style.css.ts | 73 +++ .../components/server-badge/ServerBadge.tsx | 16 + src/app/components/server-badge/index.ts | 1 + src/app/features/lobby/HierarchyItemMenu.tsx | 13 +- src/app/features/room-nav/RoomNavItem.tsx | 8 +- .../features/room-settings/RoomSettings.tsx | 172 ++++++ .../room-settings/RoomSettingsRenderer.tsx | 39 ++ .../developer-tools/DevelopTools.tsx | 396 ++++++++++++ .../developer-tools/SendRoomEvent.tsx | 208 +++++++ .../developer-tools/StateEventEditor.tsx | 298 +++++++++ .../room-settings/developer-tools/index.ts | 1 + .../emojis-stickers/EmojisStickers.tsx | 49 ++ .../emojis-stickers/RoomPacks.tsx | 349 +++++++++++ .../room-settings/emojis-stickers/index.ts | 1 + .../room-settings/general/General.tsx | 57 ++ .../room-settings/general/RoomAddress.tsx | 438 +++++++++++++ .../room-settings/general/RoomEncryption.tsx | 150 +++++ .../general/RoomHistoryVisibility.tsx | 169 +++++ .../room-settings/general/RoomJoinRules.tsx | 124 ++++ .../room-settings/general/RoomProfile.tsx | 351 +++++++++++ .../features/room-settings/general/index.ts | 1 + src/app/features/room-settings/index.ts | 2 + .../room-settings/members/Members.tsx | 353 +++++++++++ .../features/room-settings/members/index.ts | 1 + .../permissions/PermissionGroups.tsx | 287 +++++++++ .../room-settings/permissions/Permissions.tsx | 66 ++ .../room-settings/permissions/Powers.tsx | 170 +++++ .../permissions/PowersEditor.tsx | 579 ++++++++++++++++++ .../room-settings/permissions/index.ts | 1 + .../permissions/usePermissionItems.ts | 218 +++++++ src/app/features/room-settings/styles.css.ts | 6 + src/app/features/room/MembersDrawer.tsx | 216 +------ src/app/features/room/RoomInput.tsx | 18 +- src/app/features/room/RoomTimeline.tsx | 13 +- src/app/features/room/RoomViewHeader.tsx | 11 +- .../settings/developer-tools/AccountData.tsx | 74 ++- .../settings/developer-tools/DevelopTools.tsx | 16 +- .../settings/developer-tools/styles.css.ts | 24 - src/app/hooks/useGetRoom.ts | 29 + src/app/hooks/useImagePackRooms.ts | 22 + src/app/hooks/useMemberFilter.ts | 57 ++ src/app/hooks/useMemberSort.ts | 48 ++ src/app/hooks/usePowerLevelTags.ts | 172 +++++- src/app/hooks/usePowerLevels.ts | 279 +++++++-- src/app/hooks/useRoomAccountData.ts | 29 + src/app/hooks/useRoomAliases.ts | 170 +++++ src/app/hooks/useRoomMeta.ts | 7 + src/app/hooks/useRoomState.ts | 50 ++ src/app/hooks/useTextAreaCodeEditor.ts | 44 ++ src/app/pages/Router.tsx | 2 + src/app/state/hooks/roomSettings.ts | 34 + src/app/state/roomSettings.ts | 17 + src/app/utils/matrix.ts | 3 +- src/types/matrix/room.ts | 1 + 73 files changed, 6196 insertions(+), 420 deletions(-) rename src/app/{features/settings/developer-tools => components}/AccountDataEditor.tsx (75%) create mode 100644 src/app/components/BetaNoticeBadge.tsx create mode 100644 src/app/components/HexColorPickerPopOut.tsx create mode 100644 src/app/components/JoinRulesSwitcher.tsx create mode 100644 src/app/components/MemberSortMenu.tsx create mode 100644 src/app/components/MembershipFilterMenu.tsx create mode 100644 src/app/components/cutout-card/CutoutCard.css.ts create mode 100644 src/app/components/cutout-card/CutoutCard.tsx create mode 100644 src/app/components/cutout-card/index.ts create mode 100644 src/app/components/member-tile/MemberTile.tsx create mode 100644 src/app/components/member-tile/index.ts create mode 100644 src/app/components/member-tile/style.css.ts create mode 100644 src/app/components/power/PowerColorBadge.tsx create mode 100644 src/app/components/power/PowerIcon.tsx create mode 100644 src/app/components/power/PowerSelector.tsx create mode 100644 src/app/components/power/index.ts create mode 100644 src/app/components/power/style.css.ts create mode 100644 src/app/components/server-badge/ServerBadge.tsx create mode 100644 src/app/components/server-badge/index.ts create mode 100644 src/app/features/room-settings/RoomSettings.tsx create mode 100644 src/app/features/room-settings/RoomSettingsRenderer.tsx create mode 100644 src/app/features/room-settings/developer-tools/DevelopTools.tsx create mode 100644 src/app/features/room-settings/developer-tools/SendRoomEvent.tsx create mode 100644 src/app/features/room-settings/developer-tools/StateEventEditor.tsx create mode 100644 src/app/features/room-settings/developer-tools/index.ts create mode 100644 src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx create mode 100644 src/app/features/room-settings/emojis-stickers/RoomPacks.tsx create mode 100644 src/app/features/room-settings/emojis-stickers/index.ts create mode 100644 src/app/features/room-settings/general/General.tsx create mode 100644 src/app/features/room-settings/general/RoomAddress.tsx create mode 100644 src/app/features/room-settings/general/RoomEncryption.tsx create mode 100644 src/app/features/room-settings/general/RoomHistoryVisibility.tsx create mode 100644 src/app/features/room-settings/general/RoomJoinRules.tsx create mode 100644 src/app/features/room-settings/general/RoomProfile.tsx create mode 100644 src/app/features/room-settings/general/index.ts create mode 100644 src/app/features/room-settings/index.ts create mode 100644 src/app/features/room-settings/members/Members.tsx create mode 100644 src/app/features/room-settings/members/index.ts create mode 100644 src/app/features/room-settings/permissions/PermissionGroups.tsx create mode 100644 src/app/features/room-settings/permissions/Permissions.tsx create mode 100644 src/app/features/room-settings/permissions/Powers.tsx create mode 100644 src/app/features/room-settings/permissions/PowersEditor.tsx create mode 100644 src/app/features/room-settings/permissions/index.ts create mode 100644 src/app/features/room-settings/permissions/usePermissionItems.ts create mode 100644 src/app/features/room-settings/styles.css.ts delete mode 100644 src/app/features/settings/developer-tools/styles.css.ts create mode 100644 src/app/hooks/useGetRoom.ts create mode 100644 src/app/hooks/useImagePackRooms.ts create mode 100644 src/app/hooks/useMemberFilter.ts create mode 100644 src/app/hooks/useMemberSort.ts create mode 100644 src/app/hooks/useRoomAccountData.ts create mode 100644 src/app/hooks/useRoomAliases.ts create mode 100644 src/app/hooks/useRoomState.ts create mode 100644 src/app/hooks/useTextAreaCodeEditor.ts create mode 100644 src/app/state/hooks/roomSettings.ts create mode 100644 src/app/state/roomSettings.ts diff --git a/package-lock.json b/package-lock.json index b173bc70..3971adb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "react-aria": "3.29.1", "react-autosize-textarea": "7.1.0", "react-blurhash": "0.2.0", + "react-colorful": "5.6.1", "react-dom": "18.2.0", "react-error-boundary": "4.0.13", "react-google-recaptcha": "2.1.0", @@ -9654,6 +9655,16 @@ "react": ">=15" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", diff --git a/package.json b/package.json index aea713ed..074f4dd9 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "react-aria": "3.29.1", "react-autosize-textarea": "7.1.0", "react-blurhash": "0.2.0", + "react-colorful": "5.6.1", "react-dom": "18.2.0", "react-error-boundary": "4.0.13", "react-google-recaptcha": "2.1.0", diff --git a/src/app/features/settings/developer-tools/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx similarity index 75% rename from src/app/features/settings/developer-tools/AccountDataEditor.tsx rename to src/app/components/AccountDataEditor.tsx index b5ac0f8a..2dbaf1f1 100644 --- a/src/app/features/settings/developer-tools/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -1,12 +1,4 @@ -import React, { - FormEventHandler, - KeyboardEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Text, @@ -22,22 +14,20 @@ import { Scroll, config, } from 'folds'; -import { isKeyHotkey } from 'is-hotkey'; import { MatrixError } from 'matrix-js-sdk'; -import * as css from './styles.css'; -import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent'; -import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area'; -import { GetTarget } from '../../../plugins/text-area/type'; -import { syntaxErrorPosition } from '../../../utils/dom'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { Page, PageHeader } from '../../../components/page'; -import { useAlive } from '../../../hooks/useAlive'; -import { SequenceCard } from '../../../components/sequence-card'; -import { TextViewerContent } from '../../../components/text-viewer'; +import { Cursor } from '../plugins/text-area'; +import { syntaxErrorPosition } from '../utils/dom'; +import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; +import { Page, PageHeader } from './page'; +import { useAlive } from '../hooks/useAlive'; +import { SequenceCard } from './sequence-card'; +import { TextViewerContent } from './text-viewer'; +import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor'; const EDITOR_INTENT_SPACE_COUNT = 2; +export type AccountDataSubmitCallback = (type: string, content: object) => Promise; + type AccountDataInfo = { type: string; content: object; @@ -46,45 +36,28 @@ type AccountDataInfo = { type AccountDataEditProps = { type: string; defaultContent: string; + submitChange: AccountDataSubmitCallback; onCancel: () => void; onSave: (info: AccountDataInfo) => void; }; -function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) { - const mx = useMatrixClient(); +function AccountDataEdit({ + type, + defaultContent, + submitChange, + onCancel, + onSave, +}: AccountDataEditProps) { const alive = useAlive(); const textAreaRef = useRef(null); const [jsonError, setJSONError] = useState(); - const getTarget: GetTarget = useCallback(() => { - const target = textAreaRef.current; - if (!target) throw new Error('TextArea element not found!'); - return target; - }, []); - - const { textArea, operations, intent } = useMemo(() => { - const ta = new TextArea(getTarget); - const op = new TextAreaOperations(getTarget); - return { - textArea: ta, - operations: op, - intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op), - }; - }, [getTarget]); - - const intentHandler = useTextAreaIntentHandler(textArea, operations, intent); - - const handleKeyDown: KeyboardEventHandler = (evt) => { - intentHandler(evt); - if (isKeyHotkey('escape', evt)) { - const cursor = Cursor.fromTextAreaElement(getTarget()); - operations.deselect(cursor); - } - }; - - const [submitState, submit] = useAsyncCallback( - useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx]) + const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor( + textAreaRef, + EDITOR_INTENT_SPACE_COUNT ); + + const [submitState, submit] = useAsyncCallback(submitChange); const submitting = submitState.status === AsyncStatus.Loading; const handleSubmit: FormEventHandler = (evt) => { @@ -140,7 +113,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData as="form" onSubmit={handleSubmit} grow="Yes" - className={css.EditorContent} + style={{ + padding: config.space.S400, + }} direction="Column" gap="400" aria-disabled={submitting} @@ -174,6 +149,7 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData fill="Soft" size="400" radii="300" + type="button" onClick={onCancel} disabled={submitting} > @@ -194,7 +170,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData + Account Data @@ -259,15 +243,20 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) export type AccountDataEditorProps = { type?: string; + content?: object; + submitChange: AccountDataSubmitCallback; requestClose: () => void; }; -export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) { - const mx = useMatrixClient(); - +export function AccountDataEditor({ + type, + content, + submitChange, + requestClose, +}: AccountDataEditorProps) { const [data, setData] = useState({ type: type ?? '', - content: mx.getAccountData(type ?? '')?.getContent() ?? {}, + content: content ?? {}, }); const [edit, setEdit] = useState(!type); @@ -316,6 +305,7 @@ export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps diff --git a/src/app/components/BetaNoticeBadge.tsx b/src/app/components/BetaNoticeBadge.tsx new file mode 100644 index 00000000..d1859876 --- /dev/null +++ b/src/app/components/BetaNoticeBadge.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds'; + +export function BetaNoticeBadge() { + return ( + + + Notice + This feature is under testing and may change over time. + + + } + > + {(triggerRef) => ( + + Beta + + )} + + ); +} diff --git a/src/app/components/HexColorPickerPopOut.tsx b/src/app/components/HexColorPickerPopOut.tsx new file mode 100644 index 00000000..d8fb4bc3 --- /dev/null +++ b/src/app/components/HexColorPickerPopOut.tsx @@ -0,0 +1,59 @@ +import FocusTrap from 'focus-trap-react'; +import { Box, Button, config, Menu, PopOut, RectCords, Text } from 'folds'; +import React, { MouseEventHandler, ReactNode, useState } from 'react'; +import { stopPropagation } from '../utils/keyboard'; + +type HexColorPickerPopOutProps = { + children: (onOpen: MouseEventHandler, opened: boolean) => ReactNode; + picker: ReactNode; + onRemove?: () => void; +}; +export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) { + const [cords, setCords] = useState(); + + const handleOpen: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + {picker} + {onRemove && ( + + )} + + + + } + > + {children(handleOpen, !!cords)} + + ); +} diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx new file mode 100644 index 00000000..ddd1903f --- /dev/null +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -0,0 +1,138 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { + config, + Box, + MenuItem, + Text, + Icon, + Icons, + IconSrc, + RectCords, + PopOut, + Menu, + Button, + Spinner, +} from 'folds'; +import { JoinRule } from 'matrix-js-sdk'; +import FocusTrap from 'focus-trap-react'; +import { stopPropagation } from '../utils/keyboard'; + +type JoinRuleIcons = Record; +export const useRoomJoinRuleIcon = (): JoinRuleIcons => + useMemo( + () => ({ + [JoinRule.Invite]: Icons.HashLock, + [JoinRule.Knock]: Icons.HashLock, + [JoinRule.Restricted]: Icons.Hash, + [JoinRule.Public]: Icons.HashGlobe, + [JoinRule.Private]: Icons.HashLock, + }), + [] + ); + +type JoinRuleLabels = Record; +export const useRoomJoinRuleLabel = (): JoinRuleLabels => + useMemo( + () => ({ + [JoinRule.Invite]: 'Invite Only', + [JoinRule.Knock]: 'Knock & Invite', + [JoinRule.Restricted]: 'Space Members', + [JoinRule.Public]: 'Public', + [JoinRule.Private]: 'Invite Only', + }), + [] + ); + +type JoinRulesSwitcherProps = { + icons: JoinRuleIcons; + labels: JoinRuleLabels; + rules: T; + value: T[number]; + onChange: (value: T[number]) => void; + disabled?: boolean; + changing?: boolean; +}; +export function JoinRulesSwitcher({ + icons, + labels, + rules, + value, + onChange, + disabled, + changing, +}: JoinRulesSwitcherProps) { + const [cords, setCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleChange = useCallback( + (selectedRule: JoinRule) => { + setCords(undefined); + onChange(selectedRule); + }, + [onChange] + ); + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {rules.map((rule) => ( + handleChange(rule)} + before={} + disabled={disabled} + > + + {labels[rule]} + + + ))} + + + + } + > + + + ); +} diff --git a/src/app/components/MemberSortMenu.tsx b/src/app/components/MemberSortMenu.tsx new file mode 100644 index 00000000..d77c80c6 --- /dev/null +++ b/src/app/components/MemberSortMenu.tsx @@ -0,0 +1,45 @@ +import FocusTrap from 'focus-trap-react'; +import React from 'react'; +import { config, Menu, MenuItem, Text } from 'folds'; +import { stopPropagation } from '../utils/keyboard'; +import { useMemberSortMenu } from '../hooks/useMemberSort'; + +type MemberSortMenuProps = { + requestClose: () => void; + selected: number; + onSelect: (index: number) => void; +}; +export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) { + const memberSortMenu = useMemberSortMenu(); + + return ( + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {memberSortMenu.map((menuItem, index) => ( + { + onSelect(index); + requestClose(); + }} + > + {menuItem.name} + + ))} + + + ); +} diff --git a/src/app/components/MembershipFilterMenu.tsx b/src/app/components/MembershipFilterMenu.tsx new file mode 100644 index 00000000..bf17677d --- /dev/null +++ b/src/app/components/MembershipFilterMenu.tsx @@ -0,0 +1,49 @@ +import FocusTrap from 'focus-trap-react'; +import React from 'react'; +import { config, Menu, MenuItem, Text } from 'folds'; +import { stopPropagation } from '../utils/keyboard'; +import { useMembershipFilterMenu } from '../hooks/useMemberFilter'; + +type MembershipFilterMenuProps = { + requestClose: () => void; + selected: number; + onSelect: (index: number) => void; +}; +export function MembershipFilterMenu({ + selected, + onSelect, + requestClose, +}: MembershipFilterMenuProps) { + const membershipFilterMenu = useMembershipFilterMenu(); + + return ( + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {membershipFilterMenu.map((menuItem, index) => ( + { + onSelect(index); + requestClose(); + }} + > + {menuItem.name} + + ))} + + + ); +} diff --git a/src/app/components/cutout-card/CutoutCard.css.ts b/src/app/components/cutout-card/CutoutCard.css.ts new file mode 100644 index 00000000..8bdf0201 --- /dev/null +++ b/src/app/components/cutout-card/CutoutCard.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const CutoutCard = style({ + borderRadius: config.radii.R300, + borderWidth: config.borderWidth.B300, + overflow: 'hidden', +}); diff --git a/src/app/components/cutout-card/CutoutCard.tsx b/src/app/components/cutout-card/CutoutCard.tsx new file mode 100644 index 00000000..bf5ddf8b --- /dev/null +++ b/src/app/components/cutout-card/CutoutCard.tsx @@ -0,0 +1,15 @@ +import { as, ContainerColor as TContainerColor } from 'folds'; +import React from 'react'; +import classNames from 'classnames'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import * as css from './CutoutCard.css'; + +export const CutoutCard = as<'div', { variant?: TContainerColor }>( + ({ as: AsCutoutCard = 'div', className, variant = 'Surface', ...props }, ref) => ( + + ) +); diff --git a/src/app/components/cutout-card/index.ts b/src/app/components/cutout-card/index.ts new file mode 100644 index 00000000..4ce2f8b5 --- /dev/null +++ b/src/app/components/cutout-card/index.ts @@ -0,0 +1 @@ +export * from './CutoutCard'; diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 28735081..72a60f2b 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -654,6 +654,7 @@ export function EmojiBoard({ onCustomEmojiSelect, onStickerSelect, allowTextCustomEmoji, + addToRecentEmoji = true, }: { tab?: EmojiBoardTab; onTabChange?: (tab: EmojiBoardTab) => void; @@ -664,6 +665,7 @@ export function EmojiBoard({ onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; allowTextCustomEmoji?: boolean; + addToRecentEmoji?: boolean; }) { const emojiTab = tab === EmojiBoardTab.Emoji; const stickerTab = tab === EmojiBoardTab.Sticker; @@ -735,7 +737,9 @@ export function EmojiBoard({ if (emojiInfo.type === EmojiType.Emoji) { onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); if (!evt.altKey && !evt.shiftKey) { - addRecentEmoji(mx, emojiInfo.data); + if (addToRecentEmoji) { + addRecentEmoji(mx, emojiInfo.data); + } requestClose(); } } diff --git a/src/app/components/member-tile/MemberTile.tsx b/src/app/components/member-tile/MemberTile.tsx new file mode 100644 index 00000000..d36d46c2 --- /dev/null +++ b/src/app/components/member-tile/MemberTile.tsx @@ -0,0 +1,53 @@ +import React, { ReactNode } from 'react'; +import { as, Avatar, Box, Icon, Icons, Text } from 'folds'; +import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; +import { getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { UserAvatar } from '../user-avatar'; +import * as css from './style.css'; + +const getName = (room: Room, member: RoomMember) => + getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; + +type MemberTileProps = { + mx: MatrixClient; + room: Room; + member: RoomMember; + useAuthentication: boolean; + after?: ReactNode; +}; +export const MemberTile = as<'button', MemberTileProps>( + ({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => { + const name = getName(room, member); + const username = getMxIdLocalPart(member.userId); + + const avatarMxcUrl = member.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) + : undefined; + + return ( + + + } + /> + + + + {name} + + + + {username} + + + + {after} + + ); + } +); diff --git a/src/app/components/member-tile/index.ts b/src/app/components/member-tile/index.ts new file mode 100644 index 00000000..463f621e --- /dev/null +++ b/src/app/components/member-tile/index.ts @@ -0,0 +1 @@ +export * from './MemberTile'; diff --git a/src/app/components/member-tile/style.css.ts b/src/app/components/member-tile/style.css.ts new file mode 100644 index 00000000..7cfe8947 --- /dev/null +++ b/src/app/components/member-tile/style.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, DefaultReset, Disabled, FocusOutline } from 'folds'; + +export const MemberTile = style([ + DefaultReset, + { + width: '100%', + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + + padding: config.space.S100, + borderRadius: config.radii.R500, + + selectors: { + 'button&': { + cursor: 'pointer', + }, + '&[aria-pressed=true]': { + backgroundColor: color.Surface.ContainerActive, + }, + 'button&:hover, &:focus-visible': { + backgroundColor: color.Surface.ContainerHover, + }, + 'button&:active': { + backgroundColor: color.Surface.ContainerActive, + }, + }, + }, + FocusOutline, + Disabled, +]); diff --git a/src/app/components/power/PowerColorBadge.tsx b/src/app/components/power/PowerColorBadge.tsx new file mode 100644 index 00000000..a1df0126 --- /dev/null +++ b/src/app/components/power/PowerColorBadge.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { as } from 'folds'; +import classNames from 'classnames'; +import * as css from './style.css'; + +type PowerColorBadgeProps = { + color?: string; +}; +export const PowerColorBadge = as<'span', PowerColorBadgeProps>( + ({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => ( + + ) +); diff --git a/src/app/components/power/PowerIcon.tsx b/src/app/components/power/PowerIcon.tsx new file mode 100644 index 00000000..6f6df242 --- /dev/null +++ b/src/app/components/power/PowerIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import * as css from './style.css'; +import { JUMBO_EMOJI_REG } from '../../utils/regex'; + +type PowerIconProps = css.PowerIconVariants & { + iconSrc: string; + name?: string; +}; +export function PowerIcon({ size, iconSrc, name }: PowerIconProps) { + return JUMBO_EMOJI_REG.test(iconSrc) ? ( + {iconSrc} + ) : ( + {name} + ); +} diff --git a/src/app/components/power/PowerSelector.tsx b/src/app/components/power/PowerSelector.tsx new file mode 100644 index 00000000..2b3b48c3 --- /dev/null +++ b/src/app/components/power/PowerSelector.tsx @@ -0,0 +1,94 @@ +import React, { forwardRef, MouseEventHandler, ReactNode, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { Box, config, Menu, MenuItem, PopOut, Scroll, Text, toRem, RectCords } from 'folds'; +import { getPowers, PowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { PowerColorBadge } from './PowerColorBadge'; +import { stopPropagation } from '../../utils/keyboard'; + +type PowerSelectorProps = { + powerLevelTags: PowerLevelTags; + value: number; + onChange: (value: number) => void; +}; +export const PowerSelector = forwardRef( + ({ powerLevelTags, value, onChange }, ref) => ( + + + +
    + {getPowers(powerLevelTags).map((power) => { + const selected = value === power; + const tag = powerLevelTags[power]; + + return ( + onChange(power)} + before={} + after={{power}} + > + + {tag.name} + + + ); + })} +
    +
    +
    +
    + ) +); + +type PowerSwitcherProps = PowerSelectorProps & { + children: (handleOpen: MouseEventHandler, opened: boolean) => ReactNode; +}; +export function PowerSwitcher({ powerLevelTags, value, onChange, children }: PowerSwitcherProps) { + const [menuCords, setMenuCords] = useState(); + + const handleOpen: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + { + onChange(v); + setMenuCords(undefined); + }} + /> + + } + > + {children(handleOpen, !!menuCords)} + + ); +} diff --git a/src/app/components/power/index.ts b/src/app/components/power/index.ts new file mode 100644 index 00000000..e2887808 --- /dev/null +++ b/src/app/components/power/index.ts @@ -0,0 +1,3 @@ +export * from './PowerColorBadge'; +export * from './PowerIcon'; +export * from './PowerSelector'; diff --git a/src/app/components/power/style.css.ts b/src/app/components/power/style.css.ts new file mode 100644 index 00000000..bf752987 --- /dev/null +++ b/src/app/components/power/style.css.ts @@ -0,0 +1,73 @@ +import { createVar, style } from '@vanilla-extract/css'; +import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; +import { color, config, DefaultReset, toRem } from 'folds'; + +export const PowerColorBadge = style({ + display: 'inline-block', + flexShrink: 0, + width: toRem(16), + height: toRem(16), + backgroundColor: color.Surface.OnContainer, + borderRadius: config.radii.Pill, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, +}); + +const PowerIconSize = createVar(); +export const PowerIcon = recipe({ + base: [ + DefaultReset, + { + display: 'inline-flex', + height: PowerIconSize, + minWidth: PowerIconSize, + fontSize: PowerIconSize, + lineHeight: PowerIconSize, + borderRadius: config.radii.R300, + cursor: 'default', + }, + ], + variants: { + size: { + '50': { + vars: { + [PowerIconSize]: config.size.X50, + }, + }, + '100': { + vars: { + [PowerIconSize]: config.size.X100, + }, + }, + '200': { + vars: { + [PowerIconSize]: config.size.X200, + }, + }, + '300': { + vars: { + [PowerIconSize]: config.size.X300, + }, + }, + '400': { + vars: { + [PowerIconSize]: config.size.X400, + }, + }, + '500': { + vars: { + [PowerIconSize]: config.size.X500, + }, + }, + '600': { + vars: { + [PowerIconSize]: config.size.X600, + }, + }, + }, + }, + defaultVariants: { + size: '400', + }, +}); + +export type PowerIconVariants = RecipeVariants; diff --git a/src/app/components/server-badge/ServerBadge.tsx b/src/app/components/server-badge/ServerBadge.tsx new file mode 100644 index 00000000..f61a146f --- /dev/null +++ b/src/app/components/server-badge/ServerBadge.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { as, Badge, Text } from 'folds'; + +export const ServerBadge = as< + 'div', + { + server: string; + fill?: 'Solid' | 'None'; + } +>(({ as: AsServerBadge = 'div', fill, server, ...props }, ref) => ( + + + {server} + + +)); diff --git a/src/app/components/server-badge/index.ts b/src/app/components/server-badge/index.ts new file mode 100644 index 00000000..eed89184 --- /dev/null +++ b/src/app/components/server-badge/index.ts @@ -0,0 +1 @@ +export * from './ServerBadge'; diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index d1a7ec6b..195d4443 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -18,16 +18,14 @@ import { import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room'; -import { - openInviteUser, - openSpaceSettings, - toggleRoomSettings, -} from '../../../client/action/navigation'; +import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { stopPropagation } from '../../utils/keyboard'; +import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; +import { useSpaceOptionally } from '../../hooks/useSpace'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -154,11 +152,14 @@ function SettingsMenuItem({ requestClose: () => void; disabled?: boolean; }) { + const openRoomSettings = useOpenRoomSettings(); + const space = useSpaceOptionally(); + const handleSettings = () => { if ('space' in item) { openSpaceSettings(item.roomId); } else { - toggleRoomSettings(item.roomId); + openRoomSettings(item.roomId, space?.roomId); } requestClose(); }; diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ffff0f45..27f72835 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -29,7 +29,7 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; import { copyToClipboard } from '../../utils/dom'; import { markAsRead } from '../../../client/action/notifications'; -import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation'; +import { openInviteUser } from '../../../client/action/navigation'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; @@ -41,6 +41,8 @@ import { getViaServers } from '../../plugins/via-servers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; +import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; +import { useSpaceOptionally } from '../../hooks/useSpace'; type RoomNavItemMenuProps = { room: Room; @@ -54,6 +56,8 @@ const RoomNavItemMenu = forwardRef( const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const openRoomSettings = useOpenRoomSettings(); + const space = useSpaceOptionally(); const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); @@ -73,7 +77,7 @@ const RoomNavItemMenu = forwardRef( }; const handleRoomSettings = () => { - toggleRoomSettings(room.roomId); + openRoomSettings(room.roomId, space?.roomId); requestClose(); }; diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx new file mode 100644 index 00000000..42192c01 --- /dev/null +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -0,0 +1,172 @@ +import React, { useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds'; +import { JoinRule } from 'matrix-js-sdk'; +import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { mxcUrlToHttp } from '../../utils/matrix'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta'; +import { mDirectAtom } from '../../state/mDirectList'; +import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; +import { General } from './general'; +import { Members } from './members'; +import { EmojisStickers } from './emojis-stickers'; +import { Permissions } from './permissions'; +import { RoomSettingsPage } from '../../state/roomSettings'; +import { useRoom } from '../../hooks/useRoom'; +import { DeveloperTools } from './developer-tools'; + +type RoomSettingsMenuItem = { + page: RoomSettingsPage; + name: string; + icon: IconSrc; +}; + +const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => + useMemo( + () => [ + { + page: RoomSettingsPage.GeneralPage, + name: 'General', + icon: Icons.Setting, + }, + { + page: RoomSettingsPage.MembersPage, + name: 'Members', + icon: Icons.User, + }, + { + page: RoomSettingsPage.PermissionsPage, + name: 'Permissions', + icon: Icons.Lock, + }, + { + page: RoomSettingsPage.EmojisStickersPage, + name: 'Emojis & Stickers', + icon: Icons.Smile, + }, + { + page: RoomSettingsPage.DeveloperToolsPage, + name: 'Developer Tools', + icon: Icons.Terminal, + }, + ], + [] + ); + +type RoomSettingsProps = { + initialPage?: RoomSettingsPage; + requestClose: () => void; +}; +export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { + const room = useRoom(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const mDirects = useAtomValue(mDirectAtom); + + const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId)); + const roomName = useRoomName(room); + const joinRuleContent = useRoomJoinRule(room); + + const avatarUrl = roomAvatar + ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + + const screenSize = useScreenSizeContext(); + const [activePage, setActivePage] = useState(() => { + if (initialPage) return initialPage; + return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage; + }); + const menuItems = useRoomSettingsMenuItems(); + + const handlePageRequestClose = () => { + if (screenSize === ScreenSize.Mobile) { + setActivePage(undefined); + return; + } + requestClose(); + }; + + return ( + + + + + ( + + )} + /> + + + {roomName} + + + + {screenSize === ScreenSize.Mobile && ( + + + + )} + + + + +
    + {menuItems.map((item) => ( + } + onClick={() => setActivePage(item.page)} + > + + {item.name} + + + ))} +
    +
    +
    + + ) + } + > + {activePage === RoomSettingsPage.GeneralPage && ( + + )} + {activePage === RoomSettingsPage.MembersPage && ( + + )} + {activePage === RoomSettingsPage.PermissionsPage && ( + + )} + {activePage === RoomSettingsPage.EmojisStickersPage && ( + + )} + {activePage === RoomSettingsPage.DeveloperToolsPage && ( + + )} +
    + ); +} diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx new file mode 100644 index 00000000..bc967775 --- /dev/null +++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { RoomSettings } from './RoomSettings'; +import { Modal500 } from '../../components/Modal500'; +import { useCloseRoomSettings, useRoomSettingsState } from '../../state/hooks/roomSettings'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { RoomSettingsState } from '../../state/roomSettings'; +import { RoomProvider } from '../../hooks/useRoom'; +import { SpaceProvider } from '../../hooks/useSpace'; + +type RenderSettingsProps = { + state: RoomSettingsState; +}; +function RenderSettings({ state }: RenderSettingsProps) { + const { roomId, spaceId, page } = state; + const closeSettings = useCloseRoomSettings(); + const allJoinedRooms = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allJoinedRooms); + const room = getRoom(roomId); + const space = spaceId ? getRoom(spaceId) : undefined; + + if (!room) return null; + + return ( + + + + + + + + ); +} + +export function RoomSettingsRenderer() { + const state = useRoomSettingsState(); + + if (!state) return null; + return ; +} diff --git a/src/app/features/room-settings/developer-tools/DevelopTools.tsx b/src/app/features/room-settings/developer-tools/DevelopTools.tsx new file mode 100644 index 00000000..29b6aa51 --- /dev/null +++ b/src/app/features/room-settings/developer-tools/DevelopTools.tsx @@ -0,0 +1,396 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Text, + IconButton, + Icon, + Icons, + Scroll, + Switch, + Button, + MenuItem, + config, + color, +} 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 { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { copyToClipboard } from '../../../utils/dom'; +import { useRoom } from '../../../hooks/useRoom'; +import { useRoomState } from '../../../hooks/useRoomState'; +import { StateEventEditor, StateEventInfo } from './StateEventEditor'; +import { SendRoomEvent } from './SendRoomEvent'; +import { useRoomAccountData } from '../../../hooks/useRoomAccountData'; +import { CutoutCard } from '../../../components/cutout-card'; +import { + AccountDataEditor, + AccountDataSubmitCallback, +} from '../../../components/AccountDataEditor'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; + +type DeveloperToolsProps = { + requestClose: () => void; +}; +export function DeveloperTools({ requestClose }: DeveloperToolsProps) { + const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const mx = useMatrixClient(); + const room = useRoom(); + + const roomState = useRoomState(room); + const accountData = useRoomAccountData(room); + + const [expandState, setExpandState] = useState(false); + const [expandStateType, setExpandStateType] = useState(); + const [openStateEvent, setOpenStateEvent] = useState(); + const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>(); + + const [expandAccountData, setExpandAccountData] = useState(false); + const [accountDataType, setAccountDataType] = useState(); + + const handleClose = useCallback(() => { + setOpenStateEvent(undefined); + setComposeEvent(undefined); + setAccountDataType(undefined); + }, []); + + const submitAccountData: AccountDataSubmitCallback = useCallback( + async (type, content) => { + await mx.setRoomAccountData(room.roomId, type, content); + }, + [mx, room.roomId] + ); + + if (accountDataType !== undefined) { + return ( + + ); + } + + if (composeEvent) { + return ; + } + + if (openStateEvent) { + return ; + } + + return ( + + + + + + Developer Tools + + + + + + + + + + + + + + + Options + + + } + /> + + {developerTools && ( + + copyToClipboard(room.roomId ?? '')} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Copy + + } + /> + + )} + + + {developerTools && ( + + Data + + + setComposeEvent({})} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Compose + + } + /> + + + setExpandState(!expandState)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expandState ? 'Collapse' : 'Expand'} + + } + /> + {expandState && ( + + + Events + Total: {roomState.size} + + + setComposeEvent({ stateKey: '' })} + variant="Surface" + fill="None" + size="300" + radii="0" + before={} + > + + + Add New + + + + {Array.from(roomState.keys()) + .sort() + .map((eventType) => { + const expanded = eventType === expandStateType; + const stateKeyToEvents = roomState.get(eventType); + if (!stateKeyToEvents) return null; + + return ( + + + setExpandStateType(expanded ? undefined : eventType) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={ + + } + after={{stateKeyToEvents.size}} + > + + + {eventType} + + + + {expanded && ( +
    + + setComposeEvent({ type: eventType, stateKey: '' }) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={} + > + + + Add New + + + + {Array.from(stateKeyToEvents.keys()) + .sort() + .map((stateKey) => ( + { + setOpenStateEvent({ + type: eventType, + stateKey, + }); + }} + key={stateKey} + variant="Surface" + fill="None" + size="300" + radii="0" + after={} + > + + + {stateKey ? `"${stateKey}"` : 'Default'} + + + + ))} +
    + )} +
    + ); + })} +
    +
    + )} +
    + + setExpandAccountData(!expandAccountData)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expandAccountData ? 'Collapse' : 'Expand'} + + } + /> + {expandAccountData && ( + + + Events + Total: {accountData.size} + + + } + onClick={() => setAccountDataType(null)} + > + + + Add New + + + + {Array.from(accountData.keys()) + .sort() + .map((type) => ( + } + onClick={() => setAccountDataType(type)} + > + + + {type} + + + + ))} + + + )} + +
    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/src/app/features/room-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/room-settings/developer-tools/SendRoomEvent.tsx new file mode 100644 index 00000000..f25ba7c3 --- /dev/null +++ b/src/app/features/room-settings/developer-tools/SendRoomEvent.tsx @@ -0,0 +1,208 @@ +import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react'; +import { MatrixError } from 'matrix-js-sdk'; +import { + Box, + Chip, + Icon, + Icons, + IconButton, + Text, + config, + Button, + Spinner, + color, + TextArea as TextAreaComponent, + Input, +} from 'folds'; +import { Page, PageHeader } from '../../../components/page'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { useAlive } from '../../../hooks/useAlive'; +import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { syntaxErrorPosition } from '../../../utils/dom'; +import { Cursor } from '../../../plugins/text-area'; + +const EDITOR_INTENT_SPACE_COUNT = 2; + +export type SendRoomEventProps = { + type?: string; + stateKey?: string; + requestClose: () => void; +}; +export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const alive = useAlive(); + const composeStateEvent = typeof stateKey === 'string'; + + const textAreaRef = useRef(null); + const [jsonError, setJSONError] = useState(); + const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor( + textAreaRef, + EDITOR_INTENT_SPACE_COUNT + ); + + const [submitState, submit] = useAsyncCallback< + object, + MatrixError, + [string, string | undefined, object] + >( + useCallback( + (evtType, evtStateKey, evtContent) => { + if (typeof evtStateKey === 'string') { + return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey); + } + return mx.sendEvent(room.roomId, evtType as any, evtContent); + }, + [mx, room] + ) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (submitting) return; + + const target = evt.target as HTMLFormElement | undefined; + const typeInput = target?.typeInput as HTMLInputElement | undefined; + const stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined; + const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined; + if (!typeInput || !contentTextArea) return; + + const evtType = typeInput.value; + const evtStateKey = stateKeyInput?.value; + const contentStr = contentTextArea.value.trim(); + + let parsedContent: object; + try { + parsedContent = JSON.parse(contentStr); + } catch (e) { + setJSONError(e as SyntaxError); + return; + } + setJSONError(undefined); + + if (parsedContent === null) { + return; + } + + submit(evtType, evtStateKey, parsedContent).then(() => { + if (alive()) { + requestClose(); + } + }); + }; + + useEffect(() => { + if (jsonError) { + const errorPosition = syntaxErrorPosition(jsonError) ?? 0; + const cursor = new Cursor(errorPosition, errorPosition, 'none'); + operations.select(cursor); + getTarget()?.focus(); + } + }, [jsonError, operations, getTarget]); + + return ( + + + + + } + > + Developer Tools + + + + + + + + + + + + + {composeStateEvent ? 'State Event Type' : 'Message Event Type'} + + + + + + + + {submitState.status === AsyncStatus.Error && ( + + {submitState.error.message} + + )} + + {composeStateEvent && ( + + State Key (Optional) + + + )} + + + JSON Content + + + {jsonError && ( + + + {jsonError.name}: {jsonError.message} + + + )} + + + + + ); +} diff --git a/src/app/features/room-settings/developer-tools/StateEventEditor.tsx b/src/app/features/room-settings/developer-tools/StateEventEditor.tsx new file mode 100644 index 00000000..6ee19be9 --- /dev/null +++ b/src/app/features/room-settings/developer-tools/StateEventEditor.tsx @@ -0,0 +1,298 @@ +import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Box, + Text, + Icon, + Icons, + IconButton, + Chip, + Scroll, + config, + TextArea as TextAreaComponent, + color, + Spinner, + Button, +} from 'folds'; +import { MatrixError } from 'matrix-js-sdk'; +import { Page, PageHeader } from '../../../components/page'; +import { SequenceCard } from '../../../components/sequence-card'; +import { TextViewerContent } from '../../../components/text-viewer'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { useRoom } from '../../../hooks/useRoom'; +import { StateEvent } from '../../../../types/matrix/room'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useAlive } from '../../../hooks/useAlive'; +import { Cursor } from '../../../plugins/text-area'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { syntaxErrorPosition } from '../../../utils/dom'; +import { SettingTile } from '../../../components/setting-tile'; +import { SequenceCardStyle } from '../styles.css'; +import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; + +const EDITOR_INTENT_SPACE_COUNT = 2; + +type StateEventEditProps = { + type: string; + stateKey: string; + content: object; + requestClose: () => void; +}; +function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const alive = useAlive(); + + const defaultContentStr = useMemo( + () => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT), + [content] + ); + + const textAreaRef = useRef(null); + const [jsonError, setJSONError] = useState(); + const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor( + textAreaRef, + EDITOR_INTENT_SPACE_COUNT + ); + + const [submitState, submit] = useAsyncCallback( + useCallback( + (c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey), + [mx, room, type, stateKey] + ) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (submitting) return; + + const target = evt.target as HTMLFormElement | undefined; + const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined; + if (!contentTextArea) return; + + const contentStr = contentTextArea.value.trim(); + + let parsedContent: object; + try { + parsedContent = JSON.parse(contentStr); + } catch (e) { + setJSONError(e as SyntaxError); + return; + } + setJSONError(undefined); + + if ( + parsedContent === null || + defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) + ) { + return; + } + + submit(parsedContent).then(() => { + if (alive()) { + requestClose(); + } + }); + }; + + useEffect(() => { + if (jsonError) { + const errorPosition = syntaxErrorPosition(jsonError) ?? 0; + const cursor = new Cursor(errorPosition, errorPosition, 'none'); + operations.select(cursor); + getTarget()?.focus(); + } + }, [jsonError, operations, getTarget]); + + return ( + + + State Event + + + + + + } + /> + + + {submitState.status === AsyncStatus.Error && ( + + {submitState.error.message} + + )} + + + + JSON Content + + + {jsonError && ( + + + {jsonError.name}: {jsonError.message} + + + )} + +
    + ); +} + +type StateEventViewProps = { + content: object; + eventJSONStr: string; + onEditContent?: (content: object) => void; +}; +function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) { + return ( + + + + + State Event + + {onEditContent && ( + + onEditContent(content)} + > + Edit + + + )} + + + + + + + + + ); +} + +export type StateEventInfo = { + type: string; + stateKey: string; +}; +export type StateEventEditorProps = StateEventInfo & { + requestClose: () => void; +}; + +export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey); + const [editContent, setEditContent] = useState(); + const powerLevels = usePowerLevels(room); + const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); + const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId())); + + const eventJSONStr = useMemo(() => { + if (!stateEvent) return ''; + return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT); + }, [stateEvent]); + + const handleCloseEdit = useCallback(() => { + setEditContent(undefined); + }, []); + + return ( + + + + + } + > + Developer Tools + + + + + + + + + + + {editContent ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/app/features/room-settings/developer-tools/index.ts b/src/app/features/room-settings/developer-tools/index.ts new file mode 100644 index 00000000..1fcceff5 --- /dev/null +++ b/src/app/features/room-settings/developer-tools/index.ts @@ -0,0 +1 @@ +export * from './DevelopTools'; diff --git a/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx new file mode 100644 index 00000000..ad8ffae6 --- /dev/null +++ b/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '../../../components/page'; +import { ImagePack } from '../../../plugins/custom-emoji'; +import { ImagePackView } from '../../../components/image-pack-view'; +import { RoomPacks } from './RoomPacks'; + +type EmojisStickersProps = { + requestClose: () => void; +}; +export function EmojisStickers({ requestClose }: EmojisStickersProps) { + const [imagePack, setImagePack] = useState(); + + const handleImagePackViewClose = () => { + setImagePack(undefined); + }; + + if (imagePack) { + return ; + } + + return ( + + + + + + Emojis & Stickers + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/room-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/room-settings/emojis-stickers/RoomPacks.tsx new file mode 100644 index 00000000..56dda548 --- /dev/null +++ b/src/app/features/room-settings/emojis-stickers/RoomPacks.tsx @@ -0,0 +1,349 @@ +import React, { FormEventHandler, useCallback, useMemo, useState } from 'react'; +import { + Box, + Text, + Button, + Icon, + Icons, + Avatar, + AvatarImage, + AvatarFallback, + toRem, + config, + Input, + Spinner, + color, + IconButton, + Menu, +} from 'folds'; +import { MatrixError } from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { + ImagePack, + ImageUsage, + PackAddress, + packAddressEqual, + PackContent, +} from '../../../plugins/custom-emoji'; +import { useRoom } from '../../../hooks/useRoom'; +import { useRoomImagePacks } from '../../../hooks/useImagePacks'; +import { LineClamp2 } from '../../../styles/Text.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { SequenceCardStyle } from '../styles.css'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mxcUrlToHttp } from '../../../utils/matrix'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { StateEvent } from '../../../../types/matrix/room'; +import { suffixRename } from '../../../utils/common'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useAlive } from '../../../hooks/useAlive'; + +type CreatePackTileProps = { + packs: ImagePack[]; + roomId: string; +}; +function CreatePackTile({ packs, roomId }: CreatePackTileProps) { + const mx = useMatrixClient(); + const alive = useAlive(); + + const [addState, addPack] = useAsyncCallback( + useCallback( + async (stateKey, name) => { + const content: PackContent = { + pack: { + display_name: name, + }, + }; + await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey); + }, + [mx, roomId] + ) + ); + + const creating = addState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (creating) return; + + const target = evt.target as HTMLFormElement | undefined; + const nameInput = target?.nameInput as HTMLInputElement | undefined; + if (!nameInput) return; + const name = nameInput?.value.trim(); + if (!name) return; + + let packKey = name.replace(/\s/g, '-'); + + const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k); + if (hasPack(packKey)) { + packKey = suffixRename(packKey, hasPack); + } + + addPack(packKey, name).then(() => { + if (alive()) { + nameInput.value = ''; + } + }); + }; + + return ( + + + + + Name + + {addState.status === AsyncStatus.Error && ( + + {addState.error.message} + + )} + + + + + + ); +} + +type RoomPacksProps = { + onViewPack: (imagePack: ImagePack) => void; +}; +export function RoomPacks({ onViewPack }: RoomPacksProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const room = useRoom(); + const alive = useAlive(); + + const powerLevels = usePowerLevels(room); + const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); + const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId())); + + const unfilteredPacks = useRoomImagePacks(room); + const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]); + + const [removedPacks, setRemovedPacks] = useState([]); + const hasChanges = removedPacks.length > 0; + + const [applyState, applyChanges] = useAsyncCallback( + useCallback(async () => { + for (let i = 0; i < removedPacks.length; i += 1) { + const addr = removedPacks[i]; + // eslint-disable-next-line no-await-in-loop + await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey); + } + }, [mx, room, removedPacks]) + ); + const applyingChanges = applyState.status === AsyncStatus.Loading; + + const handleRemove = (address: PackAddress) => { + setRemovedPacks((addresses) => [...addresses, address]); + }; + + const handleUndoRemove = (address: PackAddress) => { + setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address))); + }; + + const handleCancelChanges = () => setRemovedPacks([]); + + const handleApplyChanges = () => { + applyChanges().then(() => { + if (alive()) { + setRemovedPacks([]); + } + }); + }; + + const renderPack = (pack: ImagePack) => { + const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon); + const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; + const { address } = pack; + if (!address) return null; + const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address)); + + return ( + + + {pack.meta.name ?? 'Unknown'} + + } + description={{pack.meta.attribution}} + before={ + + {canEdit && + (removed ? ( + handleUndoRemove(address)} + disabled={applyingChanges} + > + + + ) : ( + handleRemove(address)} + disabled={applyingChanges} + > + + + ))} + + {avatarUrl ? ( + + ) : ( + + + + )} + + + } + after={ + !removed && ( + + ) + } + /> + + ); + }; + + return ( + <> + + Packs + {canEdit && } + {packs.map(renderPack)} + {packs.length === 0 && ( + + + + No Packs + + + There are no emoji or sticker packs to display at the moment. + + + + )} + + + {hasChanges && ( + + + + {applyState.status === AsyncStatus.Error ? ( + + Failed to remove packs! Please try again. + + ) : ( + + Delete selected packs. ({removedPacks.length} selected) + + )} + + + + + + + + )} + + ); +} diff --git a/src/app/features/room-settings/emojis-stickers/index.ts b/src/app/features/room-settings/emojis-stickers/index.ts new file mode 100644 index 00000000..9c9e9f52 --- /dev/null +++ b/src/app/features/room-settings/emojis-stickers/index.ts @@ -0,0 +1 @@ +export * from './EmojisStickers'; diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx new file mode 100644 index 00000000..6d66406a --- /dev/null +++ b/src/app/features/room-settings/general/General.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '../../../components/page'; +import { RoomProfile } from './RoomProfile'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRoom } from '../../../hooks/useRoom'; +import { RoomEncryption } from './RoomEncryption'; +import { RoomHistoryVisibility } from './RoomHistoryVisibility'; +import { RoomJoinRules } from './RoomJoinRules'; +import { RoomLocalAddresses, RoomPublishedAddresses } from './RoomAddress'; + +type GeneralProps = { + requestClose: () => void; +}; +export function General({ requestClose }: GeneralProps) { + const room = useRoom(); + const powerLevels = usePowerLevels(room); + + return ( + + + + + + General + + + + + + + + + + + + + + + + Options + + + + + + Addresses + + + + + + + + + ); +} diff --git a/src/app/features/room-settings/general/RoomAddress.tsx b/src/app/features/room-settings/general/RoomAddress.tsx new file mode 100644 index 00000000..dfe6645d --- /dev/null +++ b/src/app/features/room-settings/general/RoomAddress.tsx @@ -0,0 +1,438 @@ +import React, { FormEventHandler, useCallback, useState } from 'react'; +import { + Badge, + Box, + Button, + Checkbox, + Chip, + color, + config, + Icon, + Icons, + Input, + Spinner, + Text, + toRem, +} from 'folds'; +import { MatrixError } from 'matrix-js-sdk'; +import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; +import { SettingTile } from '../../../components/setting-tile'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { + useLocalAliases, + usePublishedAliases, + usePublishUnpublishAliases, + useSetMainAlias, +} from '../../../hooks/useRoomAliases'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { CutoutCard } from '../../../components/cutout-card'; +import { getIdServer } from '../../../../util/matrixUtil'; +import { replaceSpaceWithDash } from '../../../utils/common'; +import { useAlive } from '../../../hooks/useAlive'; +import { StateEvent } from '../../../../types/matrix/room'; + +type RoomPublishedAddressesProps = { + powerLevels: IPowerLevels; +}; + +export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); + const canEditCanonical = powerLevelAPI.canSendStateEvent( + powerLevels, + StateEvent.RoomCanonicalAlias, + userPowerLevel + ); + + const [canonicalAlias, publishedAliases] = usePublishedAliases(room); + const setMainAlias = useSetMainAlias(room); + + const [mainState, setMain] = useAsyncCallback(setMainAlias); + const loading = mainState.status === AsyncStatus.Loading; + + return ( + + + If room access is Public, Published addresses will be used to join by anyone. + + } + /> + + {publishedAliases.length === 0 ? ( + + No Addresses + + To publish an address, it needs to be set as a local address first + + + ) : ( + + {publishedAliases.map((alias) => ( + + + + {alias === canonicalAlias ? {alias} : alias} + + {alias === canonicalAlias && ( + + Main + + )} + + {canEditCanonical && ( + + {alias === canonicalAlias ? ( + setMain(undefined)} + > + Unset Main + + ) : ( + setMain(alias)} + > + Set Main + + )} + + )} + + ))} + + {mainState.status === AsyncStatus.Error && ( + + {(mainState.error as MatrixError).message} + + )} + + )} + + + ); +} + +function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise }) { + const mx = useMatrixClient(); + const userId = mx.getSafeUserId(); + const server = getIdServer(userId); + const alive = useAlive(); + + const [addState, addAlias] = useAsyncCallback(addLocalAlias); + const adding = addState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + if (adding) return; + evt.preventDefault(); + + const target = evt.target as HTMLFormElement | undefined; + const aliasInput = target?.aliasInput as HTMLInputElement | undefined; + if (!aliasInput) return; + const alias = replaceSpaceWithDash(aliasInput.value.trim()); + if (!alias) return; + + addAlias(`#${alias}:${server}`).then(() => { + if (alive()) { + aliasInput.value = ''; + } + }); + }; + + return ( + + + + #} + readOnly={adding} + after={ + + :{server} + + } + /> + + + + + + {addState.status === AsyncStatus.Error && ( + + {(addState.error as MatrixError).httpStatus === 409 + ? 'Address is already in use!' + : (addState.error as MatrixError).message} + + )} + + ); +} + +function LocalAddressesList({ + localAliases, + removeLocalAlias, + canEditCanonical, +}: { + localAliases: string[]; + removeLocalAlias: (alias: string) => Promise; + canEditCanonical?: boolean; +}) { + const room = useRoom(); + const alive = useAlive(); + + const [, publishedAliases] = usePublishedAliases(room); + const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room); + + const [selectedAliases, setSelectedAliases] = useState([]); + const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias)); + + const toggleSelect = (alias: string) => { + setSelectedAliases((aliases) => { + if (aliases.includes(alias)) { + return aliases.filter((a) => a !== alias); + } + const newAliases = [...aliases]; + newAliases.push(alias); + return newAliases; + }); + }; + const clearSelected = () => { + if (alive()) { + setSelectedAliases([]); + } + }; + + const [deleteState, deleteAliases] = useAsyncCallback( + useCallback( + async (aliases: string[]) => { + for (let i = 0; i < aliases.length; i += 1) { + const alias = aliases[i]; + // eslint-disable-next-line no-await-in-loop + await removeLocalAlias(alias); + } + }, + [removeLocalAlias] + ) + ); + const [publishState, publish] = useAsyncCallback(publishAliases); + const [unpublishState, unpublish] = useAsyncCallback(unpublishAliases); + + const handleDelete = () => { + deleteAliases(selectedAliases).then(clearSelected); + }; + const handlePublish = () => { + publish(selectedAliases).then(clearSelected); + }; + const handleUnpublish = () => { + unpublish(selectedAliases).then(clearSelected); + }; + + const loading = + deleteState.status === AsyncStatus.Loading || + publishState.status === AsyncStatus.Loading || + unpublishState.status === AsyncStatus.Loading; + let error: MatrixError | undefined; + if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError; + if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError; + if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError; + + return ( + + {selectedAliases.length > 0 && ( + + + {selectedAliases.length} Selected + + + {canEditCanonical && + (selectHasPublished ? ( + + ) + } + > + Unpublish + + ) : ( + + ) + } + > + Publish + + ))} + + ) + } + > + Delete + + + + )} + {localAliases.map((alias) => { + const published = publishedAliases.includes(alias); + const selected = selectedAliases.includes(alias); + + return ( + + + toggleSelect(alias)} + size="50" + variant="Primary" + disabled={loading} + /> + + + + {alias} + + + + {published && ( + + Published + + )} + + + ); + })} + {error && ( + + {error.message} + + )} + + ); +} + +export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) { + const mx = useMatrixClient(); + const room = useRoom(); + const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); + const canEditCanonical = powerLevelAPI.canSendStateEvent( + powerLevels, + StateEvent.RoomCanonicalAlias, + userPowerLevel + ); + + const [expand, setExpand] = useState(false); + + const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId); + + return ( + + setExpand(!expand)} + size="300" + variant="Secondary" + fill="Soft" + outlined + radii="300" + before={ + + } + > + + {expand ? 'Collapse' : 'Expand'} + + + } + /> + {expand && ( + + {localAliasesState.status === AsyncStatus.Loading && ( + + + Loading... + + )} + {localAliasesState.status === AsyncStatus.Success && + (localAliasesState.data.length === 0 ? ( + + No Addresses + + ) : ( + + ))} + {localAliasesState.status === AsyncStatus.Error && ( + + + {localAliasesState.error.message} + + + )} + + )} + {expand && } + + ); +} diff --git a/src/app/features/room-settings/general/RoomEncryption.tsx b/src/app/features/room-settings/general/RoomEncryption.tsx new file mode 100644 index 00000000..7d95fe35 --- /dev/null +++ b/src/app/features/room-settings/general/RoomEncryption.tsx @@ -0,0 +1,150 @@ +import { + Badge, + Box, + Button, + color, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, +} from 'folds'; +import React, { useCallback, useState } from 'react'; +import { MatrixError } from 'matrix-js-sdk'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { StateEvent } from '../../../../types/matrix/room'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useRoom } from '../../../hooks/useRoom'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { stopPropagation } from '../../../utils/keyboard'; + +const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2'; + +type RoomEncryptionProps = { + powerLevels: IPowerLevels; +}; +export function RoomEncryption({ powerLevels }: RoomEncryptionProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); + const canEnable = powerLevelAPI.canSendStateEvent( + powerLevels, + StateEvent.RoomEncryption, + userPowerLevel + ); + const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{ + algorithm: string; + }>(); + const enabled = content?.algorithm === ROOM_ENC_ALGO; + + const [enableState, enable] = useAsyncCallback( + useCallback(async () => { + await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, { + algorithm: ROOM_ENC_ALGO, + }); + }, [mx, room.roomId]) + ); + + const enabling = enableState.status === AsyncStatus.Loading; + + const [prompt, setPrompt] = useState(false); + + const handleEnable = () => { + enable(); + setPrompt(false); + }; + + return ( + + + Enabled + + ) : ( + + ) + } + > + {enableState.status === AsyncStatus.Error && ( + + {(enableState.error as MatrixError).message} + + )} + {prompt && ( + }> + + setPrompt(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
    + + Enable Encryption + + setPrompt(false)} radii="300"> + + +
    + + + Are you sure? Once enabled, encryption cannot be disabled! + + + +
    +
    +
    +
    + )} +
    +
    + ); +} diff --git a/src/app/features/room-settings/general/RoomHistoryVisibility.tsx b/src/app/features/room-settings/general/RoomHistoryVisibility.tsx new file mode 100644 index 00000000..d36e3121 --- /dev/null +++ b/src/app/features/room-settings/general/RoomHistoryVisibility.tsx @@ -0,0 +1,169 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { + Button, + color, + config, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, +} from 'folds'; +import { HistoryVisibility, MatrixError } from 'matrix-js-sdk'; +import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { StateEvent } from '../../../../types/matrix/room'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { stopPropagation } from '../../../utils/keyboard'; + +const useVisibilityStr = () => + useMemo( + () => ({ + [HistoryVisibility.Invited]: 'After Invite', + [HistoryVisibility.Joined]: 'After Join', + [HistoryVisibility.Shared]: 'All Messages', + [HistoryVisibility.WorldReadable]: 'All Messages (Guests)', + }), + [] + ); + +const useVisibilityMenu = () => + useMemo( + () => [ + HistoryVisibility.Shared, + HistoryVisibility.Invited, + HistoryVisibility.Joined, + HistoryVisibility.WorldReadable, + ], + [] + ); + +type RoomHistoryVisibilityProps = { + powerLevels: IPowerLevels; +}; +export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); + const canEdit = powerLevelAPI.canSendStateEvent( + powerLevels, + StateEvent.RoomHistoryVisibility, + userPowerLevel + ); + + const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility); + const historyVisibility: HistoryVisibility = + visibilityEvent?.getContent().history_visibility ?? + HistoryVisibility.Shared; + const visibilityMenu = useVisibilityMenu(); + const visibilityStr = useVisibilityStr(); + + const [menuAnchor, setMenuAnchor] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + const [submitState, submit] = useAsyncCallback( + useCallback( + async (visibility: HistoryVisibility) => { + const content: RoomHistoryVisibilityEventContent = { + history_visibility: visibility, + }; + await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content); + }, + [mx, room.roomId] + ) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleChange = (visibility: HistoryVisibility) => { + submit(visibility); + setMenuAnchor(undefined); + }; + + return ( + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {visibilityMenu.map((visibility) => ( + handleChange(visibility)} + aria-pressed={visibility === historyVisibility} + > + + {visibilityStr[visibility]} + + + ))} + + + } + > + + + } + > + {submitState.status === AsyncStatus.Error && ( + + {(submitState.error as MatrixError).message} + + )} + + + ); +} diff --git a/src/app/features/room-settings/general/RoomJoinRules.tsx b/src/app/features/room-settings/general/RoomJoinRules.tsx new file mode 100644 index 00000000..a98fee63 --- /dev/null +++ b/src/app/features/room-settings/general/RoomJoinRules.tsx @@ -0,0 +1,124 @@ +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 { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; +import { + JoinRulesSwitcher, + useRoomJoinRuleIcon, + useRoomJoinRuleLabel, +} from '../../../components/JoinRulesSwitcher'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { StateEvent } from '../../../../types/matrix/room'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { getStateEvents } from '../../../utils/room'; + +type RestrictedRoomAllowContent = { + room_id: string; + type: RestrictedAllowType; +}; + +type RoomJoinRulesProps = { + powerLevels: IPowerLevels; +}; +export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const roomVersion = parseInt(room.getVersion(), 10); + const allowRestricted = roomVersion >= 8; + const allowKnock = roomVersion >= 7; + const space = useSpaceOptionally(); + + const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); + const canEdit = powerLevelAPI.canSendStateEvent( + powerLevels, + StateEvent.RoomHistoryVisibility, + userPowerLevel + ); + + const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules); + const content = joinRuleEvent?.getContent(); + const rule: JoinRule = content?.join_rule ?? JoinRule.Invite; + + const joinRules: Array = useMemo(() => { + const r: JoinRule[] = [JoinRule.Invite]; + if (allowKnock) { + r.push(JoinRule.Knock); + } + if (allowRestricted && space) { + r.push(JoinRule.Restricted); + } + r.push(JoinRule.Public); + + return r; + }, [allowRestricted, allowKnock, space]); + + const icons = useRoomJoinRuleIcon(); + const labels = useRoomJoinRuleLabel(); + + const [submitState, submit] = useAsyncCallback( + useCallback( + async (joinRule: JoinRule) => { + const allow: RestrictedRoomAllowContent[] = []; + if (joinRule === JoinRule.Restricted) { + const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) => + event.getStateKey() + ); + parents.forEach((parentRoomId) => { + if (!parentRoomId) return; + allow.push({ + type: RestrictedAllowType.RoomMembership, + room_id: parentRoomId, + }); + }); + } + + const c: RoomJoinRulesEventContent = { + join_rule: joinRule, + }; + if (allow.length > 0) c.allow = allow; + await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); + }, + [mx, room] + ) + ); + + const submitting = submitState.status === AsyncStatus.Loading; + + return ( + + + } + > + {submitState.status === AsyncStatus.Error && ( + + {(submitState.error as MatrixError).message} + + )} + + + ); +} diff --git a/src/app/features/room-settings/general/RoomProfile.tsx b/src/app/features/room-settings/general/RoomProfile.tsx new file mode 100644 index 00000000..c0d89b43 --- /dev/null +++ b/src/app/features/room-settings/general/RoomProfile.tsx @@ -0,0 +1,351 @@ +import { + Avatar, + Box, + Button, + Chip, + color, + Icon, + Icons, + Input, + Spinner, + Text, + TextArea, +} from 'folds'; +import React, { FormEventHandler, useCallback, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import Linkify from 'linkify-react'; +import classNames from 'classnames'; +import { JoinRule, MatrixError } from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { useRoom } from '../../../hooks/useRoom'; +import { + useRoomAvatar, + useRoomJoinRule, + useRoomName, + useRoomTopic, +} from '../../../hooks/useRoomMeta'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; +import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser'; +import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { mxcUrlToHttp } from '../../../utils/matrix'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { StateEvent } from '../../../../types/matrix/room'; +import { CompactUploadCardRenderer } from '../../../components/upload-card'; +import { useObjectURL } from '../../../hooks/useObjectURL'; +import { createUploadAtom, UploadSuccess } from '../../../state/upload'; +import { useFilePicker } from '../../../hooks/useFilePicker'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useAlive } from '../../../hooks/useAlive'; + +type RoomProfileEditProps = { + canEditAvatar: boolean; + canEditName: boolean; + canEditTopic: boolean; + avatar?: string; + name?: string; + topic?: string; + onClose: () => void; +}; +export function RoomProfileEdit({ + canEditAvatar, + canEditName, + canEditTopic, + avatar, + name, + topic, + onClose, +}: RoomProfileEditProps) { + const room = useRoom(); + const mx = useMatrixClient(); + const alive = useAlive(); + const useAuthentication = useMediaAuthentication(); + const joinRule = useRoomJoinRule(room); + const [roomAvatar, setRoomAvatar] = useState(avatar); + + const avatarUrl = roomAvatar + ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined + : undefined; + + const [imageFile, setImageFile] = useState(); + const avatarFileUrl = useObjectURL(imageFile); + const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false; + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + setRoomAvatar(avatar); + }, [avatar]); + + const handleUploaded = useCallback((upload: UploadSuccess) => { + setRoomAvatar(upload.mxc); + }, []); + + const [submitState, submit] = useAsyncCallback( + useCallback( + async ( + roomAvatarMxc?: string | null, + roomName?: string | null, + roomTopic?: string | null + ) => { + if (roomAvatarMxc !== undefined) { + await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, { + url: roomAvatarMxc, + }); + } + if (roomName !== undefined) { + await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName }); + } + if (roomTopic !== undefined) { + await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic }); + } + }, + [mx, room.roomId] + ) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (uploadingAvatar) return; + + const target = evt.target as HTMLFormElement | undefined; + const nameInput = target?.nameInput as HTMLInputElement | undefined; + const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined; + if (!nameInput || !topicTextArea) return; + + const roomName = nameInput.value.trim(); + const roomTopic = topicTextArea.value.trim(); + + submit( + roomAvatar === avatar ? undefined : roomAvatar || null, + roomName === name ? undefined : roomName || null, + roomTopic === topic ? undefined : roomTopic || null + ).then(() => { + if (alive()) { + onClose(); + } + }); + }; + + return ( + + + + Avatar + {uploadAtom ? ( + + + + ) : ( + + + {!roomAvatar && avatar && ( + + )} + {roomAvatar && ( + + )} + + )} + + + + ( + + )} + /> + + + + + Name + + + + Topic +