URL navigation in interface and other improvements (#1633)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Fri, 31 May 2024 14:19:46 +0000 (19:49 +0530)
committerGitHub <noreply@github.com>
Fri, 31 May 2024 14:19:46 +0000 (00:19 +1000)
* load room on url change

* add direct room list

* render space room list

* fix css syntax error

* update scroll virtualizer

* render subspaces room list

* improve sidebar notification badge perf

* add nav category components

* add space recursive direct component

* use nav category component in home, direct and space room list

* add empty home and direct list layout

* fix unread room menu ref

* add more navigation items in room, direct and space tab

* add more navigation

* fix unread room menu to links

* fix space lobby and search link

* add explore navigation section

* add notifications navigation menu

* redirect to initial path after login

* include unsupported room in rooms

* move router hooks in hooks/router folder

* add featured explore - WIP

* load featured room with room summary

* fix room card topic line clamp

* add react query

* load room summary using react query

* add join button in room card

* add content component

* use content component in featured community content

* fix content width

* add responsive room card grid

* fix async callback error status

* add room card error button

* fix client drawer shrink

* add room topic viewer

* open room card topic in viewer

* fix room topic close btn

* add get orphan parent util

* add room card error dialog

* add view featured room or space btn

* refactor orphanParent to orphanParents

* WIP - explore server

* show space hint in room card

* add room type filters

* add per page item limit popout

* reset scroll on public rooms load

* refactor explore ui

* refactor public rooms component

* reset search on server change

* fix typo

* add empty featured section info

* display user server on top

* make server room card view btn clickable

* add user server as default redirect for explore path

* make home empty btn clickable

* add thirdparty instance filter in server explore

* remove since param on instance change

* add server button in explore menu

* rename notifications path to inbox

* update react-virtual

* Add notification messages inbox - WIP

* add scroll top container component

* add useInterval hook

* add visibility change callback prop to scroll top container component

* auto refresh notifications every 10 seconds

* make message related component reusable

* refactor matrix event renderer hoook

* render notification message content

* refactor matrix event renderer hook

* update sequence card styles

* move room navigate hook in global hooks

* add open message button in notifications

* add mark room as read button in notification group

* show error in notification messages

* add more featured spaces

* render reply in notification messages

* make notification message reply clickable

* add outline prop for attachments

* make old settings dialog viewable

* add open featured communities as default config option

* add invite count notification badge in sidebar and inbox menu

* add element size observer hook

* improve element size observer hook props

* improve screen size hook

* fix room avatar util function

* allow Text props in Time component

* fix dm room util function

* add invitations

* add no invites and notification cards

* fix inbox tab unread badge visible without invite count

* update folds and change inbox icon

* memo search param construction

* add message search in home

* fix default message search order

* fix display edited message new content

* highlight search text in search messages

* fix message search loading

* disable log in production

* add use space context

* add useRoom context

* fix space room list

* fix inbox tab active state

* add hook to get space child room recursive

* add search for space

* add virtual tile component

* virtualize home and directs room list

* update nav category component

* use virtual tile component in more places

* fix message highlight when click on reply twice

* virtualize space room list

* fix space room list lag issue

* update folds

* add room nav item component in space room list

* use room nav item in home and direct room list

* make space categories closable and save it in local storage

* show unread room when category is collapsed

* make home and direct room list category closable

* rename room nav item show avatar prop

* fix explore server category text alignment

* rename closedRoomCategories to closedNavCategories

* add nav category handler hook

* save and restore last navigation path on space select

* filter space rooms category by activity when it is closed

* save and restore home and direct nav path state

* save and restore inbox active path on open

* save and restore explore tab active path

* remove notification badge unread menu

* add join room or space before navigate screen

* move room component to features folder and add new room header

* update folds

* add room header menu

* fix home room list activity sorting

* do not hide selected room item on category closed in home and direct tab

* replace old select room/tab call with navigate hook

* improve state event hooks

* show room card summary for joined rooms

* prevent room from opening in wrong tab

* only show message sender id on hover in modern layout

* revert state event hooks changes

* add key prop to room provider components

* add welcome page

* prevent excessive redirects

* fix sidebar style with no spaces

* move room settings in popup window

* remove invite option from room settings

* fix open room list search

* add leave room prompt

* standardize room and user avatar

* fix avatar text size

* add new reply layout

* rename space hierarchy hook

* add room topic hook

* add room name hook

* add room avatar hook and add direct room avatar util

* space lobby - WIP

* hide invalid space child event from space hierarchy in lobby

* move lobby to features

* fix element size observer hook width and height

* add lobby header and hero section

* add hierarchy room item error and loading state

* add first and last child prop in sequence card

* redirect to lobby from index path

* memo and retry hierarchy room summary error

* fix hierarchy room item styles

* rename lobby hierarchy item card to room item card

* show direct room avatar in space lobby

* add hierarchy space item

* add space item unknown room join button

* fix space hierarchy hook refresh after new space join

* change user avatar color and fallback render to user icon

* change room avatar fallback to room icon

* rename room/user avatar renderInitial prop to renderFallback

* add room join and view button in space lobby

* make power level api more reusable

* fix space hierarchy not updating on child update

* add menu to suggest or remove space children

* show reply arrow in place of reply bend in message

* fix typeerror in search because of wrong js-sdk t.ds

* do not refetch hierarchy room summary on window focus

* make room/user avatar un-draggable

* change welcome page support button copy

* drag-and-drop ordering of lobby spaces/rooms - WIP

* add ASCIILexicalTable algorithms

* fix wrong power level check in lobby items options

* fix lobby can drop checks

* fix join button error crash

* fix reply spacing

* fix m direct updated with other account data

* add option to open room/space settings from lobby

* add option in lobby to add new or existing room/spaces

* fix room nav item selected styles

* add space children reorder mechanism

* fix space child reorder bug

* fix hierarchy item sort function

* Apply reorder of lobby into room list

* add and improve space lobby menu items

* add existing spaces menu in lobby

* change restricted room allow params when dragging outside space

* move featured servers config from homeserver list

* removed unused features from space settings

* add canonical alias as name fallback in lobby item

* fix unreliable unread count update bug

* fix after login redirect

* fix room card topic hover style

* Add dnd and folders in sidebar spaces

* fix orphan space not visible in sidebar

* fix sso login has mix of icon and button

* fix space children not  visible in home upon leaving space

* recalculate notification on updating any space child

* fix user color saturation/lightness

* add user color to user avatar

* add background colors to room avatar

* show 2 length initial in sidebar space avatar

* improve link color

* add nav button component

* open legacy create room and create direct

* improve page route structure

* handle hash router in path utils

* mobile friendly router and navigation

* make room header member drawer icon mobile friendly

* setup index redirect for inbox and explore server route

* add leave space prompt

* improve member drawer filter menu

* add space context menu

* add context menu in home

* add leave button in lobby items

* render user tab avatar on sidebar

* force overwrite netlify - test

* netlify test

* fix reset-password path without server redirected to login

* add message link copy button in message menu

* reset unread on sync prepared

* fix stuck typing notifications

* show typing indication in room nav item

* refactor closedNavCategories atom to use userId in store key

* refactor closedLobbyCategoriesAtom to include userId in store key

* refactor navToActivePathAtom to use userId in storage key

* remove unused file

* refactor openedSidebarFolderAtom to include userId in storage key

* add context menu for sidebar space tab

* fix eslint not working

* add option to pin/unpin child spaces

* add context menu for directs tab

* add context menu for direct and home tab

* show lock icon for non-public space in header

* increase matrix max listener count

* wrap lobby add space room in callback hook

327 files changed:
.eslintrc.cjs [new file with mode: 0644]
.eslintrc.js [deleted file]
config.json
netlify.toml
package-lock.json
package.json
src/app/components/CapabilitiesAndMediaConfigLoader.tsx [new file with mode: 0644]
src/app/components/CapabilitiesLoader.tsx [new file with mode: 0644]
src/app/components/MediaConfigLoader.tsx [new file with mode: 0644]
src/app/components/Pdf-viewer/PdfViewer.tsx
src/app/components/RenderMessageContent.tsx [new file with mode: 0644]
src/app/components/RoomSummaryLoader.tsx [new file with mode: 0644]
src/app/components/RoomUnreadProvider.tsx [new file with mode: 0644]
src/app/components/SpaceChildDirectsProvider.tsx [new file with mode: 0644]
src/app/components/SpaceChildRoomsProvider.tsx [new file with mode: 0644]
src/app/components/SpecVersionsLoader.tsx
src/app/components/editor/Toolbar.tsx
src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx
src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
src/app/components/event-readers/EventReaders.tsx
src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx [new file with mode: 0644]
src/app/components/leave-room-prompt/index.ts [new file with mode: 0644]
src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx [new file with mode: 0644]
src/app/components/leave-space-prompt/index.ts [new file with mode: 0644]
src/app/components/message/FileHeader.tsx [new file with mode: 0644]
src/app/components/message/MessageContentFallback.tsx [deleted file]
src/app/components/message/MsgTypeRenderers.tsx [new file with mode: 0644]
src/app/components/message/RenderBody.tsx [new file with mode: 0644]
src/app/components/message/Reply.css.ts
src/app/components/message/Reply.tsx
src/app/components/message/Time.tsx
src/app/components/message/content/AudioContent.tsx [new file with mode: 0644]
src/app/components/message/content/EventContent.tsx [new file with mode: 0644]
src/app/components/message/content/FallbackContent.tsx [new file with mode: 0644]
src/app/components/message/content/FileContent.tsx [new file with mode: 0644]
src/app/components/message/content/ImageContent.tsx [new file with mode: 0644]
src/app/components/message/content/ThumbnailContent.tsx [new file with mode: 0644]
src/app/components/message/content/VideoContent.tsx [new file with mode: 0644]
src/app/components/message/content/index.ts [new file with mode: 0644]
src/app/components/message/content/style.css.ts [new file with mode: 0644]
src/app/components/message/content/util.ts [new file with mode: 0644]
src/app/components/message/index.ts
src/app/components/message/layout/layout.css.ts
src/app/components/nav/NavCategory.tsx [new file with mode: 0644]
src/app/components/nav/NavCategoryHeader.tsx [new file with mode: 0644]
src/app/components/nav/NavEmptyLayout.tsx [new file with mode: 0644]
src/app/components/nav/NavItem.tsx [new file with mode: 0644]
src/app/components/nav/NavItemContent.tsx [new file with mode: 0644]
src/app/components/nav/NavItemOptions.tsx [new file with mode: 0644]
src/app/components/nav/index.ts [new file with mode: 0644]
src/app/components/nav/styles.css.ts [new file with mode: 0644]
src/app/components/page/Page.tsx [new file with mode: 0644]
src/app/components/page/index.tsx [new file with mode: 0644]
src/app/components/page/style.css.ts [new file with mode: 0644]
src/app/components/room-avatar/RoomAvatar.css.ts [new file with mode: 0644]
src/app/components/room-avatar/RoomAvatar.tsx [new file with mode: 0644]
src/app/components/room-avatar/index.ts [new file with mode: 0644]
src/app/components/room-card/RoomCard.tsx [new file with mode: 0644]
src/app/components/room-card/index.ts [new file with mode: 0644]
src/app/components/room-card/style.css.ts [new file with mode: 0644]
src/app/components/room-intro/RoomIntro.tsx
src/app/components/room-topic-viewer/RoomTopicViewer.tsx [new file with mode: 0644]
src/app/components/room-topic-viewer/index.ts [new file with mode: 0644]
src/app/components/room-topic-viewer/style.css.ts [new file with mode: 0644]
src/app/components/scroll-top-container/ScrollTopContainer.tsx [new file with mode: 0644]
src/app/components/scroll-top-container/index.ts [new file with mode: 0644]
src/app/components/scroll-top-container/style.css.ts [new file with mode: 0644]
src/app/components/sequence-card/SequenceCard.tsx [new file with mode: 0644]
src/app/components/sequence-card/index.ts [new file with mode: 0644]
src/app/components/sequence-card/style.css.ts [new file with mode: 0644]
src/app/components/sidebar/Sidebar.css.ts
src/app/components/sidebar/SidebarAvatar.tsx [deleted file]
src/app/components/sidebar/SidebarContent.tsx
src/app/components/sidebar/SidebarItem.tsx [new file with mode: 0644]
src/app/components/sidebar/index.ts
src/app/components/typing-indicator/TypingIndicator.css.ts
src/app/components/typing-indicator/TypingIndicator.tsx
src/app/components/unread-badge/UnreadBadge.tsx [new file with mode: 0644]
src/app/components/unread-badge/index.ts [new file with mode: 0644]
src/app/components/url-preview/UrlPreviewCard.css.tsx [new file with mode: 0644]
src/app/components/url-preview/UrlPreviewCard.tsx [new file with mode: 0644]
src/app/components/url-preview/index.ts
src/app/components/user-avatar/UserAvatar.css.ts [new file with mode: 0644]
src/app/components/user-avatar/UserAvatar.tsx [new file with mode: 0644]
src/app/components/user-avatar/index.ts [new file with mode: 0644]
src/app/components/virtualizer/VirtualTile.tsx [new file with mode: 0644]
src/app/components/virtualizer/index.ts [new file with mode: 0644]
src/app/components/virtualizer/style.css.ts [new file with mode: 0644]
src/app/cs-api.ts
src/app/features/join-before-navigate/JoinBeforeNavigate.tsx [new file with mode: 0644]
src/app/features/join-before-navigate/index.ts [new file with mode: 0644]
src/app/features/lobby/DnD.css.ts [new file with mode: 0644]
src/app/features/lobby/DnD.tsx [new file with mode: 0644]
src/app/features/lobby/HierarchyItemMenu.tsx [new file with mode: 0644]
src/app/features/lobby/Lobby.tsx [new file with mode: 0644]
src/app/features/lobby/LobbyHeader.css.ts [new file with mode: 0644]
src/app/features/lobby/LobbyHeader.tsx [new file with mode: 0644]
src/app/features/lobby/LobbyHero.css.tsx [new file with mode: 0644]
src/app/features/lobby/LobbyHero.tsx [new file with mode: 0644]
src/app/features/lobby/RoomItem.css.ts [new file with mode: 0644]
src/app/features/lobby/RoomItem.tsx [new file with mode: 0644]
src/app/features/lobby/SpaceItem.css.ts [new file with mode: 0644]
src/app/features/lobby/SpaceItem.tsx [new file with mode: 0644]
src/app/features/lobby/index.ts [new file with mode: 0644]
src/app/features/lobby/style.css.ts [new file with mode: 0644]
src/app/features/message-search/MessageSearch.tsx [new file with mode: 0644]
src/app/features/message-search/SearchFilters.tsx [new file with mode: 0644]
src/app/features/message-search/SearchInput.tsx [new file with mode: 0644]
src/app/features/message-search/SearchResultGroup.tsx [new file with mode: 0644]
src/app/features/message-search/index.ts [new file with mode: 0644]
src/app/features/message-search/useMessageSearch.ts [new file with mode: 0644]
src/app/features/room-nav/RoomNavCategoryButton.tsx [new file with mode: 0644]
src/app/features/room-nav/RoomNavItem.tsx [new file with mode: 0644]
src/app/features/room-nav/index.ts [new file with mode: 0644]
src/app/features/room-nav/styles.css.ts [new file with mode: 0644]
src/app/features/room/CommandAutocomplete.tsx [new file with mode: 0644]
src/app/features/room/MembersDrawer.css.ts [new file with mode: 0644]
src/app/features/room/MembersDrawer.tsx [new file with mode: 0644]
src/app/features/room/Room.tsx [new file with mode: 0644]
src/app/features/room/RoomInput.tsx [new file with mode: 0644]
src/app/features/room/RoomInputPlaceholder.css.ts [new file with mode: 0644]
src/app/features/room/RoomInputPlaceholder.tsx [new file with mode: 0644]
src/app/features/room/RoomTimeline.css.ts [new file with mode: 0644]
src/app/features/room/RoomTimeline.tsx [new file with mode: 0644]
src/app/features/room/RoomTombstone.css.ts [new file with mode: 0644]
src/app/features/room/RoomTombstone.tsx [new file with mode: 0644]
src/app/features/room/RoomView.tsx [new file with mode: 0644]
src/app/features/room/RoomViewFollowing.css.ts [new file with mode: 0644]
src/app/features/room/RoomViewFollowing.tsx [new file with mode: 0644]
src/app/features/room/RoomViewHeader.css.ts [new file with mode: 0644]
src/app/features/room/RoomViewHeader.tsx [new file with mode: 0644]
src/app/features/room/RoomViewTyping.css.ts [new file with mode: 0644]
src/app/features/room/RoomViewTyping.tsx [new file with mode: 0644]
src/app/features/room/index.ts [new file with mode: 0644]
src/app/features/room/message/EncryptedContent.tsx [new file with mode: 0644]
src/app/features/room/message/Message.tsx [new file with mode: 0644]
src/app/features/room/message/MessageEditor.tsx [new file with mode: 0644]
src/app/features/room/message/Reactions.tsx [new file with mode: 0644]
src/app/features/room/message/index.ts [new file with mode: 0644]
src/app/features/room/message/styles.css.ts [new file with mode: 0644]
src/app/features/room/msgContent.ts [new file with mode: 0644]
src/app/features/room/reaction-viewer/ReactionViewer.css.ts [new file with mode: 0644]
src/app/features/room/reaction-viewer/ReactionViewer.tsx [new file with mode: 0644]
src/app/features/room/reaction-viewer/index.ts [new file with mode: 0644]
src/app/hooks/router/useDirectSelected.ts [new file with mode: 0644]
src/app/hooks/router/useExploreSelected.ts [new file with mode: 0644]
src/app/hooks/router/useHomeSelected.ts [new file with mode: 0644]
src/app/hooks/router/useInbox.ts [new file with mode: 0644]
src/app/hooks/router/useSelectedRoom.ts [new file with mode: 0644]
src/app/hooks/router/useSelectedSpace.ts [new file with mode: 0644]
src/app/hooks/useAccountDataCallback.ts [new file with mode: 0644]
src/app/hooks/useAsyncCallback.ts
src/app/hooks/useCapabilities.ts [new file with mode: 0644]
src/app/hooks/useCategoryHandler.ts [new file with mode: 0644]
src/app/hooks/useClientConfig.ts
src/app/hooks/useCommands.ts
src/app/hooks/useElementSizeObserver.ts [new file with mode: 0644]
src/app/hooks/useInterval.ts [new file with mode: 0644]
src/app/hooks/useJoinedRoomId.ts [new file with mode: 0644]
src/app/hooks/useLocalRoomSummary.ts [new file with mode: 0644]
src/app/hooks/useMatrixEventRenderer.ts
src/app/hooks/useMediaConfig.ts [new file with mode: 0644]
src/app/hooks/useNavToActivePathMapper.ts [new file with mode: 0644]
src/app/hooks/usePowerLevels.ts
src/app/hooks/useRoom.ts [new file with mode: 0644]
src/app/hooks/useRoomMeta.ts [new file with mode: 0644]
src/app/hooks/useRoomMsgContentRenderer.ts [deleted file]
src/app/hooks/useRoomNavigate.ts [new file with mode: 0644]
src/app/hooks/useRoomTypingMembers.ts [new file with mode: 0644]
src/app/hooks/useScreenSize.ts
src/app/hooks/useSidebarItems.ts [new file with mode: 0644]
src/app/hooks/useSpace.ts [new file with mode: 0644]
src/app/hooks/useSpaceHierarchy.ts [new file with mode: 0644]
src/app/hooks/useSyncState.ts [new file with mode: 0644]
src/app/hooks/useTypingStatusUpdater.ts
src/app/molecules/space-add-existing/SpaceAddExisting.jsx
src/app/organisms/navigation/Drawer.jsx
src/app/organisms/navigation/Sidebar1.tsx [deleted file]
src/app/organisms/pw/Windows.jsx
src/app/organisms/room/CommandAutocomplete.tsx [deleted file]
src/app/organisms/room/MembersDrawer.css.ts [deleted file]
src/app/organisms/room/MembersDrawer.tsx [deleted file]
src/app/organisms/room/Room.scss
src/app/organisms/room/Room.tsx [deleted file]
src/app/organisms/room/RoomInput.tsx [deleted file]
src/app/organisms/room/RoomInputPlaceholder.css.ts [deleted file]
src/app/organisms/room/RoomInputPlaceholder.tsx [deleted file]
src/app/organisms/room/RoomSettings.jsx
src/app/organisms/room/RoomSettings.scss
src/app/organisms/room/RoomTimeline.css.ts [deleted file]
src/app/organisms/room/RoomTimeline.tsx [deleted file]
src/app/organisms/room/RoomTombstone.css.ts [deleted file]
src/app/organisms/room/RoomTombstone.tsx [deleted file]
src/app/organisms/room/RoomView.jsx [deleted file]
src/app/organisms/room/RoomViewFollowing.css.ts [deleted file]
src/app/organisms/room/RoomViewFollowing.tsx [deleted file]
src/app/organisms/room/RoomViewTyping.css.ts [deleted file]
src/app/organisms/room/RoomViewTyping.tsx [deleted file]
src/app/organisms/room/message/AudioContent.tsx [deleted file]
src/app/organisms/room/message/EncryptedContent.tsx [deleted file]
src/app/organisms/room/message/EventContent.tsx [deleted file]
src/app/organisms/room/message/FileContent.tsx [deleted file]
src/app/organisms/room/message/FileHeader.tsx [deleted file]
src/app/organisms/room/message/ImageContent.tsx [deleted file]
src/app/organisms/room/message/Message.tsx [deleted file]
src/app/organisms/room/message/MessageEditor.tsx [deleted file]
src/app/organisms/room/message/Reactions.tsx [deleted file]
src/app/organisms/room/message/StickerContent.tsx [deleted file]
src/app/organisms/room/message/UrlPreviewCard.tsx [deleted file]
src/app/organisms/room/message/VideoContent.tsx [deleted file]
src/app/organisms/room/message/fileRenderer.tsx [deleted file]
src/app/organisms/room/message/index.ts [deleted file]
src/app/organisms/room/message/styles.css.ts [deleted file]
src/app/organisms/room/message/util.ts [deleted file]
src/app/organisms/room/msgContent.ts [deleted file]
src/app/organisms/room/reaction-viewer/ReactionViewer.css.ts [deleted file]
src/app/organisms/room/reaction-viewer/ReactionViewer.tsx [deleted file]
src/app/organisms/room/reaction-viewer/index.ts [deleted file]
src/app/organisms/search/Search.jsx
src/app/organisms/space-settings/SpaceSettings.jsx
src/app/pages/App.tsx
src/app/pages/MobileFriendly.tsx [new file with mode: 0644]
src/app/pages/Router.tsx [new file with mode: 0644]
src/app/pages/afterLoginRedirectPath.ts [new file with mode: 0644]
src/app/pages/auth/AuthLayout.tsx
src/app/pages/auth/SSOLogin.tsx
src/app/pages/auth/ServerPicker.tsx
src/app/pages/auth/login/Login.tsx
src/app/pages/auth/login/PasswordLoginForm.tsx
src/app/pages/auth/login/loginUtil.ts
src/app/pages/auth/register/Register.tsx
src/app/pages/auth/register/registerUtil.ts
src/app/pages/auth/reset-password/ResetPassword.tsx
src/app/pages/client/ClientBindAtoms.ts [new file with mode: 0644]
src/app/pages/client/ClientInitStorageAtom.tsx [new file with mode: 0644]
src/app/pages/client/ClientLayout.tsx [new file with mode: 0644]
src/app/pages/client/ClientRoot.tsx [new file with mode: 0644]
src/app/pages/client/SidebarNav.tsx [new file with mode: 0644]
src/app/pages/client/SpecVersions.tsx [new file with mode: 0644]
src/app/pages/client/WelcomePage.tsx [new file with mode: 0644]
src/app/pages/client/direct/Direct.tsx [new file with mode: 0644]
src/app/pages/client/direct/RoomProvider.tsx [new file with mode: 0644]
src/app/pages/client/direct/index.ts [new file with mode: 0644]
src/app/pages/client/direct/useDirectRooms.ts [new file with mode: 0644]
src/app/pages/client/explore/Explore.tsx [new file with mode: 0644]
src/app/pages/client/explore/Featured.tsx [new file with mode: 0644]
src/app/pages/client/explore/Server.tsx [new file with mode: 0644]
src/app/pages/client/explore/index.ts [new file with mode: 0644]
src/app/pages/client/explore/style.css.ts [new file with mode: 0644]
src/app/pages/client/home/Home.tsx [new file with mode: 0644]
src/app/pages/client/home/RoomProvider.tsx [new file with mode: 0644]
src/app/pages/client/home/Search.tsx [new file with mode: 0644]
src/app/pages/client/home/index.ts [new file with mode: 0644]
src/app/pages/client/home/useHomeRooms.ts [new file with mode: 0644]
src/app/pages/client/inbox/Inbox.tsx [new file with mode: 0644]
src/app/pages/client/inbox/Invites.tsx [new file with mode: 0644]
src/app/pages/client/inbox/Notifications.tsx [new file with mode: 0644]
src/app/pages/client/inbox/index.ts [new file with mode: 0644]
src/app/pages/client/index.ts [new file with mode: 0644]
src/app/pages/client/sidebar/DirectTab.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/ExploreTab.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/HomeTab.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/InboxTab.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/SpaceTabs.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/UserTab.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/index.ts [new file with mode: 0644]
src/app/pages/client/space/RoomProvider.tsx [new file with mode: 0644]
src/app/pages/client/space/Search.tsx [new file with mode: 0644]
src/app/pages/client/space/Space.tsx [new file with mode: 0644]
src/app/pages/client/space/SpaceProvider.tsx [new file with mode: 0644]
src/app/pages/client/space/index.ts [new file with mode: 0644]
src/app/pages/pathUtils.ts
src/app/pages/paths.ts
src/app/plugins/millify.ts [new file with mode: 0644]
src/app/plugins/pdfjs-dist.ts
src/app/plugins/react-custom-html-parser.tsx
src/app/state/closedLobbyCategories.ts [new file with mode: 0644]
src/app/state/closedNavCategories.ts [new file with mode: 0644]
src/app/state/hooks/closedLobbyCategories.ts [new file with mode: 0644]
src/app/state/hooks/closedNavCategories.ts [new file with mode: 0644]
src/app/state/hooks/inviteList.ts
src/app/state/hooks/navToActivePath.ts [new file with mode: 0644]
src/app/state/hooks/openedSidebarFolder.ts [new file with mode: 0644]
src/app/state/hooks/roomList.ts
src/app/state/hooks/unread.ts [new file with mode: 0644]
src/app/state/hooks/useBindAtoms.ts
src/app/state/inviteList.ts [deleted file]
src/app/state/mDirectList.ts
src/app/state/mutedRoomList.ts [deleted file]
src/app/state/navToActivePath.ts [new file with mode: 0644]
src/app/state/openedSidebarFolder.ts [new file with mode: 0644]
src/app/state/room-list/inviteList.ts [new file with mode: 0644]
src/app/state/room-list/mutedRoomList.ts [new file with mode: 0644]
src/app/state/room-list/roomList.ts [new file with mode: 0644]
src/app/state/room-list/utils.ts [new file with mode: 0644]
src/app/state/room/roomInputDrafts.ts [new file with mode: 0644]
src/app/state/room/roomToParents.ts [new file with mode: 0644]
src/app/state/room/roomToUnread.ts [new file with mode: 0644]
src/app/state/roomInputDrafts.ts [deleted file]
src/app/state/roomList.ts [deleted file]
src/app/state/roomToParents.ts [deleted file]
src/app/state/roomToUnread.ts [deleted file]
src/app/state/selectedRoom.ts [deleted file]
src/app/state/selectedTab.ts [deleted file]
src/app/state/spaceRooms.ts [new file with mode: 0644]
src/app/state/tabToRoom.ts [deleted file]
src/app/state/typingMembers.ts
src/app/state/utils.ts [deleted file]
src/app/styles/ContainerColor.css.ts [new file with mode: 0644]
src/app/styles/CustomHtml.css.ts
src/app/templates/client/Client.jsx
src/app/templates/client/Client.scss
src/app/templates/client/ClientContent.jsx
src/app/utils/ASCIILexicalTable.ts [new file with mode: 0644]
src/app/utils/common.ts
src/app/utils/keyboard.ts
src/app/utils/matrix.ts
src/app/utils/regex.ts
src/app/utils/room.ts
src/app/utils/sort.ts [new file with mode: 0644]
src/client/action/navigation.js
src/client/event/hotkeys.js
src/client/initMatrix.js
src/client/state/navigation.js
src/index.scss
src/types/matrix/room.ts
vite.config.js

diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644 (file)
index 0000000..36101fb
--- /dev/null
@@ -0,0 +1,64 @@
+module.exports = {
+  env: {
+    browser: true,
+    es2021: true,
+  },
+  extends: [
+    "eslint:recommended",
+    "plugin:react/recommended",
+    "plugin:react-hooks/recommended",
+    "plugin:@typescript-eslint/eslint-recommended",
+    "plugin:@typescript-eslint/recommended",
+    'airbnb',
+    'prettier',
+  ],
+  parser: "@typescript-eslint/parser",
+  parserOptions: {
+    ecmaFeatures: {
+      jsx: true,
+    },
+    ecmaVersion: 'latest',
+    sourceType: 'module',
+  },
+  "globals": {
+    JSX: "readonly"
+  },
+  plugins: [
+    'react',
+    '@typescript-eslint'
+  ],
+  rules: {
+    'linebreak-style': 0,
+    'no-underscore-dangle': 0,
+    "no-shadow": "off",
+
+    "import/prefer-default-export": "off",
+    "import/extensions": "off",
+    "import/no-unresolved": "off",
+    "import/no-extraneous-dependencies": [
+      "error",
+      {
+        devDependencies: true,
+      },
+    ],
+
+    'react/no-unstable-nested-components': [
+      'error',
+      { allowAsProps: true },
+    ],
+    "react/jsx-filename-extension": [
+      "error",
+      {
+        extensions: [".tsx", ".jsx"],
+      },
+    ],
+
+    "react/require-default-props": "off",
+    "react/jsx-props-no-spreading": "off",
+    "react-hooks/rules-of-hooks": "error",
+    "react-hooks/exhaustive-deps": "error",
+
+    "@typescript-eslint/no-unused-vars": "error",
+    "@typescript-eslint/no-shadow": "error"
+  },
+};
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644 (file)
index 36101fb..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-module.exports = {
-  env: {
-    browser: true,
-    es2021: true,
-  },
-  extends: [
-    "eslint:recommended",
-    "plugin:react/recommended",
-    "plugin:react-hooks/recommended",
-    "plugin:@typescript-eslint/eslint-recommended",
-    "plugin:@typescript-eslint/recommended",
-    'airbnb',
-    'prettier',
-  ],
-  parser: "@typescript-eslint/parser",
-  parserOptions: {
-    ecmaFeatures: {
-      jsx: true,
-    },
-    ecmaVersion: 'latest',
-    sourceType: 'module',
-  },
-  "globals": {
-    JSX: "readonly"
-  },
-  plugins: [
-    'react',
-    '@typescript-eslint'
-  ],
-  rules: {
-    'linebreak-style': 0,
-    'no-underscore-dangle': 0,
-    "no-shadow": "off",
-
-    "import/prefer-default-export": "off",
-    "import/extensions": "off",
-    "import/no-unresolved": "off",
-    "import/no-extraneous-dependencies": [
-      "error",
-      {
-        devDependencies: true,
-      },
-    ],
-
-    'react/no-unstable-nested-components': [
-      'error',
-      { allowAsProps: true },
-    ],
-    "react/jsx-filename-extension": [
-      "error",
-      {
-        extensions: [".tsx", ".jsx"],
-      },
-    ],
-
-    "react/require-default-props": "off",
-    "react/jsx-props-no-spreading": "off",
-    "react-hooks/rules-of-hooks": "error",
-    "react-hooks/exhaustive-deps": "error",
-
-    "@typescript-eslint/no-unused-vars": "error",
-    "@typescript-eslint/no-shadow": "error"
-  },
-};
index 484c7cd783f6221790f155702c27bad0b68bd037..762c3a3bc07b4a3223a9dbadf991628de2aea5c1 100644 (file)
   ],
   "allowCustomHomeservers": true,
 
+  "featuredCommunities": {
+    "openAsDefault": false,
+    "spaces": [
+      "#cinny-space:matrix.org",
+      "#community:matrix.org",
+      "#space:envs.net",
+      "#science-space:matrix.org",
+      "#libregaming-games:tchncs.de",
+      "#mathematics-on:matrix.org"
+    ],
+    "rooms": [
+      "#cinny:matrix.org",
+      "#foundation-office:matrix.org",
+      "#thisweekinmatrix:matrix.org",
+      "#matrix-dev:matrix.org",
+      "#matrix:matrix.org"
+    ],
+    "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
+  },
+
   "hashRouter": {
     "enabled": false,
     "basename": "/"
index e7d948e62f92d2780616e75b1c11a3f0a7dd785b..d79aa91c32c024324eb107cc9c5331ba7565c2c9 100644 (file)
@@ -9,9 +9,10 @@
   status = 200
   
 [[redirects]]
-  from = "/olm.wasm"
+  from = "*/olm.wasm"
   to = "/olm.wasm"
   status = 200
+  force = true
   
 [[redirects]]
   from = "/pdf.worker.min.js"
@@ -31,4 +32,5 @@
 [[redirects]]
   from = "/*"
   to = "/index.html"
-  status = 200
\ No newline at end of file
+  status = 200
+  force = true
\ No newline at end of file
index 2eec29124df81df4c2b4ccb7cfdd12e243277b04..17e4dd509b2f8525d3aa6269445c6b056d749f78 100644 (file)
@@ -9,10 +9,15 @@
       "version": "3.2.0",
       "license": "AGPL-3.0-only",
       "dependencies": {
+        "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
+        "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
+        "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
         "@fontsource/inter": "4.5.14",
         "@khanacademy/simple-markdown": "0.8.6",
         "@matrix-org/olm": "3.2.14",
-        "@tanstack/react-virtual": "3.0.0-beta.54",
+        "@tanstack/react-query": "5.24.1",
+        "@tanstack/react-query-devtools": "5.24.1",
+        "@tanstack/react-virtual": "3.2.0",
         "@tippyjs/react": "4.2.6",
         "@vanilla-extract/css": "1.9.3",
         "@vanilla-extract/recipes": "0.3.0",
@@ -29,7 +34,7 @@
         "file-saver": "2.0.5",
         "flux": "4.0.3",
         "focus-trap-react": "10.0.2",
-        "folds": "1.5.1",
+        "folds": "2.0.0",
         "formik": "2.2.9",
         "html-dom-parser": "4.0.0",
         "html-react-parser": "4.2.0",
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@atlaskit/pragmatic-drag-and-drop": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.6.tgz",
+      "integrity": "sha512-+jGspaRMyHWB6g9w+N1KImS5I+xt0ML89pwUyCueEhf2KGsl6zyH9ZxjTVKfrbY89FyZvuuXT9oFRHTUKGBi/w==",
+      "dependencies": {
+        "@babel/runtime": "^7.0.0",
+        "bind-event-listener": "^3.0.0",
+        "raf-schd": "^4.0.3"
+      }
+    },
+    "node_modules/@atlaskit/pragmatic-drag-and-drop-auto-scroll": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-1.3.0.tgz",
+      "integrity": "sha512-8wjKAI5qSrLojt8ZJ2WhoS5P75oBu5g0yMpAnTDgfqFyQnkt5Uc1txCRWpG26SS1mv19nm8ak9XHF2DOugVfpw==",
+      "dependencies": {
+        "@atlaskit/pragmatic-drag-and-drop": "^1.1.0",
+        "@babel/runtime": "^7.0.0"
+      }
+    },
+    "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz",
+      "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==",
+      "dependencies": {
+        "@atlaskit/pragmatic-drag-and-drop": "^1.1.0",
+        "@babel/runtime": "^7.0.0"
+      }
+    },
     "node_modules/@babel/code-frame": {
       "version": "7.23.4",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
         "@swc/counter": "^0.1.3"
       }
     },
+    "node_modules/@tanstack/query-core": {
+      "version": "5.24.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz",
+      "integrity": "sha512-DZ6Nx9p7BhjkG50ayJ+MKPgff+lMeol7QYXkvuU5jr2ryW/4ok5eanaS9W5eooA4xN0A/GPHdLGOZGzArgf5Cg==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
+    "node_modules/@tanstack/query-devtools": {
+      "version": "5.24.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.24.0.tgz",
+      "integrity": "sha512-pThim455t69zrZaQKa7IRkEIK8UBTS+gHVAdNfhO72Xh4rWpMc63ovRje5/n6iw63+d6QiJzVadsJVdPoodSeQ==",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
+    "node_modules/@tanstack/react-query": {
+      "version": "5.24.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.24.1.tgz",
+      "integrity": "sha512-4+09JEdO4d6+Gc8Y/g2M/MuxDK5IY0QV8+2wL2304wPKJgJ54cBbULd3nciJ5uvh/as8rrxx6s0mtIwpRuGd1g==",
+      "dependencies": {
+        "@tanstack/query-core": "5.24.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^18.0.0"
+      }
+    },
+    "node_modules/@tanstack/react-query-devtools": {
+      "version": "5.24.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.24.1.tgz",
+      "integrity": "sha512-qa4SEugN+EF8JJXcpsM9Lu05HfUv5cvHvLuB0uw/81eJZyNHFdtHFBi5RLCgpBrOyVMDfH8UQ3VBMqXzFKV68A==",
+      "dependencies": {
+        "@tanstack/query-devtools": "5.24.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "@tanstack/react-query": "^5.24.1",
+        "react": "^18.0.0"
+      }
+    },
     "node_modules/@tanstack/react-virtual": {
-      "version": "3.0.0-beta.54",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.54.tgz",
-      "integrity": "sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==",
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz",
+      "integrity": "sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==",
       "dependencies": {
-        "@tanstack/virtual-core": "3.0.0-beta.54"
+        "@tanstack/virtual-core": "3.2.0"
       },
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
       },
       "peerDependencies": {
-        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
       }
     },
     "node_modules/@tanstack/virtual-core": {
-      "version": "3.0.0-beta.54",
-      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.54.tgz",
-      "integrity": "sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g==",
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz",
+      "integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==",
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
         "node": ">=8"
       }
     },
+    "node_modules/bind-event-listener": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
+      "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q=="
+    },
     "node_modules/blurhash": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.4.tgz",
       }
     },
     "node_modules/folds": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.1.tgz",
-      "integrity": "sha512-2QxyA+FRKjPKXDTMDoD7NmOUiReWrKYO0Msg44QqlzTkTrRVEzJgyPIfC/Ia4/u0ByQpk6dbq8UQxomKmneJ/g==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/folds/-/folds-2.0.0.tgz",
+      "integrity": "sha512-lKv31vij4GEpEzGKWk5c3ar78fMZ9Di5n1XFR14Z2wnnpqhiiM5JTIzr127Gk5dOfy4mJkjnv/ZfMZvM2k+OQg==",
       "peerDependencies": {
         "@vanilla-extract/css": "^1.9.2",
         "@vanilla-extract/recipes": "^0.3.0",
         }
       ]
     },
+    "node_modules/raf-schd": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
+      "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
+    },
     "node_modules/react": {
       "version": "18.2.0",
       "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
index 44c28d348b0b94c434d626af510f77d1b485ae9e..e626e83738cb1b398d6f40752b0f6280d66d4b31 100644 (file)
   "author": "Ajay Bura",
   "license": "AGPL-3.0-only",
   "dependencies": {
+    "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
+    "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
+    "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
     "@fontsource/inter": "4.5.14",
     "@khanacademy/simple-markdown": "0.8.6",
     "@matrix-org/olm": "3.2.14",
-    "@tanstack/react-virtual": "3.0.0-beta.54",
+    "@tanstack/react-query": "5.24.1",
+    "@tanstack/react-query-devtools": "5.24.1",
+    "@tanstack/react-virtual": "3.2.0",
     "@tippyjs/react": "4.2.6",
     "@vanilla-extract/css": "1.9.3",
     "@vanilla-extract/recipes": "0.3.0",
@@ -40,7 +45,7 @@
     "file-saver": "2.0.5",
     "flux": "4.0.3",
     "focus-trap-react": "10.0.2",
-    "folds": "1.5.1",
+    "folds": "2.0.0",
     "formik": "2.2.9",
     "html-dom-parser": "4.0.0",
     "html-react-parser": "4.2.0",
diff --git a/src/app/components/CapabilitiesAndMediaConfigLoader.tsx b/src/app/components/CapabilitiesAndMediaConfigLoader.tsx
new file mode 100644 (file)
index 0000000..338e528
--- /dev/null
@@ -0,0 +1,36 @@
+import { ReactNode, useCallback, useEffect } from 'react';
+import { Capabilities } from 'matrix-js-sdk';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { MediaConfig } from '../hooks/useMediaConfig';
+import { promiseFulfilledResult } from '../utils/common';
+
+type CapabilitiesAndMediaConfigLoaderProps = {
+  children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
+};
+export function CapabilitiesAndMediaConfigLoader({
+  children,
+}: CapabilitiesAndMediaConfigLoaderProps) {
+  const mx = useMatrixClient();
+
+  const [state, load] = useAsyncCallback<
+    [Capabilities | undefined, MediaConfig | undefined],
+    unknown,
+    []
+  >(
+    useCallback(async () => {
+      const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
+      const capabilities = promiseFulfilledResult(result[0]);
+      const mediaConfig = promiseFulfilledResult(result[1]);
+      return [capabilities, mediaConfig];
+    }, [mx])
+  );
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  const [capabilities, mediaConfig] =
+    state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
+  return children(capabilities, mediaConfig);
+}
diff --git a/src/app/components/CapabilitiesLoader.tsx b/src/app/components/CapabilitiesLoader.tsx
new file mode 100644 (file)
index 0000000..dad59ec
--- /dev/null
@@ -0,0 +1,19 @@
+import { ReactNode, useCallback, useEffect } from 'react';
+import { Capabilities } from 'matrix-js-sdk';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+
+type CapabilitiesLoaderProps = {
+  children: (capabilities: Capabilities | undefined) => ReactNode;
+};
+export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
+  const mx = useMatrixClient();
+
+  const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  return children(state.status === AsyncStatus.Success ? state.data : undefined);
+}
diff --git a/src/app/components/MediaConfigLoader.tsx b/src/app/components/MediaConfigLoader.tsx
new file mode 100644 (file)
index 0000000..9fd9933
--- /dev/null
@@ -0,0 +1,19 @@
+import { ReactNode, useCallback, useEffect } from 'react';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { MediaConfig } from '../hooks/useMediaConfig';
+
+type MediaConfigLoaderProps = {
+  children: (mediaConfig: MediaConfig | undefined) => ReactNode;
+};
+export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
+  const mx = useMatrixClient();
+
+  const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  return children(state.status === AsyncStatus.Success ? state.data : undefined);
+}
index c440cce9b8f758dc83844e8a8caeabe502d23289..a78c13f2a1c7e37c8646b33614970abb53f3d4d9 100644 (file)
@@ -1,6 +1,6 @@
 /* eslint-disable no-param-reassign */
 /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
-import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
+import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
 import classNames from 'classnames';
 import {
   Box,
@@ -13,6 +13,7 @@ import {
   Input,
   Menu,
   PopOut,
+  RectCords,
   Scroll,
   Spinner,
   Text,
@@ -48,7 +49,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
     const isError =
       pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
     const [pageNo, setPageNo] = useState(1);
-    const [openJump, setOpenJump] = useState(false);
+    const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
 
     useEffect(() => {
       loadPdfJS();
@@ -86,7 +87,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
       if (!jumpInput) return;
       const jumpTo = parseInt(jumpInput.value, 10);
       setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
-      setOpenJump(false);
+      setJumpAnchor(undefined);
     };
 
     const handlePrevPage = () => {
@@ -98,6 +99,10 @@ export const PdfViewer = as<'div', PdfViewerProps>(
       setPageNo((n) => Math.min(n + 1, docState.data.numPages));
     };
 
+    const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
+      setJumpAnchor(evt.currentTarget.getBoundingClientRect());
+    };
+
     return (
       <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
         <Header className={css.PdfViewerHeader} size="400">
@@ -187,14 +192,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
             </Chip>
             <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
               <PopOut
-                open={openJump}
+                anchor={jumpAnchor}
                 align="Center"
                 position="Top"
                 content={
                   <FocusTrap
                     focusTrapOptions={{
                       initialFocus: false,
-                      onDeactivate: () => setOpenJump(false),
+                      onDeactivate: () => setJumpAnchor(undefined),
                       clickOutsideDeactivates: true,
                     }}
                   >
@@ -227,17 +232,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
                   </FocusTrap>
                 }
               >
-                {(anchorRef) => (
-                  <Chip
-                    onClick={() => setOpenJump(!openJump)}
-                    ref={anchorRef}
-                    variant="SurfaceVariant"
-                    radii="300"
-                    aria-pressed={openJump}
-                  >
-                    <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
-                  </Chip>
-                )}
+                <Chip
+                  onClick={handleOpenJump}
+                  variant="SurfaceVariant"
+                  radii="300"
+                  aria-pressed={jumpAnchor !== undefined}
+                >
+                  <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
+                </Chip>
               </PopOut>
             </Box>
             <Chip
diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
new file mode 100644 (file)
index 0000000..60e0331
--- /dev/null
@@ -0,0 +1,245 @@
+import React from 'react';
+import { MsgType } from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import {
+  AudioContent,
+  DownloadFile,
+  FileContent,
+  ImageContent,
+  MAudio,
+  MBadEncrypted,
+  MEmote,
+  MFile,
+  MImage,
+  MLocation,
+  MNotice,
+  MText,
+  MVideo,
+  ReadPdfFile,
+  ReadTextFile,
+  RenderBody,
+  ThumbnailContent,
+  UnsupportedContent,
+  VideoContent,
+} from './message';
+import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
+import { Image, MediaControl, Video } from './media';
+import { ImageViewer } from './image-viewer';
+import { PdfViewer } from './Pdf-viewer';
+import { TextViewer } from './text-viewer';
+
+type RenderMessageContentProps = {
+  displayName: string;
+  msgType: string;
+  ts: number;
+  edited?: boolean;
+  getContent: <T>() => T;
+  mediaAutoLoad?: boolean;
+  urlPreview?: boolean;
+  highlightRegex?: RegExp;
+  htmlReactParserOptions: HTMLReactParserOptions;
+  outlineAttachment?: boolean;
+};
+export function RenderMessageContent({
+  displayName,
+  msgType,
+  ts,
+  edited,
+  getContent,
+  mediaAutoLoad,
+  urlPreview,
+  highlightRegex,
+  htmlReactParserOptions,
+  outlineAttachment,
+}: RenderMessageContentProps) {
+  const renderFile = () => (
+    <MFile
+      content={getContent()}
+      renderFileContent={({ body, mimeType, info, encInfo, url }) => (
+        <FileContent
+          body={body}
+          mimeType={mimeType}
+          renderAsPdfFile={() => (
+            <ReadPdfFile
+              body={body}
+              mimeType={mimeType}
+              url={url}
+              encInfo={encInfo}
+              renderViewer={(p) => <PdfViewer {...p} />}
+            />
+          )}
+          renderAsTextFile={() => (
+            <ReadTextFile
+              body={body}
+              mimeType={mimeType}
+              url={url}
+              encInfo={encInfo}
+              renderViewer={(p) => <TextViewer {...p} />}
+            />
+          )}
+        >
+          <DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
+        </FileContent>
+      )}
+      outlined={outlineAttachment}
+    />
+  );
+
+  if (msgType === MsgType.Text) {
+    return (
+      <MText
+        edited={edited}
+        content={getContent()}
+        renderBody={(props) => (
+          <RenderBody
+            {...props}
+            highlightRegex={highlightRegex}
+            htmlReactParserOptions={htmlReactParserOptions}
+          />
+        )}
+        renderUrlsPreview={
+          urlPreview
+            ? (urls) => (
+                <UrlPreviewHolder>
+                  {urls.map((url) => (
+                    <UrlPreviewCard key={url} url={url} ts={ts} />
+                  ))}
+                </UrlPreviewHolder>
+              )
+            : undefined
+        }
+      />
+    );
+  }
+
+  if (msgType === MsgType.Emote) {
+    return (
+      <MEmote
+        displayName={displayName}
+        edited={edited}
+        content={getContent()}
+        renderBody={(props) => (
+          <RenderBody
+            {...props}
+            highlightRegex={highlightRegex}
+            htmlReactParserOptions={htmlReactParserOptions}
+          />
+        )}
+        renderUrlsPreview={
+          urlPreview
+            ? (urls) => (
+                <UrlPreviewHolder>
+                  {urls.map((url) => (
+                    <UrlPreviewCard key={url} url={url} ts={ts} />
+                  ))}
+                </UrlPreviewHolder>
+              )
+            : undefined
+        }
+      />
+    );
+  }
+
+  if (msgType === MsgType.Notice) {
+    return (
+      <MNotice
+        edited={edited}
+        content={getContent()}
+        renderBody={(props) => (
+          <RenderBody
+            {...props}
+            highlightRegex={highlightRegex}
+            htmlReactParserOptions={htmlReactParserOptions}
+          />
+        )}
+        renderUrlsPreview={
+          urlPreview
+            ? (urls) => (
+                <UrlPreviewHolder>
+                  {urls.map((url) => (
+                    <UrlPreviewCard key={url} url={url} ts={ts} />
+                  ))}
+                </UrlPreviewHolder>
+              )
+            : undefined
+        }
+      />
+    );
+  }
+
+  if (msgType === MsgType.Image) {
+    return (
+      <MImage
+        content={getContent()}
+        renderImageContent={(props) => (
+          <ImageContent
+            {...props}
+            autoPlay={mediaAutoLoad}
+            renderImage={(p) => <Image {...p} loading="lazy" />}
+            renderViewer={(p) => <ImageViewer {...p} />}
+          />
+        )}
+        outlined={outlineAttachment}
+      />
+    );
+  }
+
+  if (msgType === MsgType.Video) {
+    return (
+      <MVideo
+        content={getContent()}
+        renderAsFile={renderFile}
+        renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
+          <VideoContent
+            body={body}
+            info={info}
+            mimeType={mimeType}
+            url={url}
+            encInfo={encInfo}
+            renderThumbnail={
+              mediaAutoLoad
+                ? () => (
+                    <ThumbnailContent
+                      info={info}
+                      renderImage={(src) => (
+                        <Image alt={body} title={body} src={src} loading="lazy" />
+                      )}
+                    />
+                  )
+                : undefined
+            }
+            renderVideo={(p) => <Video {...p} />}
+          />
+        )}
+        outlined={outlineAttachment}
+      />
+    );
+  }
+
+  if (msgType === MsgType.Audio) {
+    return (
+      <MAudio
+        content={getContent()}
+        renderAsFile={renderFile}
+        renderAudioContent={(props) => (
+          <AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
+        )}
+        outlined={outlineAttachment}
+      />
+    );
+  }
+
+  if (msgType === MsgType.File) {
+    return renderFile();
+  }
+
+  if (msgType === MsgType.Location) {
+    return <MLocation content={getContent()} />;
+  }
+
+  if (msgType === 'm.bad.encrypted') {
+    return <MBadEncrypted />;
+  }
+
+  return <UnsupportedContent />;
+}
diff --git a/src/app/components/RoomSummaryLoader.tsx b/src/app/components/RoomSummaryLoader.tsx
new file mode 100644 (file)
index 0000000..efdce93
--- /dev/null
@@ -0,0 +1,90 @@
+import { ReactNode, useCallback, useState } from 'react';
+import { MatrixClient, Room } from 'matrix-js-sdk';
+import { useQuery } from '@tanstack/react-query';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
+import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
+
+export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
+
+type RoomSummaryLoaderProps = {
+  roomIdOrAlias: string;
+  children: (roomSummary?: IRoomSummary) => ReactNode;
+};
+
+export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
+  const mx = useMatrixClient();
+
+  const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
+
+  const { data } = useQuery({
+    queryKey: [roomIdOrAlias, `summary`],
+    queryFn: fetchSummary,
+  });
+
+  return children(data);
+}
+
+export function LocalRoomSummaryLoader({
+  room,
+  children,
+}: {
+  room: Room;
+  children: (roomSummary: LocalRoomSummary) => ReactNode;
+}) {
+  const summary = useLocalRoomSummary(room);
+
+  return children(summary);
+}
+
+export function HierarchyRoomSummaryLoader({
+  roomId,
+  children,
+}: {
+  roomId: string;
+  children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
+}) {
+  const mx = useMatrixClient();
+
+  const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
+  const [errorMemo, setError] = useState<Error>();
+
+  const { data, error } = useQuery({
+    queryKey: [roomId, `hierarchy`],
+    queryFn: fetchSummary,
+    retryOnMount: false,
+    refetchOnWindowFocus: false,
+    retry: (failureCount, err) => {
+      setError(err);
+      if (failureCount > 3) return false;
+      return true;
+    },
+  });
+
+  let state: AsyncState<IHierarchyRoom, Error> = {
+    status: AsyncStatus.Loading,
+  };
+  if (error) {
+    state = {
+      status: AsyncStatus.Error,
+      error,
+    };
+  }
+  if (errorMemo) {
+    state = {
+      status: AsyncStatus.Error,
+      error: errorMemo,
+    };
+  }
+
+  const summary = data?.rooms[0] ?? undefined;
+  if (summary) {
+    state = {
+      status: AsyncStatus.Success,
+      data: summary,
+    };
+  }
+
+  return children(state);
+}
diff --git a/src/app/components/RoomUnreadProvider.tsx b/src/app/components/RoomUnreadProvider.tsx
new file mode 100644 (file)
index 0000000..43dfd48
--- /dev/null
@@ -0,0 +1,24 @@
+import { ReactElement } from 'react';
+import { Unread } from '../../types/matrix/room';
+import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
+import { roomToUnreadAtom } from '../state/room/roomToUnread';
+
+type RoomUnreadProviderProps = {
+  roomId: string;
+  children: (unread?: Unread) => ReactElement;
+};
+export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
+  const unread = useRoomUnread(roomId, roomToUnreadAtom);
+
+  return children(unread);
+}
+
+type RoomsUnreadProviderProps = {
+  rooms: string[];
+  children: (unread?: Unread) => ReactElement;
+};
+export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
+  const unread = useRoomsUnread(rooms, roomToUnreadAtom);
+
+  return children(unread);
+}
diff --git a/src/app/components/SpaceChildDirectsProvider.tsx b/src/app/components/SpaceChildDirectsProvider.tsx
new file mode 100644 (file)
index 0000000..203363e
--- /dev/null
@@ -0,0 +1,28 @@
+import { ReactNode } from 'react';
+import { RoomToParents } from '../../types/matrix/room';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { useChildDirectScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
+
+type SpaceChildDirectsProviderProps = {
+  spaceId: string;
+  mDirects: Set<string>;
+  roomToParents: RoomToParents;
+  children: (rooms: string[]) => ReactNode;
+};
+export function SpaceChildDirectsProvider({
+  spaceId,
+  roomToParents,
+  mDirects,
+  children,
+}: SpaceChildDirectsProviderProps) {
+  const mx = useMatrixClient();
+
+  const childDirects = useSpaceChildren(
+    allRoomsAtom,
+    spaceId,
+    useChildDirectScopeFactory(mx, mDirects, roomToParents)
+  );
+
+  return children(childDirects);
+}
diff --git a/src/app/components/SpaceChildRoomsProvider.tsx b/src/app/components/SpaceChildRoomsProvider.tsx
new file mode 100644 (file)
index 0000000..65ed03b
--- /dev/null
@@ -0,0 +1,28 @@
+import { ReactNode } from 'react';
+import { RoomToParents } from '../../types/matrix/room';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { useChildRoomScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
+
+type SpaceChildRoomsProviderProps = {
+  spaceId: string;
+  mDirects: Set<string>;
+  roomToParents: RoomToParents;
+  children: (rooms: string[]) => ReactNode;
+};
+export function SpaceChildRoomsProvider({
+  spaceId,
+  roomToParents,
+  mDirects,
+  children,
+}: SpaceChildRoomsProviderProps) {
+  const mx = useMatrixClient();
+
+  const childRooms = useSpaceChildren(
+    allRoomsAtom,
+    spaceId,
+    useChildRoomScopeFactory(mx, mDirects, roomToParents)
+  );
+
+  return children(childRooms);
+}
index 56d7f8b055b5f6a1810480bf3d3cae22c725b068..5ee47bdfdceb33e83ca6ef278fef7960e7bb9010 100644 (file)
@@ -1,20 +1,25 @@
-import { ReactNode, useCallback, useEffect } from 'react';
+import { ReactNode, useCallback, useEffect, useState } from 'react';
 import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 import { SpecVersions, specVersions } from '../cs-api';
-import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
 
 type SpecVersionsLoaderProps = {
+  baseUrl: string;
   fallback?: () => ReactNode;
-  error?: (err: unknown) => ReactNode;
+  error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
   children: (versions: SpecVersions) => ReactNode;
 };
-export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
-  const autoDiscoveryInfo = useAutoDiscoveryInfo();
-  const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
-
+export function SpecVersionsLoader({
+  baseUrl,
+  fallback,
+  error,
+  children,
+}: SpecVersionsLoaderProps) {
   const [state, load] = useAsyncCallback(
     useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
   );
+  const [ignoreError, setIgnoreError] = useState(false);
+
+  const ignoreCallback = useCallback(() => setIgnoreError(true), []);
 
   useEffect(() => {
     load();
@@ -24,9 +29,15 @@ export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLo
     return fallback?.();
   }
 
-  if (state.status === AsyncStatus.Error) {
-    return error?.(state.error);
+  if (!ignoreError && state.status === AsyncStatus.Error) {
+    return error?.(state.error, load, ignoreCallback);
   }
 
-  return children(state.data);
+  return children(
+    state.status === AsyncStatus.Success
+      ? state.data
+      : {
+          versions: [],
+        }
+  );
 }
index 5d5e98943fe3f7a7a2c82de0a862e8fd4d790408..0c82855d3cd7d4c6a2ec3f6f201ed6db97ecefb1 100644 (file)
@@ -10,13 +10,14 @@ import {
   Line,
   Menu,
   PopOut,
+  RectCords,
   Scroll,
   Text,
   Tooltip,
   TooltipProvider,
   toRem,
 } from 'folds';
-import React, { ReactNode, useState } from 'react';
+import React, { MouseEventHandler, ReactNode, useState } from 'react';
 import { ReactEditor, useSlate } from 'slate-react';
 import {
   headingLevel,
@@ -119,26 +120,33 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
 export function HeadingBlockButton() {
   const editor = useSlate();
   const level = headingLevel(editor);
-  const [open, setOpen] = useState(false);
+  const [anchor, setAnchor] = useState<RectCords>();
   const isActive = isBlockActive(editor, BlockType.Heading);
   const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
 
   const handleMenuSelect = (selectedLevel: HeadingLevel) => {
-    setOpen(false);
+    setAnchor(undefined);
     toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
     ReactEditor.focus(editor);
   };
 
+  const handleMenuOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    if (isActive) {
+      toggleBlock(editor, BlockType.Heading);
+      return;
+    }
+    setAnchor(evt.currentTarget.getBoundingClientRect());
+  };
   return (
     <PopOut
-      open={open}
+      anchor={anchor}
       offset={5}
       position="Top"
       content={
         <FocusTrap
           focusTrapOptions={{
             initialFocus: false,
-            onDeactivate: () => setOpen(false),
+            onDeactivate: () => setAnchor(undefined),
             clickOutsideDeactivates: true,
             isKeyForward: (evt: KeyboardEvent) =>
               evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -197,20 +205,17 @@ export function HeadingBlockButton() {
         </FocusTrap>
       }
     >
-      {(ref) => (
-        <IconButton
-          style={{ width: 'unset' }}
-          ref={ref}
-          variant="SurfaceVariant"
-          onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
-          aria-pressed={isActive}
-          size="400"
-          radii="300"
-        >
-          <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
-          <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
-        </IconButton>
-      )}
+      <IconButton
+        style={{ width: 'unset' }}
+        variant="SurfaceVariant"
+        onClick={handleMenuOpen}
+        aria-pressed={isActive}
+        size="400"
+        radii="300"
+      >
+        <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
+        <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
+      </IconButton>
     </PopOut>
   );
 }
index b9fee8788f6555ecf459ee855aae458d8bae1980..439d98ca0956fc158058663f7065e7d746d739c8 100644 (file)
@@ -1,12 +1,11 @@
-import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
+import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
 import { Editor } from 'slate';
-import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
-import { MatrixClient } from 'matrix-js-sdk';
+import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
+import { JoinRule, MatrixClient } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
 
 import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
-import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
-import { roomIdByActivity } from '../../../../util/sort';
-import initMatrix from '../../../../client/initMatrix';
+import { getDirectRoomAvatarUrl } from '../../../utils/room';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { AutocompleteQuery } from './autocompleteQuery';
 import { AutocompleteMenu } from './AutocompleteMenu';
@@ -14,6 +13,10 @@ import { getMxIdServer, validMxId } from '../../../utils/matrix';
 import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
 import { onTabPress } from '../../../utils/keyboard';
 import { useKeyDown } from '../../../hooks/useKeyDown';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { factoryRoomIdByActivity } from '../../../utils/sort';
+import { RoomAvatar, RoomIcon } from '../../room-avatar';
 
 type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
 
@@ -74,15 +77,12 @@ export function RoomMentionAutocomplete({
   requestClose,
 }: RoomMentionAutocompleteProps) {
   const mx = useMatrixClient();
-  const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
+  const mDirects = useAtomValue(mDirectAtom);
 
-  const allRoomId: string[] = useMemo(() => {
-    const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
-    return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
-  }, []);
+  const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
 
   const [result, search, resetSearch] = useAsyncSearch(
-    allRoomId,
+    allRooms,
     useCallback(
       (rId) => {
         const r = mx.getRoom(rId);
@@ -96,7 +96,7 @@ export function RoomMentionAutocomplete({
     SEARCH_OPTIONS
   );
 
-  const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
+  const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
 
   useEffect(() => {
     if (query.text) search(query.text);
@@ -136,9 +136,7 @@ export function RoomMentionAutocomplete({
         autoCompleteRoomIds.map((rId) => {
           const room = mx.getRoom(rId);
           if (!room) return null;
-          const dm = dms.has(room.roomId);
-          const avatarUrl = getRoomAvatarUrl(mx, room);
-          const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
+          const dm = mDirects.has(room.roomId);
 
           const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
 
@@ -158,17 +156,21 @@ export function RoomMentionAutocomplete({
               }
               before={
                 <Avatar size="200">
-                  {iconSrc && <Icon src={iconSrc} size="100" />}
-                  {avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
-                  {!avatarUrl && !iconSrc && (
-                    <AvatarFallback
-                      style={{
-                        backgroundColor: color.Secondary.Container,
-                        color: color.Secondary.OnContainer,
-                      }}
-                    >
-                      <Text size="H6">{room.name[0]}</Text>
-                    </AvatarFallback>
+                  {dm ? (
+                    <RoomAvatar
+                      roomId={room.roomId}
+                      src={getDirectRoomAvatarUrl(mx, room)}
+                      alt={room.name}
+                      renderFallback={() => (
+                        <RoomIcon
+                          size="50"
+                          joinRule={room.getJoinRule() ?? JoinRule.Restricted}
+                          filled
+                        />
+                      )}
+                    />
+                  ) : (
+                    <RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
                   )}
                 </Avatar>
               }
index 8c3d91bf1569a9d642b433a7e3da0161a57f6fc4..642ce50a38be02d916fda069283d433bf7235791 100644 (file)
@@ -1,6 +1,6 @@
 import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
 import { Editor } from 'slate';
-import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds';
+import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
 import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
 
 import { AutocompleteQuery } from './autocompleteQuery';
@@ -17,6 +17,7 @@ import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 import { useKeyDown } from '../../../hooks/useKeyDown';
 import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
+import { UserAvatar } from '../../user-avatar';
 
 type MentionAutoCompleteHandler = (userId: string, name: string) => void;
 
@@ -26,12 +27,10 @@ const userIdFromQueryText = (mx: MatrixClient, text: string) =>
     : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 
 function UnknownMentionItem({
-  query,
   userId,
   name,
   handleAutocomplete,
 }: {
-  query: AutocompleteQuery<string>;
   userId: string;
   name: string;
   handleAutocomplete: MentionAutoCompleteHandler;
@@ -46,14 +45,10 @@ function UnknownMentionItem({
       onClick={() => handleAutocomplete(userId, name)}
       before={
         <Avatar size="200">
-          <AvatarFallback
-            style={{
-              backgroundColor: color.Secondary.Container,
-              color: color.Secondary.OnContainer,
-            }}
-          >
-            <Text size="H6">{query.text[0]}</Text>
-          </AvatarFallback>
+          <UserAvatar
+            userId={userId}
+            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+          />
         </Avatar>
       }
     >
@@ -135,7 +130,6 @@ export function UserMentionAutocomplete({
     <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
       {query.text === 'room' && (
         <UnknownMentionItem
-          query={query}
           userId={roomAliasOrId}
           name="@room"
           handleAutocomplete={handleAutocomplete}
@@ -143,7 +137,6 @@ export function UserMentionAutocomplete({
       )}
       {autoCompleteMembers.length === 0 ? (
         <UnknownMentionItem
-          query={query}
           userId={userIdFromQueryText(mx, query.text)}
           name={userIdFromQueryText(mx, query.text)}
           handleAutocomplete={handleAutocomplete}
@@ -167,18 +160,12 @@ export function UserMentionAutocomplete({
               }
               before={
                 <Avatar size="200">
-                  {avatarUrl ? (
-                    <AvatarImage src={avatarUrl} alt={getName(roomMember)} />
-                  ) : (
-                    <AvatarFallback
-                      style={{
-                        backgroundColor: color.Secondary.Container,
-                        color: color.Secondary.OnContainer,
-                      }}
-                    >
-                      <Text size="H6">{getName(roomMember)[0]}</Text>
-                    </AvatarFallback>
-                  )}
+                  <UserAvatar
+                    userId={roomMember.userId}
+                    src={avatarUrl ?? undefined}
+                    alt={getName(roomMember)}
+                    renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+                  />
                 </Avatar>
               }
             >
index a5a2646f481559eee92a85804c60027651c34377..08e24a52b67305696fcd521637abc96b21151c1c 100644 (file)
@@ -2,8 +2,6 @@ import React from 'react';
 import classNames from 'classnames';
 import {
   Avatar,
-  AvatarFallback,
-  AvatarImage,
   Box,
   Header,
   Icon,
@@ -21,8 +19,8 @@ import { getMemberDisplayName } from '../../utils/room';
 import { getMxIdLocalPart } from '../../utils/matrix';
 import * as css from './EventReaders.css';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
-import colorMXID from '../../../util/colorMXID';
 import { openProfileViewer } from '../../../client/action/navigation';
+import { UserAvatar } from '../user-avatar';
 
 export type EventReadersProps = {
   room: Room;
@@ -72,18 +70,12 @@ export const EventReaders = as<'div', EventReadersProps>(
                     }}
                     before={
                       <Avatar size="200">
-                        {avatarUrl ? (
-                          <AvatarImage src={avatarUrl} />
-                        ) : (
-                          <AvatarFallback
-                            style={{
-                              background: colorMXID(readerId),
-                              color: 'white',
-                            }}
-                          >
-                            <Text size="H6">{name[0]}</Text>
-                          </AvatarFallback>
-                        )}
+                        <UserAvatar
+                          userId={readerId}
+                          src={avatarUrl ?? undefined}
+                          alt={name}
+                          renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+                        />
                       </Avatar>
                     }
                   >
diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
new file mode 100644 (file)
index 0000000..f4de9ed
--- /dev/null
@@ -0,0 +1,106 @@
+import React, { useCallback, useEffect } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+  Dialog,
+  Overlay,
+  OverlayCenter,
+  OverlayBackdrop,
+  Header,
+  config,
+  Box,
+  Text,
+  IconButton,
+  Icon,
+  Icons,
+  color,
+  Button,
+  Spinner,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+type LeaveRoomPromptProps = {
+  roomId: string;
+  onDone: () => void;
+  onCancel: () => void;
+};
+export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
+  const mx = useMatrixClient();
+
+  const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
+    useCallback(async () => {
+      mx.leave(roomId);
+    }, [mx, roomId])
+  );
+
+  const handleLeave = () => {
+    leaveRoom();
+  };
+
+  useEffect(() => {
+    if (leaveState.status === AsyncStatus.Success) {
+      onDone();
+    }
+  }, [leaveState, onDone]);
+
+  return (
+    <Overlay open backdrop={<OverlayBackdrop />}>
+      <OverlayCenter>
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: onCancel,
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Dialog variant="Surface">
+            <Header
+              style={{
+                padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                borderBottomWidth: config.borderWidth.B300,
+              }}
+              variant="Surface"
+              size="500"
+            >
+              <Box grow="Yes">
+                <Text size="H4">Leave Room</Text>
+              </Box>
+              <IconButton size="300" onClick={onCancel} radii="300">
+                <Icon src={Icons.Cross} />
+              </IconButton>
+            </Header>
+            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+              <Box direction="Column" gap="200">
+                <Text priority="400">Are you sure you want to leave this room?</Text>
+                {leaveState.status === AsyncStatus.Error && (
+                  <Text style={{ color: color.Critical.Main }} size="T300">
+                    Failed to leave room! {leaveState.error.message}
+                  </Text>
+                )}
+              </Box>
+              <Button
+                type="submit"
+                variant="Critical"
+                onClick={handleLeave}
+                before={
+                  leaveState.status === AsyncStatus.Loading ? (
+                    <Spinner fill="Solid" variant="Critical" size="200" />
+                  ) : undefined
+                }
+                aria-disabled={
+                  leaveState.status === AsyncStatus.Loading ||
+                  leaveState.status === AsyncStatus.Success
+                }
+              >
+                <Text size="B400">
+                  {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+                </Text>
+              </Button>
+            </Box>
+          </Dialog>
+        </FocusTrap>
+      </OverlayCenter>
+    </Overlay>
+  );
+}
diff --git a/src/app/components/leave-room-prompt/index.ts b/src/app/components/leave-room-prompt/index.ts
new file mode 100644 (file)
index 0000000..2f7d7e3
--- /dev/null
@@ -0,0 +1 @@
+export * from './LeaveRoomPrompt';
diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
new file mode 100644 (file)
index 0000000..1132b44
--- /dev/null
@@ -0,0 +1,106 @@
+import React, { useCallback, useEffect } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+  Dialog,
+  Overlay,
+  OverlayCenter,
+  OverlayBackdrop,
+  Header,
+  config,
+  Box,
+  Text,
+  IconButton,
+  Icon,
+  Icons,
+  color,
+  Button,
+  Spinner,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+type LeaveSpacePromptProps = {
+  roomId: string;
+  onDone: () => void;
+  onCancel: () => void;
+};
+export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
+  const mx = useMatrixClient();
+
+  const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
+    useCallback(async () => {
+      mx.leave(roomId);
+    }, [mx, roomId])
+  );
+
+  const handleLeave = () => {
+    leaveRoom();
+  };
+
+  useEffect(() => {
+    if (leaveState.status === AsyncStatus.Success) {
+      onDone();
+    }
+  }, [leaveState, onDone]);
+
+  return (
+    <Overlay open backdrop={<OverlayBackdrop />}>
+      <OverlayCenter>
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: onCancel,
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Dialog variant="Surface">
+            <Header
+              style={{
+                padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                borderBottomWidth: config.borderWidth.B300,
+              }}
+              variant="Surface"
+              size="500"
+            >
+              <Box grow="Yes">
+                <Text size="H4">Leave Space</Text>
+              </Box>
+              <IconButton size="300" onClick={onCancel} radii="300">
+                <Icon src={Icons.Cross} />
+              </IconButton>
+            </Header>
+            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+              <Box direction="Column" gap="200">
+                <Text priority="400">Are you sure you want to leave this space?</Text>
+                {leaveState.status === AsyncStatus.Error && (
+                  <Text style={{ color: color.Critical.Main }} size="T300">
+                    Failed to leave space! {leaveState.error.message}
+                  </Text>
+                )}
+              </Box>
+              <Button
+                type="submit"
+                variant="Critical"
+                onClick={handleLeave}
+                before={
+                  leaveState.status === AsyncStatus.Loading ? (
+                    <Spinner fill="Solid" variant="Critical" size="200" />
+                  ) : undefined
+                }
+                aria-disabled={
+                  leaveState.status === AsyncStatus.Loading ||
+                  leaveState.status === AsyncStatus.Success
+                }
+              >
+                <Text size="B400">
+                  {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
+                </Text>
+              </Button>
+            </Box>
+          </Dialog>
+        </FocusTrap>
+      </OverlayCenter>
+    </Overlay>
+  );
+}
diff --git a/src/app/components/leave-space-prompt/index.ts b/src/app/components/leave-space-prompt/index.ts
new file mode 100644 (file)
index 0000000..b533b84
--- /dev/null
@@ -0,0 +1 @@
+export * from './LeaveSpacePrompt';
diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx
new file mode 100644 (file)
index 0000000..947be90
--- /dev/null
@@ -0,0 +1,22 @@
+import { Badge, Box, Text, as, toRem } from 'folds';
+import React from 'react';
+import { mimeTypeToExt } from '../../utils/mimeTypes';
+
+const badgeStyles = { maxWidth: toRem(100) };
+
+export type FileHeaderProps = {
+  body: string;
+  mimeType: string;
+};
+export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
+  <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
+    <Badge style={badgeStyles} variant="Secondary" radii="Pill">
+      <Text size="O400" truncate>
+        {mimeTypeToExt(mimeType)}
+      </Text>
+    </Badge>
+    <Text size="T300" truncate>
+      {body}
+    </Text>
+  </Box>
+));
diff --git a/src/app/components/message/MessageContentFallback.tsx b/src/app/components/message/MessageContentFallback.tsx
deleted file mode 100644 (file)
index 9edb967..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Box, Icon, Icons, Text, as, color, config } from 'folds';
-import React from 'react';
-
-const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
-const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
-
-export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
-  ({ reason, ...props }, ref) => (
-    <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
-      <Icon size="50" src={Icons.Delete} />
-      {reason ? (
-        <i>This message has been deleted. {reason}</i>
-      ) : (
-        <i>This message has been deleted</i>
-      )}
-    </Box>
-  )
-);
-
-export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
-  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
-    <Icon size="50" src={Icons.Warning} />
-    <i>Unsupported message</i>
-  </Box>
-));
-
-export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
-  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
-    <Icon size="50" src={Icons.Warning} />
-    <i>Failed to load message</i>
-  </Box>
-));
-
-export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
-  <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
-    <Icon size="50" src={Icons.Lock} />
-    <i>Unable to decrypt message</i>
-  </Box>
-));
-
-export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
-  <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
-    <Icon size="50" src={Icons.Lock} />
-    <i>This message is not decrypted yet</i>
-  </Box>
-));
-
-export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
-  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
-    <Icon size="50" src={Icons.Warning} />
-    <i>Broken message</i>
-  </Box>
-));
-
-export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
-  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
-    <Icon size="50" src={Icons.Warning} />
-    <i>Empty message</i>
-  </Box>
-));
-
-export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
-  <Text as="span" size="T200" priority="300" {...props} ref={ref}>
-    {' (edited)'}
-  </Text>
-));
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
new file mode 100644 (file)
index 0000000..f7cbc48
--- /dev/null
@@ -0,0 +1,398 @@
+import React, { ReactNode } from 'react';
+import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
+import { IContent } from 'matrix-js-sdk';
+import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
+import { trimReplyFromBody } from '../../utils/room';
+import { MessageTextBody } from './layout';
+import {
+  MessageBadEncryptedContent,
+  MessageBrokenContent,
+  MessageDeletedContent,
+  MessageEditedContent,
+  MessageUnsupportedContent,
+} from './content';
+import {
+  IAudioContent,
+  IAudioInfo,
+  IEncryptedFile,
+  IFileContent,
+  IFileInfo,
+  IImageContent,
+  IImageInfo,
+  IThumbnailContent,
+  IVideoContent,
+  IVideoInfo,
+} from '../../../types/matrix/common';
+import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
+import { parseGeoUri, scaleYDimension } from '../../utils/common';
+import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
+import { FileHeader } from './FileHeader';
+
+export function MBadEncrypted() {
+  return (
+    <Text>
+      <MessageBadEncryptedContent />
+    </Text>
+  );
+}
+
+type RedactedContentProps = {
+  reason?: string;
+};
+export function RedactedContent({ reason }: RedactedContentProps) {
+  return (
+    <Text>
+      <MessageDeletedContent reason={reason} />
+    </Text>
+  );
+}
+
+export function UnsupportedContent() {
+  return (
+    <Text>
+      <MessageUnsupportedContent />
+    </Text>
+  );
+}
+
+export function BrokenContent() {
+  return (
+    <Text>
+      <MessageBrokenContent />
+    </Text>
+  );
+}
+
+type RenderBodyProps = {
+  body: string;
+  customBody?: string;
+};
+type MTextProps = {
+  edited?: boolean;
+  content: Record<string, unknown>;
+  renderBody: (props: RenderBodyProps) => ReactNode;
+  renderUrlsPreview?: (urls: string[]) => ReactNode;
+};
+export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextProps) {
+  const { body, formatted_body: customBody } = content;
+
+  if (typeof body !== 'string') return <BrokenContent />;
+  const trimmedBody = trimReplyFromBody(body);
+  const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
+  const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
+  return (
+    <>
+      <MessageTextBody
+        preWrap={typeof customBody !== 'string'}
+        jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+      >
+        {renderBody({
+          body: trimmedBody,
+          customBody: typeof customBody === 'string' ? customBody : undefined,
+        })}
+        {edited && <MessageEditedContent />}
+      </MessageTextBody>
+      {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
+    </>
+  );
+}
+
+type MEmoteProps = {
+  displayName: string;
+  edited?: boolean;
+  content: Record<string, unknown>;
+  renderBody: (props: RenderBodyProps) => ReactNode;
+  renderUrlsPreview?: (urls: string[]) => ReactNode;
+};
+export function MEmote({
+  displayName,
+  edited,
+  content,
+  renderBody,
+  renderUrlsPreview,
+}: MEmoteProps) {
+  const { body, formatted_body: customBody } = content;
+
+  if (typeof body !== 'string') return <BrokenContent />;
+  const trimmedBody = trimReplyFromBody(body);
+  const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
+  const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
+  return (
+    <>
+      <MessageTextBody
+        emote
+        preWrap={typeof customBody !== 'string'}
+        jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+      >
+        <b>{`${displayName} `}</b>
+        {renderBody({
+          body: trimmedBody,
+          customBody: typeof customBody === 'string' ? customBody : undefined,
+        })}
+        {edited && <MessageEditedContent />}
+      </MessageTextBody>
+      {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
+    </>
+  );
+}
+
+type MNoticeProps = {
+  edited?: boolean;
+  content: Record<string, unknown>;
+  renderBody: (props: RenderBodyProps) => ReactNode;
+  renderUrlsPreview?: (urls: string[]) => ReactNode;
+};
+export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
+  const { body, formatted_body: customBody } = content;
+
+  if (typeof body !== 'string') return <BrokenContent />;
+  const trimmedBody = trimReplyFromBody(body);
+  const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
+  const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
+  return (
+    <>
+      <MessageTextBody
+        notice
+        preWrap={typeof customBody !== 'string'}
+        jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+      >
+        {renderBody({
+          body: trimmedBody,
+          customBody: typeof customBody === 'string' ? customBody : undefined,
+        })}
+        {edited && <MessageEditedContent />}
+      </MessageTextBody>
+      {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
+    </>
+  );
+}
+
+type RenderImageContentProps = {
+  body: string;
+  info?: IImageInfo & IThumbnailContent;
+  mimeType?: string;
+  url: string;
+  encInfo?: IEncryptedFile;
+};
+type MImageProps = {
+  content: IImageContent;
+  renderImageContent: (props: RenderImageContentProps) => ReactNode;
+  outlined?: boolean;
+};
+export function MImage({ content, renderImageContent, outlined }: MImageProps) {
+  const imgInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+  if (typeof mxcUrl !== 'string') {
+    return <BrokenContent />;
+  }
+  const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
+
+  return (
+    <Attachment outlined={outlined}>
+      <AttachmentBox
+        style={{
+          height: toRem(height < 48 ? 48 : height),
+        }}
+      >
+        {renderImageContent({
+          body: content.body || 'Image',
+          info: imgInfo,
+          mimeType: imgInfo?.mimetype,
+          url: mxcUrl,
+          encInfo: content.file,
+        })}
+      </AttachmentBox>
+    </Attachment>
+  );
+}
+
+type RenderVideoContentProps = {
+  body: string;
+  info: IVideoInfo & IThumbnailContent;
+  mimeType: string;
+  url: string;
+  encInfo?: IEncryptedFile;
+};
+type MVideoProps = {
+  content: IVideoContent;
+  renderAsFile: () => ReactNode;
+  renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
+  outlined?: boolean;
+};
+export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) {
+  const videoInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+  const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
+
+  if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
+    if (mxcUrl) {
+      return renderAsFile();
+    }
+    return <BrokenContent />;
+  }
+
+  const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
+
+  return (
+    <Attachment outlined={outlined}>
+      <AttachmentBox
+        style={{
+          height: toRem(height < 48 ? 48 : height),
+        }}
+      >
+        {renderVideoContent({
+          body: content.body || 'Video',
+          info: videoInfo,
+          mimeType: safeMimeType,
+          url: mxcUrl,
+          encInfo: content.file,
+        })}
+      </AttachmentBox>
+    </Attachment>
+  );
+}
+
+type RenderAudioContentProps = {
+  info: IAudioInfo;
+  mimeType: string;
+  url: string;
+  encInfo?: IEncryptedFile;
+};
+type MAudioProps = {
+  content: IAudioContent;
+  renderAsFile: () => ReactNode;
+  renderAudioContent: (props: RenderAudioContentProps) => ReactNode;
+  outlined?: boolean;
+};
+export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) {
+  const audioInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+  const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
+
+  if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
+    if (mxcUrl) {
+      return renderAsFile();
+    }
+    return <BrokenContent />;
+  }
+
+  return (
+    <Attachment outlined={outlined}>
+      <AttachmentHeader>
+        <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
+      </AttachmentHeader>
+      <AttachmentBox>
+        <AttachmentContent>
+          {renderAudioContent({
+            info: audioInfo,
+            mimeType: safeMimeType,
+            url: mxcUrl,
+            encInfo: content.file,
+          })}
+        </AttachmentContent>
+      </AttachmentBox>
+    </Attachment>
+  );
+}
+
+type RenderFileContentProps = {
+  body: string;
+  info: IFileInfo & IThumbnailContent;
+  mimeType: string;
+  url: string;
+  encInfo?: IEncryptedFile;
+};
+type MFileProps = {
+  content: IFileContent;
+  renderFileContent: (props: RenderFileContentProps) => ReactNode;
+  outlined?: boolean;
+};
+export function MFile({ content, renderFileContent, outlined }: MFileProps) {
+  const fileInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+
+  if (typeof mxcUrl !== 'string') {
+    return <BrokenContent />;
+  }
+
+  return (
+    <Attachment outlined={outlined}>
+      <AttachmentHeader>
+        <FileHeader
+          body={content.body ?? 'Unnamed File'}
+          mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
+        />
+      </AttachmentHeader>
+      <AttachmentBox>
+        <AttachmentContent>
+          {renderFileContent({
+            body: content.body ?? 'File',
+            info: fileInfo ?? {},
+            mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
+            url: mxcUrl,
+            encInfo: content.file,
+          })}
+        </AttachmentContent>
+      </AttachmentBox>
+    </Attachment>
+  );
+}
+
+type MLocationProps = {
+  content: IContent;
+};
+export function MLocation({ content }: MLocationProps) {
+  const geoUri = content.geo_uri;
+  if (typeof geoUri !== 'string') return <BrokenContent />;
+  const location = parseGeoUri(geoUri);
+  return (
+    <Box direction="Column" alignItems="Start" gap="100">
+      <Text size="T400">{geoUri}</Text>
+      <Chip
+        as="a"
+        size="400"
+        href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
+        target="_blank"
+        rel="noreferrer noopener"
+        variant="Primary"
+        radii="Pill"
+        before={<Icon src={Icons.External} size="50" />}
+      >
+        <Text size="B300">Open Location</Text>
+      </Chip>
+    </Box>
+  );
+}
+
+type MStickerProps = {
+  content: IImageContent;
+  renderImageContent: (props: RenderImageContentProps) => ReactNode;
+};
+export function MSticker({ content, renderImageContent }: MStickerProps) {
+  const imgInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+  if (typeof mxcUrl !== 'string') {
+    return <MessageBrokenContent />;
+  }
+  const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
+
+  return (
+    <AttachmentBox
+      style={{
+        height: toRem(height < 48 ? 48 : height),
+        width: toRem(152),
+      }}
+    >
+      {renderImageContent({
+        body: content.body || 'Sticker',
+        info: imgInfo,
+        mimeType: imgInfo?.mimetype,
+        url: mxcUrl,
+        encInfo: content.file,
+      })}
+    </AttachmentBox>
+  );
+}
diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx
new file mode 100644 (file)
index 0000000..b5b517e
--- /dev/null
@@ -0,0 +1,37 @@
+import React from 'react';
+import parse, { HTMLReactParserOptions } from 'html-react-parser';
+import Linkify from 'linkify-react';
+import { MessageEmptyContent } from './content';
+import { sanitizeCustomHtml } from '../../utils/sanitize';
+import {
+  LINKIFY_OPTS,
+  highlightText,
+  scaleSystemEmoji,
+} from '../../plugins/react-custom-html-parser';
+
+type RenderBodyProps = {
+  body: string;
+  customBody?: string;
+
+  highlightRegex?: RegExp;
+  htmlReactParserOptions: HTMLReactParserOptions;
+};
+export function RenderBody({
+  body,
+  customBody,
+  highlightRegex,
+  htmlReactParserOptions,
+}: RenderBodyProps) {
+  if (body === '') <MessageEmptyContent />;
+  if (customBody) {
+    if (customBody === '') <MessageEmptyContent />;
+    return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
+  }
+  return (
+    <Linkify options={LINKIFY_OPTS}>
+      {highlightRegex
+        ? highlightText(highlightRegex, scaleSystemEmoji(body))
+        : scaleSystemEmoji(body)}
+    </Linkify>
+  );
+}
index c47aac56d7066de8fc37ee6af99b398ee5c4db20..014a2840a09ce647e85ec3f44f6094fe401d4beb 100644 (file)
@@ -1,13 +1,20 @@
 import { style } from '@vanilla-extract/css';
 import { config, toRem } from 'folds';
 
+export const ReplyBend = style({
+  flexShrink: 0,
+});
+
 export const Reply = style({
-  padding: `0 ${config.space.S100}`,
   marginBottom: toRem(1),
-  cursor: 'pointer',
   minWidth: 0,
   maxWidth: '100%',
   minHeight: config.lineHeight.T300,
+  selectors: {
+    'button&': {
+      cursor: 'pointer',
+    },
+  },
 });
 
 export const ReplyContent = style({
@@ -19,7 +26,3 @@ export const ReplyContent = style({
     },
   },
 });
-
-export const ReplyContentText = style({
-  paddingRight: config.space.S100,
-});
index c9b6b8d8b2b0c7625e99d73525583830fafed926..85383cdb5750a0c486801b22851cf062e2901c40 100644 (file)
@@ -1,7 +1,7 @@
 import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
 import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
 import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
-import React, { useEffect, useState } from 'react';
+import React, { ReactNode, useEffect, useMemo, useState } from 'react';
 import to from 'await-to-js';
 import classNames from 'classnames';
 import colorMXID from '../../../util/colorMXID';
@@ -10,94 +10,105 @@ import { getMxIdLocalPart } from '../../utils/matrix';
 import { LinePlaceholder } from './placeholder';
 import { randomNumberBetween } from '../../utils/common';
 import * as css from './Reply.css';
-import {
-  MessageBadEncryptedContent,
-  MessageDeletedContent,
-  MessageFailedContent,
-} from './MessageContentFallback';
+import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
+import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
+
+type ReplyLayoutProps = {
+  userColor?: string;
+  username?: ReactNode;
+};
+export const ReplyLayout = as<'div', ReplyLayoutProps>(
+  ({ username, userColor, className, children, ...props }, ref) => (
+    <Box
+      className={classNames(css.Reply, className)}
+      alignItems="Center"
+      gap="100"
+      {...props}
+      ref={ref}
+    >
+      <Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
+        <Icon size="100" src={Icons.ReplyArrow} />
+        {username}
+      </Box>
+      <Box grow="Yes" className={css.ReplyContent}>
+        {children}
+      </Box>
+    </Box>
+  )
+);
 
 type ReplyProps = {
   mx: MatrixClient;
   room: Room;
-  timelineSet: EventTimelineSet;
+  timelineSet?: EventTimelineSet;
   eventId: string;
 };
 
-export const Reply = as<'div', ReplyProps>(
-  ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
-    const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
-      timelineSet.findEventById(eventId)
-    );
+export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => {
+  const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
+    timelineSet?.findEventById(eventId)
+  );
+  const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
 
-    const { body } = replyEvent?.getContent() ?? {};
-    const sender = replyEvent?.getSender();
+  const { body } = replyEvent?.getContent() ?? {};
+  const sender = replyEvent?.getSender();
 
-    const fallbackBody = replyEvent?.isRedacted() ? (
-      <MessageDeletedContent />
-    ) : (
-      <MessageFailedContent />
-    );
+  const fallbackBody = replyEvent?.isRedacted() ? (
+    <MessageDeletedContent />
+  ) : (
+    <MessageFailedContent />
+  );
 
-    useEffect(() => {
-      let disposed = false;
-      const loadEvent = async () => {
-        const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
-        const mEvent = new MatrixEvent(evt);
-        if (disposed) return;
-        if (err) {
-          setReplyEvent(null);
-          return;
-        }
-        if (mEvent.isEncrypted() && mx.getCrypto()) {
-          await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
-        }
-        setReplyEvent(mEvent);
-      };
-      if (replyEvent === undefined) loadEvent();
-      return () => {
-        disposed = true;
-      };
-    }, [replyEvent, mx, room, eventId]);
+  useEffect(() => {
+    let disposed = false;
+    const loadEvent = async () => {
+      const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
+      const mEvent = new MatrixEvent(evt);
+      if (disposed) return;
+      if (err) {
+        setReplyEvent(null);
+        return;
+      }
+      if (mEvent.isEncrypted() && mx.getCrypto()) {
+        await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
+      }
+      setReplyEvent(mEvent);
+    };
+    if (replyEvent === undefined) loadEvent();
+    return () => {
+      disposed = true;
+    };
+  }, [replyEvent, mx, room, eventId]);
 
-    const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
-    const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
+  const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
+  const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
 
-    return (
-      <Box
-        className={classNames(css.Reply, className)}
-        alignItems="Center"
-        gap="100"
-        {...props}
-        ref={ref}
-      >
-        <Box
-          style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
-          alignItems="Center"
-          shrink="No"
-        >
-          <Icon src={Icons.ReplyArrow} size="50" />
-          {sender && (
-            <Text size="T300" truncate>
-              {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
-            </Text>
-          )}
-        </Box>
-        <Box grow="Yes" className={css.ReplyContent}>
-          {replyEvent !== undefined ? (
-            <Text className={css.ReplyContentText} size="T300" truncate>
-              {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
-            </Text>
-          ) : (
-            <LinePlaceholder
-              style={{
-                backgroundColor: color.SurfaceVariant.ContainerActive,
-                maxWidth: toRem(randomNumberBetween(40, 400)),
-                width: '100%',
-              }}
-            />
-          )}
-        </Box>
-      </Box>
-    );
-  }
-);
+  return (
+    <ReplyLayout
+      userColor={sender ? colorMXID(sender) : undefined}
+      username={
+        sender && (
+          <Text size="T300" truncate>
+            <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
+          </Text>
+        )
+      }
+      {...props}
+      ref={ref}
+    >
+      {replyEvent !== undefined ? (
+        <Text size="T300" truncate>
+          {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
+        </Text>
+      ) : (
+        <LinePlaceholder
+          style={{
+            backgroundColor: color.SurfaceVariant.ContainerActive,
+            maxWidth: toRem(placeholderWidth),
+            width: '100%',
+          }}
+        />
+      )}
+    </ReplyLayout>
+  );
+});
index de11cf896146092cb6351f9d2e79b7ded8a594f1..a5126015f4de4a01591ed847fcdeeda58cf76c71 100644 (file)
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { ComponentProps } from 'react';
 import { Text, as } from 'folds';
 import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
 
@@ -7,21 +7,23 @@ export type TimeProps = {
   ts: number;
 };
 
-export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
-  let time = '';
-  if (compact) {
-    time = timeHourMinute(ts);
-  } else if (today(ts)) {
-    time = timeHourMinute(ts);
-  } else if (yesterday(ts)) {
-    time = `Yesterday ${timeHourMinute(ts)}`;
-  } else {
-    time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
-  }
+export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
+  ({ compact, ts, ...props }, ref) => {
+    let time = '';
+    if (compact) {
+      time = timeHourMinute(ts);
+    } else if (today(ts)) {
+      time = timeHourMinute(ts);
+    } else if (yesterday(ts)) {
+      time = `Yesterday ${timeHourMinute(ts)}`;
+    } else {
+      time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
+    }
 
-  return (
-    <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
-      {time}
-    </Text>
-  );
-});
+    return (
+      <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
+        {time}
+      </Text>
+    );
+  }
+);
diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx
new file mode 100644 (file)
index 0000000..34777cc
--- /dev/null
@@ -0,0 +1,199 @@
+/* eslint-disable jsx-a11y/media-has-caption */
+import React, { ReactNode, useCallback, useRef, useState } from 'react';
+import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { Range } from 'react-range';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { IAudioInfo } from '../../../../types/matrix/common';
+import {
+  PlayTimeCallback,
+  useMediaLoading,
+  useMediaPlay,
+  useMediaPlayTimeCallback,
+  useMediaSeek,
+  useMediaVolume,
+} from '../../../hooks/media';
+import { useThrottle } from '../../../hooks/useThrottle';
+import { secondsToMinutesAndSeconds } from '../../../utils/common';
+
+const PLAY_TIME_THROTTLE_OPS = {
+  wait: 500,
+  immediate: true,
+};
+
+type RenderMediaControlProps = {
+  after: ReactNode;
+  leftControl: ReactNode;
+  rightControl: ReactNode;
+  children: ReactNode;
+};
+export type AudioContentProps = {
+  mimeType: string;
+  url: string;
+  info: IAudioInfo;
+  encInfo?: EncryptedAttachmentInfo;
+  renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
+};
+export function AudioContent({
+  mimeType,
+  url,
+  info,
+  encInfo,
+  renderMediaControl,
+}: AudioContentProps) {
+  const mx = useMatrixClient();
+
+  const [srcState, loadSrc] = useAsyncCallback(
+    useCallback(
+      () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+      [mx, url, mimeType, encInfo]
+    )
+  );
+
+  const audioRef = useRef<HTMLAudioElement | null>(null);
+
+  const [currentTime, setCurrentTime] = useState(0);
+  // duration in seconds. (NOTE: info.duration is in milliseconds)
+  const infoDuration = info.duration ?? 0;
+  const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
+
+  const getAudioRef = useCallback(() => audioRef.current, []);
+  const { loading } = useMediaLoading(getAudioRef);
+  const { playing, setPlaying } = useMediaPlay(getAudioRef);
+  const { seek } = useMediaSeek(getAudioRef);
+  const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
+  const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
+    setDuration(d);
+    setCurrentTime(ct);
+  }, []);
+  useMediaPlayTimeCallback(
+    getAudioRef,
+    useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
+  );
+
+  const handlePlay = () => {
+    if (srcState.status === AsyncStatus.Success) {
+      setPlaying(!playing);
+    } else if (srcState.status !== AsyncStatus.Loading) {
+      loadSrc();
+    }
+  };
+
+  return renderMediaControl({
+    after: (
+      <Range
+        step={1}
+        min={0}
+        max={duration || 1}
+        values={[currentTime]}
+        onChange={(values) => seek(values[0])}
+        renderTrack={(params) => (
+          <div {...params.props}>
+            {params.children}
+            <ProgressBar
+              as="div"
+              variant="Secondary"
+              size="300"
+              min={0}
+              max={duration}
+              value={currentTime}
+              radii="300"
+            />
+          </div>
+        )}
+        renderThumb={(params) => (
+          <Badge
+            size="300"
+            variant="Secondary"
+            fill="Solid"
+            radii="Pill"
+            outlined
+            {...params.props}
+            style={{
+              ...params.props.style,
+              zIndex: 0,
+            }}
+          />
+        )}
+      />
+    ),
+    leftControl: (
+      <>
+        <Chip
+          onClick={handlePlay}
+          variant="Secondary"
+          radii="300"
+          disabled={srcState.status === AsyncStatus.Loading}
+          before={
+            srcState.status === AsyncStatus.Loading || loading ? (
+              <Spinner variant="Secondary" size="50" />
+            ) : (
+              <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
+            )
+          }
+        >
+          <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
+        </Chip>
+
+        <Text size="T200">{`${secondsToMinutesAndSeconds(
+          currentTime
+        )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
+      </>
+    ),
+    rightControl: (
+      <>
+        <IconButton
+          variant="SurfaceVariant"
+          size="300"
+          radii="Pill"
+          onClick={() => setMute(!mute)}
+          aria-pressed={mute}
+        >
+          <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
+        </IconButton>
+        <Range
+          step={0.1}
+          min={0}
+          max={1}
+          values={[volume]}
+          onChange={(values) => setVolume(values[0])}
+          renderTrack={(params) => (
+            <div {...params.props}>
+              {params.children}
+              <ProgressBar
+                style={{ width: toRem(48) }}
+                variant="Secondary"
+                size="300"
+                min={0}
+                max={1}
+                value={volume}
+                radii="300"
+              />
+            </div>
+          )}
+          renderThumb={(params) => (
+            <Badge
+              size="300"
+              variant="Secondary"
+              fill="Solid"
+              radii="Pill"
+              outlined
+              {...params.props}
+              style={{
+                ...params.props.style,
+                zIndex: 0,
+              }}
+            />
+          )}
+        />
+      </>
+    ),
+    children: (
+      <audio controls={false} autoPlay ref={audioRef}>
+        {srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
+      </audio>
+    ),
+  });
+}
diff --git a/src/app/components/message/content/EventContent.tsx b/src/app/components/message/content/EventContent.tsx
new file mode 100644 (file)
index 0000000..2cc4934
--- /dev/null
@@ -0,0 +1,37 @@
+import { Box, Icon, IconSrc } from 'folds';
+import React, { ReactNode } from 'react';
+import { CompactLayout, ModernLayout } from '..';
+
+export type EventContentProps = {
+  messageLayout: number;
+  time: ReactNode;
+  iconSrc: IconSrc;
+  content: ReactNode;
+};
+export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
+  const beforeJSX = (
+    <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+      {messageLayout === 1 && time}
+      <Box
+        grow={messageLayout === 1 ? undefined : 'Yes'}
+        alignItems="Center"
+        justifyContent="Center"
+      >
+        <Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
+      </Box>
+    </Box>
+  );
+
+  const msgContentJSX = (
+    <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
+      {content}
+      {messageLayout !== 1 && time}
+    </Box>
+  );
+
+  return messageLayout === 1 ? (
+    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
+  ) : (
+    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
+  );
+}
diff --git a/src/app/components/message/content/FallbackContent.tsx b/src/app/components/message/content/FallbackContent.tsx
new file mode 100644 (file)
index 0000000..9edb967
--- /dev/null
@@ -0,0 +1,66 @@
+import { Box, Icon, Icons, Text, as, color, config } from 'folds';
+import React from 'react';
+
+const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
+const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
+
+export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
+  ({ reason, ...props }, ref) => (
+    <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+      <Icon size="50" src={Icons.Delete} />
+      {reason ? (
+        <i>This message has been deleted. {reason}</i>
+      ) : (
+        <i>This message has been deleted</i>
+      )}
+    </Box>
+  )
+);
+
+export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Unsupported message</i>
+  </Box>
+));
+
+export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Failed to load message</i>
+  </Box>
+));
+
+export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Lock} />
+    <i>Unable to decrypt message</i>
+  </Box>
+));
+
+export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Lock} />
+    <i>This message is not decrypted yet</i>
+  </Box>
+));
+
+export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Broken message</i>
+  </Box>
+));
+
+export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Empty message</i>
+  </Box>
+));
+
+export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
+  <Text as="span" size="T200" priority="300" {...props} ref={ref}>
+    {' (edited)'}
+  </Text>
+));
diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx
new file mode 100644 (file)
index 0000000..af064a3
--- /dev/null
@@ -0,0 +1,292 @@
+import React, { ReactNode, useCallback, useState } from 'react';
+import {
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+} from 'folds';
+import FileSaver from 'file-saver';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import FocusTrap from 'focus-trap-react';
+import { IFileInfo } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl, getSrcFile } from './util';
+import { bytesToSize } from '../../../utils/common';
+import {
+  READABLE_EXT_TO_MIME_TYPE,
+  READABLE_TEXT_MIME_TYPES,
+  getFileNameExt,
+  mimeTypeToExt,
+} from '../../../utils/mimeTypes';
+import * as css from './style.css';
+
+const renderErrorButton = (retry: () => void, text: string) => (
+  <TooltipProvider
+    tooltip={
+      <Tooltip variant="Critical">
+        <Text>Failed to load file!</Text>
+      </Tooltip>
+    }
+    position="Top"
+    align="Center"
+  >
+    {(triggerRef) => (
+      <Button
+        ref={triggerRef}
+        size="400"
+        variant="Critical"
+        fill="Soft"
+        outlined
+        radii="300"
+        onClick={retry}
+        before={<Icon size="100" src={Icons.Warning} filled />}
+      >
+        <Text size="B400" truncate>
+          {text}
+        </Text>
+      </Button>
+    )}
+  </TooltipProvider>
+);
+
+type RenderTextViewerProps = {
+  name: string;
+  text: string;
+  langName: string;
+  requestClose: () => void;
+};
+type ReadTextFileProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  encInfo?: EncryptedAttachmentInfo;
+  renderViewer: (props: RenderTextViewerProps) => ReactNode;
+};
+export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
+  const mx = useMatrixClient();
+  const [textViewer, setTextViewer] = useState(false);
+
+  const loadSrc = useCallback(
+    () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+    [mx, url, mimeType, encInfo]
+  );
+
+  const [textState, loadText] = useAsyncCallback(
+    useCallback(async () => {
+      const src = await loadSrc();
+      const blob = await getSrcFile(src);
+      const text = blob.text();
+      setTextViewer(true);
+      return text;
+    }, [loadSrc])
+  );
+
+  return (
+    <>
+      {textState.status === AsyncStatus.Success && (
+        <Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
+          <OverlayCenter>
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                onDeactivate: () => setTextViewer(false),
+                clickOutsideDeactivates: true,
+              }}
+            >
+              <Modal
+                className={css.ModalWide}
+                size="500"
+                onContextMenu={(evt: any) => evt.stopPropagation()}
+              >
+                {renderViewer({
+                  name: body,
+                  text: textState.data,
+                  langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
+                    ? mimeTypeToExt(mimeType)
+                    : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
+                  requestClose: () => setTextViewer(false),
+                })}
+              </Modal>
+            </FocusTrap>
+          </OverlayCenter>
+        </Overlay>
+      )}
+      {textState.status === AsyncStatus.Error ? (
+        renderErrorButton(loadText, 'Open File')
+      ) : (
+        <Button
+          variant="Secondary"
+          fill="Solid"
+          radii="300"
+          size="400"
+          onClick={() =>
+            textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
+          }
+          disabled={textState.status === AsyncStatus.Loading}
+          before={
+            textState.status === AsyncStatus.Loading ? (
+              <Spinner fill="Solid" size="100" variant="Secondary" />
+            ) : (
+              <Icon size="100" src={Icons.ArrowRight} filled />
+            )
+          }
+        >
+          <Text size="B400" truncate>
+            Open File
+          </Text>
+        </Button>
+      )}
+    </>
+  );
+}
+
+type RenderPdfViewerProps = {
+  name: string;
+  src: string;
+  requestClose: () => void;
+};
+export type ReadPdfFileProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  encInfo?: EncryptedAttachmentInfo;
+  renderViewer: (props: RenderPdfViewerProps) => ReactNode;
+};
+export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
+  const mx = useMatrixClient();
+  const [pdfViewer, setPdfViewer] = useState(false);
+
+  const [pdfState, loadPdf] = useAsyncCallback(
+    useCallback(async () => {
+      const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+      setPdfViewer(true);
+      return httpUrl;
+    }, [mx, url, mimeType, encInfo])
+  );
+
+  return (
+    <>
+      {pdfState.status === AsyncStatus.Success && (
+        <Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
+          <OverlayCenter>
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                onDeactivate: () => setPdfViewer(false),
+                clickOutsideDeactivates: true,
+              }}
+            >
+              <Modal
+                className={css.ModalWide}
+                size="500"
+                onContextMenu={(evt: any) => evt.stopPropagation()}
+              >
+                {renderViewer({
+                  name: body,
+                  src: pdfState.data,
+                  requestClose: () => setPdfViewer(false),
+                })}
+              </Modal>
+            </FocusTrap>
+          </OverlayCenter>
+        </Overlay>
+      )}
+      {pdfState.status === AsyncStatus.Error ? (
+        renderErrorButton(loadPdf, 'Open PDF')
+      ) : (
+        <Button
+          variant="Secondary"
+          fill="Solid"
+          radii="300"
+          size="400"
+          onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
+          disabled={pdfState.status === AsyncStatus.Loading}
+          before={
+            pdfState.status === AsyncStatus.Loading ? (
+              <Spinner fill="Solid" size="100" variant="Secondary" />
+            ) : (
+              <Icon size="100" src={Icons.ArrowRight} filled />
+            )
+          }
+        >
+          <Text size="B400" truncate>
+            Open PDF
+          </Text>
+        </Button>
+      )}
+    </>
+  );
+}
+
+export type DownloadFileProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  info: IFileInfo;
+  encInfo?: EncryptedAttachmentInfo;
+};
+export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
+  const mx = useMatrixClient();
+
+  const [downloadState, download] = useAsyncCallback(
+    useCallback(async () => {
+      const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+      FileSaver.saveAs(httpUrl, body);
+      return httpUrl;
+    }, [mx, url, mimeType, encInfo, body])
+  );
+
+  return downloadState.status === AsyncStatus.Error ? (
+    renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
+  ) : (
+    <Button
+      variant="Secondary"
+      fill="Soft"
+      radii="300"
+      size="400"
+      onClick={() =>
+        downloadState.status === AsyncStatus.Success
+          ? FileSaver.saveAs(downloadState.data, body)
+          : download()
+      }
+      disabled={downloadState.status === AsyncStatus.Loading}
+      before={
+        downloadState.status === AsyncStatus.Loading ? (
+          <Spinner fill="Soft" size="100" variant="Secondary" />
+        ) : (
+          <Icon size="100" src={Icons.Download} filled />
+        )
+      }
+    >
+      <Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
+    </Button>
+  );
+}
+
+type FileContentProps = {
+  body: string;
+  mimeType: string;
+  renderAsTextFile: () => ReactNode;
+  renderAsPdfFile: () => ReactNode;
+};
+export const FileContent = as<'div', FileContentProps>(
+  ({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
+    <Box direction="Column" gap="300" {...props} ref={ref}>
+      {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
+        READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
+        renderAsTextFile()}
+      {mimeType === 'application/pdf' && renderAsPdfFile()}
+      {children}
+    </Box>
+  )
+);
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
new file mode 100644 (file)
index 0000000..a64b8e9
--- /dev/null
@@ -0,0 +1,208 @@
+import React, { ReactNode, useCallback, useEffect, useState } from 'react';
+import {
+  Badge,
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import FocusTrap from 'focus-trap-react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl } from './util';
+import * as css from './style.css';
+import { bytesToSize } from '../../../utils/common';
+import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
+
+type RenderViewerProps = {
+  src: string;
+  alt: string;
+  requestClose: () => void;
+};
+type RenderImageProps = {
+  alt: string;
+  title: string;
+  src: string;
+  onLoad: () => void;
+  onError: () => void;
+  onClick: () => void;
+  tabIndex: number;
+};
+export type ImageContentProps = {
+  body: string;
+  mimeType?: string;
+  url: string;
+  info?: IImageInfo;
+  encInfo?: EncryptedAttachmentInfo;
+  autoPlay?: boolean;
+  renderViewer: (props: RenderViewerProps) => ReactNode;
+  renderImage: (props: RenderImageProps) => ReactNode;
+};
+export const ImageContent = as<'div', ImageContentProps>(
+  (
+    {
+      className,
+      body,
+      mimeType,
+      url,
+      info,
+      encInfo,
+      autoPlay,
+      renderViewer,
+      renderImage,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+    const [load, setLoad] = useState(false);
+    const [error, setError] = useState(false);
+    const [viewer, setViewer] = useState(false);
+
+    const [srcState, loadSrc] = useAsyncCallback(
+      useCallback(
+        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType || FALLBACK_MIMETYPE, encInfo),
+        [mx, url, mimeType, encInfo]
+      )
+    );
+
+    const handleLoad = () => {
+      setLoad(true);
+    };
+    const handleError = () => {
+      setLoad(false);
+      setError(true);
+    };
+
+    const handleRetry = () => {
+      setError(false);
+      loadSrc();
+    };
+
+    useEffect(() => {
+      if (autoPlay) loadSrc();
+    }, [autoPlay, loadSrc]);
+
+    return (
+      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+        {srcState.status === AsyncStatus.Success && (
+          <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setViewer(false),
+                  clickOutsideDeactivates: true,
+                }}
+              >
+                <Modal
+                  className={css.ModalWide}
+                  size="500"
+                  onContextMenu={(evt: any) => evt.stopPropagation()}
+                >
+                  {renderViewer({
+                    src: srcState.data,
+                    alt: body,
+                    requestClose: () => setViewer(false),
+                  })}
+                </Modal>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+        {typeof blurHash === 'string' && !load && (
+          <BlurhashCanvas
+            style={{ width: '100%', height: '100%' }}
+            width={32}
+            height={32}
+            hash={blurHash}
+            punch={1}
+          />
+        )}
+        {!autoPlay && srcState.status === AsyncStatus.Idle && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <Button
+              variant="Secondary"
+              fill="Solid"
+              radii="300"
+              size="300"
+              onClick={loadSrc}
+              before={<Icon size="Inherit" src={Icons.Photo} filled />}
+            >
+              <Text size="B300">View</Text>
+            </Button>
+          </Box>
+        )}
+        {srcState.status === AsyncStatus.Success && (
+          <Box className={css.AbsoluteContainer}>
+            {renderImage({
+              alt: body,
+              title: body,
+              src: srcState.data,
+              onLoad: handleLoad,
+              onError: handleError,
+              onClick: () => setViewer(true),
+              tabIndex: 0,
+            })}
+          </Box>
+        )}
+        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+          !load && (
+            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+              <Spinner variant="Secondary" />
+            </Box>
+          )}
+        {(error || srcState.status === AsyncStatus.Error) && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <TooltipProvider
+              tooltip={
+                <Tooltip variant="Critical">
+                  <Text>Failed to load image!</Text>
+                </Tooltip>
+              }
+              position="Top"
+              align="Center"
+            >
+              {(triggerRef) => (
+                <Button
+                  ref={triggerRef}
+                  size="300"
+                  variant="Critical"
+                  fill="Soft"
+                  outlined
+                  radii="300"
+                  onClick={handleRetry}
+                  before={<Icon size="Inherit" src={Icons.Warning} filled />}
+                >
+                  <Text size="B300">Retry</Text>
+                </Button>
+              )}
+            </TooltipProvider>
+          </Box>
+        )}
+        {!load && typeof info?.size === 'number' && (
+          <Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
+            <Badge variant="Secondary" fill="Soft">
+              <Text size="L400">{bytesToSize(info.size)}</Text>
+            </Badge>
+          </Box>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx
new file mode 100644 (file)
index 0000000..9d940f3
--- /dev/null
@@ -0,0 +1,34 @@
+import { ReactNode, useCallback, useEffect } from 'react';
+import { IThumbnailContent } from '../../../../types/matrix/common';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+
+export type ThumbnailContentProps = {
+  info: IThumbnailContent;
+  renderImage: (src: string) => ReactNode;
+};
+export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
+  const mx = useMatrixClient();
+
+  const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
+    useCallback(() => {
+      const thumbInfo = info.thumbnail_info;
+      const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
+      if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
+        throw new Error('Failed to load thumbnail');
+      }
+      return getFileSrcUrl(
+        mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
+        thumbInfo.mimetype,
+        info.thumbnail_file
+      );
+    }, [mx, info])
+  );
+
+  useEffect(() => {
+    loadThumbSrc();
+  }, [loadThumbSrc]);
+
+  return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
+}
diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx
new file mode 100644 (file)
index 0000000..b08512a
--- /dev/null
@@ -0,0 +1,187 @@
+import React, { ReactNode, useCallback, useEffect, useState } from 'react';
+import {
+  Badge,
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import {
+  IThumbnailContent,
+  IVideoInfo,
+  MATRIX_BLUR_HASH_PROPERTY_NAME,
+} from '../../../../types/matrix/common';
+import * as css from './style.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { bytesToSize } from '../../../../util/common';
+import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
+
+type RenderVideoProps = {
+  title: string;
+  src: string;
+  onLoadedMetadata: () => void;
+  onError: () => void;
+  autoPlay: boolean;
+  controls: boolean;
+};
+type VideoContentProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  info: IVideoInfo & IThumbnailContent;
+  encInfo?: EncryptedAttachmentInfo;
+  autoPlay?: boolean;
+  renderThumbnail?: () => ReactNode;
+  renderVideo: (props: RenderVideoProps) => ReactNode;
+};
+export const VideoContent = as<'div', VideoContentProps>(
+  (
+    {
+      className,
+      body,
+      mimeType,
+      url,
+      info,
+      encInfo,
+      autoPlay,
+      renderThumbnail,
+      renderVideo,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+    const [load, setLoad] = useState(false);
+    const [error, setError] = useState(false);
+
+    const [srcState, loadSrc] = useAsyncCallback(
+      useCallback(
+        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+        [mx, url, mimeType, encInfo]
+      )
+    );
+
+    const handleLoad = () => {
+      setLoad(true);
+    };
+    const handleError = () => {
+      setLoad(false);
+      setError(true);
+    };
+
+    const handleRetry = () => {
+      setError(false);
+      loadSrc();
+    };
+
+    useEffect(() => {
+      if (autoPlay) loadSrc();
+    }, [autoPlay, loadSrc]);
+
+    return (
+      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+        {typeof blurHash === 'string' && !load && (
+          <BlurhashCanvas
+            style={{ width: '100%', height: '100%' }}
+            width={32}
+            height={32}
+            hash={blurHash}
+            punch={1}
+          />
+        )}
+        {renderThumbnail && !load && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            {renderThumbnail()}
+          </Box>
+        )}
+        {!autoPlay && srcState.status === AsyncStatus.Idle && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <Button
+              variant="Secondary"
+              fill="Solid"
+              radii="300"
+              size="300"
+              onClick={loadSrc}
+              before={<Icon size="Inherit" src={Icons.Play} filled />}
+            >
+              <Text size="B300">Watch</Text>
+            </Button>
+          </Box>
+        )}
+        {srcState.status === AsyncStatus.Success && (
+          <Box className={css.AbsoluteContainer}>
+            {renderVideo({
+              title: body,
+              src: srcState.data,
+              onLoadedMetadata: handleLoad,
+              onError: handleError,
+              autoPlay: true,
+              controls: true,
+            })}
+          </Box>
+        )}
+        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+          !load && (
+            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+              <Spinner variant="Secondary" />
+            </Box>
+          )}
+        {(error || srcState.status === AsyncStatus.Error) && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <TooltipProvider
+              tooltip={
+                <Tooltip variant="Critical">
+                  <Text>Failed to load video!</Text>
+                </Tooltip>
+              }
+              position="Top"
+              align="Center"
+            >
+              {(triggerRef) => (
+                <Button
+                  ref={triggerRef}
+                  size="300"
+                  variant="Critical"
+                  fill="Soft"
+                  outlined
+                  radii="300"
+                  onClick={handleRetry}
+                  before={<Icon size="Inherit" src={Icons.Warning} filled />}
+                >
+                  <Text size="B300">Retry</Text>
+                </Button>
+              )}
+            </TooltipProvider>
+          </Box>
+        )}
+        {!load && typeof info.size === 'number' && (
+          <Box
+            className={css.AbsoluteFooter}
+            justifyContent="SpaceBetween"
+            alignContent="Center"
+            gap="200"
+          >
+            <Badge variant="Secondary" fill="Soft">
+              <Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
+            </Badge>
+            <Badge variant="Secondary" fill="Soft">
+              <Text size="L400">{bytesToSize(info.size)}</Text>
+            </Badge>
+          </Box>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/message/content/index.ts b/src/app/components/message/content/index.ts
new file mode 100644 (file)
index 0000000..6a31ed7
--- /dev/null
@@ -0,0 +1,7 @@
+export * from './ThumbnailContent';
+export * from './ImageContent';
+export * from './VideoContent';
+export * from './AudioContent';
+export * from './FileContent';
+export * from './FallbackContent';
+export * from './EventContent';
diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts
new file mode 100644 (file)
index 0000000..0c496a8
--- /dev/null
@@ -0,0 +1,37 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config } from 'folds';
+
+export const RelativeBase = style([
+  DefaultReset,
+  {
+    position: 'relative',
+    width: '100%',
+    height: '100%',
+  },
+]);
+
+export const AbsoluteContainer = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    width: '100%',
+    height: '100%',
+  },
+]);
+
+export const AbsoluteFooter = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    bottom: config.space.S100,
+    left: config.space.S100,
+    right: config.space.S100,
+  },
+]);
+
+export const ModalWide = style({
+  minWidth: '85vw',
+  minHeight: '90vh',
+});
diff --git a/src/app/components/message/content/util.ts b/src/app/components/message/content/util.ts
new file mode 100644 (file)
index 0000000..2cc4341
--- /dev/null
@@ -0,0 +1,23 @@
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { decryptFile } from '../../../utils/matrix';
+
+export const getFileSrcUrl = async (
+  httpUrl: string,
+  mimeType: string,
+  encInfo?: EncryptedAttachmentInfo
+): Promise<string> => {
+  if (encInfo) {
+    if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+    const encRes = await fetch(httpUrl, { method: 'GET' });
+    const encData = await encRes.arrayBuffer();
+    const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
+    return URL.createObjectURL(decryptedBlob);
+  }
+  return httpUrl;
+};
+
+export const getSrcFile = async (src: string): Promise<Blob> => {
+  const res = await fetch(src, { method: 'GET' });
+  const blob = await res.blob();
+  return blob;
+};
index 58f8fe523d496adcdb3c060e75790aed1dcb7af0..6f7415ad3c5c4129a431d503c4fe4d859bef5fbc 100644 (file)
@@ -3,5 +3,8 @@ export * from './placeholder';
 export * from './Reaction';
 export * from './attachment';
 export * from './Reply';
-export * from './MessageContentFallback';
+export * from './content';
 export * from './Time';
+export * from './MsgTypeRenderers';
+export * from './FileHeader';
+export * from './RenderBody';
index a6b7db0df926ac6f2b268bdc5cd829e24d032715..a1d45679ef3b2e2621831c499acb41d50fa4079b 100644 (file)
@@ -61,6 +61,7 @@ const highlightAnime = keyframes({
 const HighlightVariant = styleVariants({
   true: {
     animation: `${highlightAnime} 2000ms ease-in-out`,
+    animationIterationCount: 'infinite',
   },
 });
 
@@ -143,12 +144,14 @@ export const BubbleContent = style({
 });
 
 export const Username = style({
-  cursor: 'pointer',
   overflow: 'hidden',
   whiteSpace: 'nowrap',
   textOverflow: 'ellipsis',
   selectors: {
-    '&:hover, &:focus-visible': {
+    'button&': {
+      cursor: 'pointer',
+    },
+    'button&:hover, button&:focus-visible': {
       textDecoration: 'underline',
     },
   },
diff --git a/src/app/components/nav/NavCategory.tsx b/src/app/components/nav/NavCategory.tsx
new file mode 100644 (file)
index 0000000..4ccf115
--- /dev/null
@@ -0,0 +1,11 @@
+import React, { ReactNode } from 'react';
+import { as } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+type NavCategoryProps = {
+  children: ReactNode;
+};
+export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
+  <div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
+));
diff --git a/src/app/components/nav/NavCategoryHeader.tsx b/src/app/components/nav/NavCategoryHeader.tsx
new file mode 100644 (file)
index 0000000..e6ae700
--- /dev/null
@@ -0,0 +1,19 @@
+import React, { ReactNode } from 'react';
+import classNames from 'classnames';
+import { Header, as } from 'folds';
+import * as css from './styles.css';
+
+export type NavCategoryHeaderProps = {
+  children: ReactNode;
+};
+export const NavCategoryHeader = as<'div', NavCategoryHeaderProps>(
+  ({ className, ...props }, ref) => (
+    <Header
+      className={classNames(css.NavCategoryHeader, className)}
+      variant="Background"
+      size="300"
+      {...props}
+      ref={ref}
+    />
+  )
+);
diff --git a/src/app/components/nav/NavEmptyLayout.tsx b/src/app/components/nav/NavEmptyLayout.tsx
new file mode 100644 (file)
index 0000000..322ef1c
--- /dev/null
@@ -0,0 +1,40 @@
+import { Box, config } from 'folds';
+import React, { ReactNode } from 'react';
+
+export function NavEmptyCenter({ children }: { children: ReactNode }) {
+  return (
+    <Box
+      style={{
+        padding: config.space.S500,
+      }}
+      grow="Yes"
+      direction="Column"
+      justifyContent="Center"
+    >
+      {children}
+    </Box>
+  );
+}
+
+type NavEmptyLayoutProps = {
+  icon?: ReactNode;
+  title?: ReactNode;
+  content?: ReactNode;
+  options?: ReactNode;
+};
+export function NavEmptyLayout({ icon, title, content, options }: NavEmptyLayoutProps) {
+  return (
+    <Box direction="Column" gap="400">
+      <Box direction="Column" alignItems="Center" gap="200">
+        {icon}
+      </Box>
+      <Box direction="Column" gap="100" alignItems="Center">
+        {title}
+        {content}
+      </Box>
+      <Box direction="Column" gap="200">
+        {options}
+      </Box>
+    </Box>
+  );
+}
diff --git a/src/app/components/nav/NavItem.tsx b/src/app/components/nav/NavItem.tsx
new file mode 100644 (file)
index 0000000..1ec1257
--- /dev/null
@@ -0,0 +1,33 @@
+import classNames from 'classnames';
+import React, { ComponentProps, forwardRef } from 'react';
+import { Link } from 'react-router-dom';
+import { as } from 'folds';
+import * as css from './styles.css';
+
+export const NavItem = as<
+  'div',
+  {
+    highlight?: boolean;
+  } & css.RoomSelectorVariants
+>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
+  <AsNavItem
+    className={classNames(css.NavItem({ variant, radii }), className)}
+    data-highlight={highlight}
+    {...props}
+    ref={ref}
+  >
+    {children}
+  </AsNavItem>
+));
+
+export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
+  ({ className, ...props }, ref) => (
+    <Link className={classNames(css.NavLink, className)} {...props} ref={ref} />
+  )
+);
+
+export const NavButton = as<'button'>(
+  ({ as: AsNavButton = 'button', className, ...props }, ref) => (
+    <AsNavButton className={classNames(css.NavLink, className)} {...props} ref={ref} />
+  )
+);
diff --git a/src/app/components/nav/NavItemContent.tsx b/src/app/components/nav/NavItemContent.tsx
new file mode 100644 (file)
index 0000000..dcd0edf
--- /dev/null
@@ -0,0 +1,10 @@
+import React, { ComponentProps } from 'react';
+import { Text, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const NavItemContent = as<'p', ComponentProps<typeof Text>>(
+  ({ className, ...props }, ref) => (
+    <Text className={classNames(css.NavItemContent, className)} size="T400" {...props} ref={ref} />
+  )
+);
diff --git a/src/app/components/nav/NavItemOptions.tsx b/src/app/components/nav/NavItemOptions.tsx
new file mode 100644 (file)
index 0000000..1c2ffd3
--- /dev/null
@@ -0,0 +1,17 @@
+import React, { ComponentProps } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const NavItemOptions = as<'div', ComponentProps<typeof Box>>(
+  ({ className, ...props }, ref) => (
+    <Box
+      className={classNames(css.NavItemOptions, className)}
+      alignItems="Center"
+      shrink="No"
+      gap="0"
+      {...props}
+      ref={ref}
+    />
+  )
+);
diff --git a/src/app/components/nav/index.ts b/src/app/components/nav/index.ts
new file mode 100644 (file)
index 0000000..1226744
--- /dev/null
@@ -0,0 +1,6 @@
+export * from './NavCategory';
+export * from './NavCategoryHeader';
+export * from './NavEmptyLayout';
+export * from './NavItem';
+export * from './NavItemContent';
+export * from './NavItemOptions';
diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts
new file mode 100644 (file)
index 0000000..06710dd
--- /dev/null
@@ -0,0 +1,127 @@
+import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
+
+export const NavCategory = style([
+  DefaultReset,
+  {
+    position: 'relative',
+  },
+]);
+
+export const NavCategoryHeader = style({
+  gap: config.space.S100,
+});
+
+export const NavLink = style({
+  color: 'inherit',
+  minWidth: 0,
+  display: 'flex',
+  alignItems: 'center',
+  cursor: 'pointer',
+  flexGrow: 1,
+  ':hover': {
+    textDecoration: 'unset',
+  },
+  ':focus': {
+    outline: 'none',
+  },
+});
+
+const Container = createVar();
+const ContainerHover = createVar();
+const ContainerActive = createVar();
+const ContainerLine = createVar();
+const OnContainer = createVar();
+
+const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
+  vars: {
+    [Container]: color[variant].Container,
+    [ContainerHover]: color[variant].ContainerHover,
+    [ContainerActive]: color[variant].ContainerActive,
+    [ContainerLine]: color[variant].ContainerLine,
+    [OnContainer]: color[variant].OnContainer,
+  },
+});
+
+const NavItemBase = style({
+  width: '100%',
+  display: 'flex',
+  justifyContent: 'start',
+  cursor: 'pointer',
+  backgroundColor: Container,
+  color: OnContainer,
+  outline: 'none',
+  minHeight: toRem(38),
+
+  selectors: {
+    '&:hover, &:focus-visible': {
+      backgroundColor: ContainerHover,
+    },
+    '&[data-hover=true]': {
+      backgroundColor: ContainerHover,
+    },
+    [`&:has(.${NavLink}:active)`]: {
+      backgroundColor: ContainerActive,
+    },
+    '&[aria-selected=true]': {
+      backgroundColor: ContainerActive,
+    },
+    [`&:has(.${NavLink}:focus-visible)`]: {
+      outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
+      outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
+    },
+  },
+  '@supports': {
+    [`not selector(:has(.${NavLink}:focus-visible))`]: {
+      ':focus-within': {
+        outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
+        outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
+      },
+    },
+  },
+});
+export const NavItem = recipe({
+  base: [DefaultReset, NavItemBase, Disabled],
+  variants: {
+    variant: {
+      Background: getVariant('Background'),
+      Surface: getVariant('Surface'),
+      SurfaceVariant: getVariant('SurfaceVariant'),
+      Primary: getVariant('Primary'),
+      Secondary: getVariant('Secondary'),
+      Success: getVariant('Success'),
+      Warning: getVariant('Warning'),
+      Critical: getVariant('Critical'),
+    },
+    radii: RadiiVariant,
+  },
+  defaultVariants: {
+    variant: 'Surface',
+    radii: '400',
+  },
+});
+
+export type RoomSelectorVariants = RecipeVariants<typeof NavItem>;
+export const NavItemContent = style({
+  paddingLeft: config.space.S200,
+  paddingRight: config.space.S300,
+  height: 'inherit',
+  minWidth: 0,
+  flexGrow: 1,
+  display: 'flex',
+  alignItems: 'center',
+
+  selectors: {
+    '&:hover': {
+      textDecoration: 'unset',
+    },
+    [`.${NavItemBase}[data-highlight=true] &`]: {
+      fontWeight: config.fontWeight.W600,
+    },
+  },
+});
+
+export const NavItemOptions = style({
+  paddingRight: config.space.S200,
+});
diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx
new file mode 100644 (file)
index 0000000..4ccb1ec
--- /dev/null
@@ -0,0 +1,146 @@
+import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
+import { Box, Header, Line, Scroll, Text, as } from 'folds';
+import classNames from 'classnames';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import * as css from './style.css';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+
+type PageRootProps = {
+  nav: ReactNode;
+  children: ReactNode;
+};
+
+export function PageRoot({ nav, children }: PageRootProps) {
+  const screenSize = useScreenSizeContext();
+
+  return (
+    <Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
+      {nav}
+      {screenSize !== ScreenSize.Mobile && (
+        <Line variant="Background" size="300" direction="Vertical" />
+      )}
+      {children}
+    </Box>
+  );
+}
+
+type ClientDrawerLayoutProps = {
+  children: ReactNode;
+};
+export function PageNav({ children }: ClientDrawerLayoutProps) {
+  const screenSize = useScreenSizeContext();
+  const isMobile = screenSize === ScreenSize.Mobile;
+
+  return (
+    <Box
+      grow={isMobile ? 'Yes' : undefined}
+      className={css.PageNav}
+      shrink={isMobile ? 'Yes' : 'No'}
+    >
+      <Box grow="Yes" direction="Column">
+        {children}
+      </Box>
+    </Box>
+  );
+}
+
+export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
+  <Header
+    className={classNames(css.PageNavHeader, className)}
+    variant="Background"
+    size="600"
+    {...props}
+    ref={ref}
+  />
+));
+
+export function PageNavContent({
+  scrollRef,
+  children,
+}: {
+  children: ReactNode;
+  scrollRef?: MutableRefObject<HTMLDivElement | null>;
+}) {
+  return (
+    <Box grow="Yes" direction="Column">
+      <Scroll
+        ref={scrollRef}
+        variant="Background"
+        direction="Vertical"
+        size="300"
+        hideTrack
+        visibility="Hover"
+      >
+        <div className={css.PageNavContent}>{children}</div>
+      </Scroll>
+    </Box>
+  );
+}
+
+export const Page = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    grow="Yes"
+    direction="Column"
+    className={classNames(ContainerColor({ variant: 'Surface' }), className)}
+    {...props}
+    ref={ref}
+  />
+));
+
+export const PageHeader = as<'div'>(({ className, ...props }, ref) => (
+  <Header
+    as="header"
+    size="600"
+    className={classNames(css.PageHeader, className)}
+    {...props}
+    ref={ref}
+  />
+));
+
+export const PageContent = as<'div'>(({ className, ...props }, ref) => (
+  <div className={classNames(css.PageContent, className)} {...props} ref={ref} />
+));
+
+export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
+  ({ className, ...props }, ref) => (
+    <Box
+      direction="Column"
+      className={classNames(css.PageHeroSection, className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export function PageHero({
+  icon,
+  title,
+  subTitle,
+  children,
+}: {
+  icon: ReactNode;
+  title: ReactNode;
+  subTitle: ReactNode;
+  children?: ReactNode;
+}) {
+  return (
+    <Box direction="Column" gap="400">
+      <Box direction="Column" alignItems="Center" gap="200">
+        {icon}
+      </Box>
+      <Box as="h2" direction="Column" gap="200" alignItems="Center">
+        <Text align="Center" size="H2">
+          {title}
+        </Text>
+        <Text align="Center" priority="400">
+          {subTitle}
+        </Text>
+      </Box>
+      {children}
+    </Box>
+  );
+}
+
+export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
+  <div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
+));
diff --git a/src/app/components/page/index.tsx b/src/app/components/page/index.tsx
new file mode 100644 (file)
index 0000000..d9925d7
--- /dev/null
@@ -0,0 +1 @@
+export * from './Page';
diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts
new file mode 100644 (file)
index 0000000..559d366
--- /dev/null
@@ -0,0 +1,69 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const PageNav = style({
+  width: toRem(280),
+});
+
+export const PageNavHeader = style({
+  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+  flexShrink: 0,
+  borderBottomWidth: 1,
+
+  selectors: {
+    'button&': {
+      cursor: 'pointer',
+    },
+    'button&[aria-pressed=true]': {
+      backgroundColor: color.Background.ContainerActive,
+    },
+    'button&:hover, button&:focus-visible': {
+      backgroundColor: color.Background.ContainerHover,
+    },
+    'button&:active': {
+      backgroundColor: color.Background.ContainerActive,
+    },
+  },
+});
+
+export const PageNavContent = style({
+  minHeight: '100%',
+  padding: config.space.S200,
+  paddingRight: 0,
+  paddingBottom: config.space.S700,
+});
+
+export const PageHeader = style({
+  paddingLeft: config.space.S400,
+  paddingRight: config.space.S200,
+  borderBottomWidth: config.borderWidth.B300,
+});
+
+export const PageContent = style([
+  DefaultReset,
+  {
+    paddingTop: config.space.S400,
+    paddingLeft: config.space.S400,
+    paddingRight: 0,
+    paddingBottom: toRem(100),
+  },
+]);
+
+export const PageHeroSection = style([
+  DefaultReset,
+  {
+    padding: '40px 0',
+    maxWidth: toRem(466),
+    width: '100%',
+    margin: 'auto',
+  },
+]);
+
+export const PageContentCenter = style([
+  DefaultReset,
+  {
+    maxWidth: toRem(964),
+    width: '100%',
+    margin: 'auto',
+  },
+]);
diff --git a/src/app/components/room-avatar/RoomAvatar.css.ts b/src/app/components/room-avatar/RoomAvatar.css.ts
new file mode 100644 (file)
index 0000000..c9745a9
--- /dev/null
@@ -0,0 +1,14 @@
+import { style } from '@vanilla-extract/css';
+import { color } from 'folds';
+
+export const RoomAvatar = style({
+  backgroundColor: color.Secondary.Container,
+  color: color.Secondary.OnContainer,
+  textTransform: 'capitalize',
+
+  selectors: {
+    '&[data-image-loaded="true"]': {
+      backgroundColor: 'transparent',
+    },
+  },
+});
diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx
new file mode 100644 (file)
index 0000000..23f3998
--- /dev/null
@@ -0,0 +1,56 @@
+import { JoinRule } from 'matrix-js-sdk';
+import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
+import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
+import * as css from './RoomAvatar.css';
+import { joinRuleToIconSrc } from '../../utils/room';
+import colorMXID from '../../../util/colorMXID';
+
+type RoomAvatarProps = {
+  roomId: string;
+  src?: string;
+  alt?: string;
+  renderFallback: () => ReactNode;
+};
+export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
+  const [error, setError] = useState(false);
+
+  const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
+    evt.currentTarget.setAttribute('data-image-loaded', 'true');
+  };
+
+  if (!src || error) {
+    return (
+      <AvatarFallback
+        style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
+        className={css.RoomAvatar}
+      >
+        {renderFallback()}
+      </AvatarFallback>
+    );
+  }
+
+  return (
+    <AvatarImage
+      className={css.RoomAvatar}
+      src={src}
+      alt={alt}
+      onError={() => setError(true)}
+      onLoad={handleLoad}
+      draggable={false}
+    />
+  );
+}
+
+export const RoomIcon = forwardRef<
+  SVGSVGElement,
+  Omit<ComponentProps<typeof Icon>, 'src'> & {
+    joinRule: JoinRule;
+    space?: boolean;
+  }
+>(({ joinRule, space, ...props }, ref) => (
+  <Icon
+    src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
+    {...props}
+    ref={ref}
+  />
+));
diff --git a/src/app/components/room-avatar/index.ts b/src/app/components/room-avatar/index.ts
new file mode 100644 (file)
index 0000000..dde886c
--- /dev/null
@@ -0,0 +1 @@
+export * from './RoomAvatar';
diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx
new file mode 100644 (file)
index 0000000..370b790
--- /dev/null
@@ -0,0 +1,314 @@
+import React, { ReactNode, useCallback, useRef, useState } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import {
+  Avatar,
+  Badge,
+  Box,
+  Button,
+  Dialog,
+  Icon,
+  Icons,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  as,
+  color,
+  config,
+} from 'folds';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+import * as css from './style.css';
+import { RoomAvatar } from '../room-avatar';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { nameInitials } from '../../utils/common';
+import { millify } from '../../plugins/millify';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { onEnterOrSpace } from '../../utils/keyboard';
+import { RoomType, StateEvent } from '../../../types/matrix/room';
+import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
+import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
+import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
+import { useStateEventCallback } from '../../hooks/useStateEventCallback';
+
+type GridColumnCount = '1' | '2' | '3';
+const getGridColumnCount = (gridWidth: number): GridColumnCount => {
+  if (gridWidth <= 498) return '1';
+  if (gridWidth <= 748) return '2';
+  return '3';
+};
+
+const setGridColumnCount = (grid: HTMLElement, count: GridColumnCount): void => {
+  grid.style.setProperty('grid-template-columns', `repeat(${count}, 1fr)`);
+};
+
+export function RoomCardGrid({ children }: { children: ReactNode }) {
+  const gridRef = useRef<HTMLDivElement>(null);
+
+  useElementSizeObserver(
+    useCallback(() => gridRef.current, []),
+    useCallback((width, _, target) => setGridColumnCount(target, getGridColumnCount(width)), [])
+  );
+
+  return (
+    <Box className={css.CardGrid} direction="Row" gap="400" wrap="Wrap" ref={gridRef}>
+      {children}
+    </Box>
+  );
+}
+
+export const RoomCardBase = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    direction="Column"
+    gap="300"
+    className={classNames(css.RoomCardBase, className)}
+    {...props}
+    ref={ref}
+  />
+));
+
+export const RoomCardName = as<'h6'>(({ ...props }, ref) => (
+  <Text as="h6" size="H6" truncate {...props} ref={ref} />
+));
+
+export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => (
+  <Text
+    as="p"
+    size="T200"
+    className={classNames(css.RoomCardTopic, className)}
+    {...props}
+    priority="400"
+    ref={ref}
+  />
+));
+
+function ErrorDialog({
+  title,
+  message,
+  children,
+}: {
+  title: string;
+  message: string;
+  children: (openError: () => void) => ReactNode;
+}) {
+  const [viewError, setViewError] = useState(false);
+  const closeError = () => setViewError(false);
+  const openError = () => setViewError(true);
+
+  return (
+    <>
+      {children(openError)}
+      <Overlay open={viewError} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              clickOutsideDeactivates: true,
+              onDeactivate: closeError,
+            }}
+          >
+            <Dialog variant="Surface">
+              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+                <Box direction="Column" gap="100">
+                  <Text>{title}</Text>
+                  <Text style={{ color: color.Critical.Main }} size="T300" priority="400">
+                    {message}
+                  </Text>
+                </Box>
+                <Button size="400" variant="Secondary" fill="Soft" onClick={closeError}>
+                  <Text size="B400">Cancel</Text>
+                </Button>
+              </Box>
+            </Dialog>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+    </>
+  );
+}
+
+type RoomCardProps = {
+  roomIdOrAlias: string;
+  allRooms: string[];
+  avatarUrl?: string;
+  name?: string;
+  topic?: string;
+  memberCount?: number;
+  roomType?: string;
+  onView?: (roomId: string) => void;
+  renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
+};
+
+export const RoomCard = as<'div', RoomCardProps>(
+  (
+    {
+      roomIdOrAlias,
+      allRooms,
+      avatarUrl,
+      name,
+      topic,
+      memberCount,
+      roomType,
+      onView,
+      renderTopicViewer,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
+    const joinedRoom = mx.getRoom(joinedRoomId);
+    const [topicEvent, setTopicEvent] = useState(() =>
+      joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
+    );
+
+    const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
+    const fallbackTopic = roomIdOrAlias;
+
+    const avatar = joinedRoom
+      ? getRoomAvatarUrl(mx, joinedRoom, 96)
+      : avatarUrl && mx.mxcUrlToHttp(avatarUrl, 96, 96, 'crop');
+
+    const roomName = joinedRoom?.name || name || fallbackName;
+    const roomTopic =
+      (topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
+    const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
+
+    useStateEventCallback(
+      mx,
+      useCallback(
+        (event) => {
+          if (
+            joinedRoom &&
+            event.getRoomId() === joinedRoom.roomId &&
+            event.getType() === StateEvent.RoomTopic
+          ) {
+            setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic));
+          }
+        },
+        [joinedRoom]
+      )
+    );
+
+    const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
+      useCallback(() => mx.joinRoom(roomIdOrAlias), [mx, roomIdOrAlias])
+    );
+    const joining =
+      joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
+
+    const [viewTopic, setViewTopic] = useState(false);
+    const closeTopic = () => setViewTopic(false);
+    const openTopic = () => setViewTopic(true);
+
+    return (
+      <RoomCardBase {...props} ref={ref}>
+        <Box gap="200" justifyContent="SpaceBetween">
+          <Avatar size="500">
+            <RoomAvatar
+              roomId={roomIdOrAlias}
+              src={avatar ?? undefined}
+              alt={roomIdOrAlias}
+              renderFallback={() => (
+                <Text as="span" size="H3">
+                  {nameInitials(roomName)}
+                </Text>
+              )}
+            />
+          </Avatar>
+          {(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
+            <Badge variant="Secondary" fill="Soft" outlined>
+              <Text size="L400">Space</Text>
+            </Badge>
+          )}
+        </Box>
+        <Box grow="Yes" direction="Column" gap="100">
+          <RoomCardName>{roomName}</RoomCardName>
+          <RoomCardTopic onClick={openTopic} onKeyDown={onEnterOrSpace(openTopic)} tabIndex={0}>
+            {roomTopic}
+          </RoomCardTopic>
+
+          <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  clickOutsideDeactivates: true,
+                  onDeactivate: closeTopic,
+                }}
+              >
+                {renderTopicViewer(roomName, roomTopic, closeTopic)}
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        </Box>
+        {typeof joinedMemberCount === 'number' && (
+          <Box gap="100">
+            <Icon size="50" src={Icons.User} />
+            <Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
+          </Box>
+        )}
+        {typeof joinedRoomId === 'string' && (
+          <Button
+            onClick={onView ? () => onView(joinedRoomId) : undefined}
+            variant="Secondary"
+            fill="Soft"
+            size="300"
+          >
+            <Text size="B300" truncate>
+              View
+            </Text>
+          </Button>
+        )}
+        {typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
+          <Button
+            onClick={join}
+            variant="Secondary"
+            size="300"
+            disabled={joining}
+            before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
+          >
+            <Text size="B300" truncate>
+              {joining ? 'Joining' : 'Join'}
+            </Text>
+          </Button>
+        )}
+        {typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
+          <Box gap="200">
+            <Button
+              onClick={join}
+              className={css.ActionButton}
+              variant="Critical"
+              fill="Solid"
+              size="300"
+            >
+              <Text size="B300" truncate>
+                Retry
+              </Text>
+            </Button>
+            <ErrorDialog
+              title="Join Error"
+              message={joinState.error.message || 'Failed to join. Unknown Error.'}
+            >
+              {(openError) => (
+                <Button
+                  onClick={openError}
+                  className={css.ActionButton}
+                  variant="Critical"
+                  fill="Soft"
+                  outlined
+                  size="300"
+                >
+                  <Text size="B300" truncate>
+                    View Error
+                  </Text>
+                </Button>
+              )}
+            </ErrorDialog>
+          </Box>
+        )}
+      </RoomCardBase>
+    );
+  }
+);
diff --git a/src/app/components/room-card/index.ts b/src/app/components/room-card/index.ts
new file mode 100644 (file)
index 0000000..f06ebed
--- /dev/null
@@ -0,0 +1 @@
+export * from './RoomCard';
diff --git a/src/app/components/room-card/style.css.ts b/src/app/components/room-card/style.css.ts
new file mode 100644 (file)
index 0000000..b15acfe
--- /dev/null
@@ -0,0 +1,36 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config } from 'folds';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+
+export const CardGrid = style({
+  display: 'grid',
+  gridTemplateColumns: 'repeat(3, 1fr)',
+  gap: config.space.S400,
+});
+
+export const RoomCardBase = style([
+  DefaultReset,
+  ContainerColor({ variant: 'SurfaceVariant' }),
+  {
+    padding: config.space.S500,
+    borderRadius: config.radii.R500,
+  },
+]);
+
+export const RoomCardTopic = style({
+  minHeight: `calc(3 * ${config.lineHeight.T200})`,
+  display: '-webkit-box',
+  WebkitLineClamp: 3,
+  WebkitBoxOrient: 'vertical',
+  overflow: 'hidden',
+  cursor: 'pointer',
+
+  ':hover': {
+    textDecoration: 'underline',
+  },
+});
+
+export const ActionButton = style({
+  flex: '1 1 0',
+  minWidth: 1,
+});
index 863c7cf01884cb2624a12e64af5abbe1da3e2ce4..0fed123d353733b78bddfd937b0851d62c855a8d 100644 (file)
@@ -1,14 +1,19 @@
 import React, { useCallback } from 'react';
-import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
+import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
 import { Room } from 'matrix-js-sdk';
-import { openInviteUser, selectRoom } from '../../../client/action/navigation';
-import { useStateEvent } from '../../hooks/useStateEvent';
+import { useAtomValue } from 'jotai';
+import { openInviteUser } from '../../../client/action/navigation';
 import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
 import { getMemberDisplayName, getStateEvent } from '../../utils/room';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { getMxIdLocalPart } from '../../utils/matrix';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { RoomAvatar } from '../room-avatar';
+import { nameInitials } from '../../utils/common';
+import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
 
 export type RoomIntroProps = {
   room: Room;
@@ -16,21 +21,21 @@ export type RoomIntroProps = {
 
 export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
   const mx = useMatrixClient();
+  const { navigateRoom } = useRoomNavigate();
+  const mDirects = useAtomValue(mDirectAtom);
+
   const createEvent = getStateEvent(room, StateEvent.RoomCreate);
-  const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
-  const nameEvent = useStateEvent(room, StateEvent.RoomName);
-  const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
-  const createContent = createEvent?.getContent<IRoomCreateContent>();
+  const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
+  const name = useRoomName(room);
+  const topic = useRoomTopic(room);
+  const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
 
+  const createContent = createEvent?.getContent<IRoomCreateContent>();
   const ts = createEvent?.getTs();
   const creatorId = createEvent?.getSender();
   const creatorName =
     creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
   const prevRoomId = createContent?.predecessor?.room_id;
-  const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
-  const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
-  const name = (nameEvent?.getContent().name || room.name) as string;
-  const topic = (topicEvent?.getContent().topic as string) || undefined;
 
   const [prevRoomState, joinPrevRoom] = useAsyncCallback(
     useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
@@ -40,18 +45,12 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
     <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
       <Box>
         <Avatar size="500">
-          {avatarHttpUrl ? (
-            <AvatarImage src={avatarHttpUrl} alt={name} />
-          ) : (
-            <AvatarFallback
-              style={{
-                backgroundColor: color.SurfaceVariant.Container,
-                color: color.SurfaceVariant.OnContainer,
-              }}
-            >
-              <Text size="H2">{name[0]}</Text>
-            </AvatarFallback>
-          )}
+          <RoomAvatar
+            roomId={room.roomId}
+            src={avatarHttpUrl ?? undefined}
+            alt={name}
+            renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
+          />
         </Avatar>
       </Box>
       <Box direction="Column" gap="300">
@@ -82,7 +81,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
           {typeof prevRoomId === 'string' &&
             (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
               <Button
-                onClick={() => selectRoom(prevRoomId)}
+                onClick={() => navigateRoom(prevRoomId)}
                 variant="Success"
                 size="300"
                 fill="Soft"
diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx
new file mode 100644 (file)
index 0000000..5212877
--- /dev/null
@@ -0,0 +1,41 @@
+import React from 'react';
+import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
+import classNames from 'classnames';
+import Linkify from 'linkify-react';
+import * as css from './style.css';
+import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
+
+export const RoomTopicViewer = as<
+  'div',
+  {
+    name: string;
+    topic: string;
+    requestClose: () => void;
+  }
+>(({ name, topic, requestClose, className, ...props }, ref) => (
+  <Modal
+    size="300"
+    flexHeight
+    className={classNames(css.ModalFlex, className)}
+    {...props}
+    ref={ref}
+  >
+    <Header className={css.ModalHeader} variant="Surface" size="500">
+      <Box grow="Yes">
+        <Text size="H4" truncate>
+          {name}
+        </Text>
+      </Box>
+      <IconButton size="300" onClick={requestClose} radii="300">
+        <Icon src={Icons.Cross} />
+      </IconButton>
+    </Header>
+    <Scroll className={css.ModalScroll} size="300" hideTrack>
+      <Box className={css.ModalContent} direction="Column" gap="100">
+        <Text size="T300" className={css.ModalTopic} priority="400">
+          <Linkify options={LINKIFY_OPTS}>{scaleSystemEmoji(topic)}</Linkify>
+        </Text>
+      </Box>
+    </Scroll>
+  </Modal>
+));
diff --git a/src/app/components/room-topic-viewer/index.ts b/src/app/components/room-topic-viewer/index.ts
new file mode 100644 (file)
index 0000000..82cb004
--- /dev/null
@@ -0,0 +1 @@
+export * from './RoomTopicViewer';
diff --git a/src/app/components/room-topic-viewer/style.css.ts b/src/app/components/room-topic-viewer/style.css.ts
new file mode 100644 (file)
index 0000000..d943500
--- /dev/null
@@ -0,0 +1,23 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const ModalFlex = style({
+  display: 'flex',
+  flexDirection: 'column',
+});
+export const ModalHeader = style({
+  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+  borderBottomWidth: config.borderWidth.B300,
+});
+export const ModalScroll = style({
+  flexGrow: 1,
+});
+export const ModalContent = style({
+  padding: config.space.S400,
+  paddingRight: config.space.S200,
+  paddingBottom: config.space.S700,
+});
+export const ModalTopic = style({
+  whiteSpace: 'pre-wrap',
+  wordBreak: 'break-word',
+});
diff --git a/src/app/components/scroll-top-container/ScrollTopContainer.tsx b/src/app/components/scroll-top-container/ScrollTopContainer.tsx
new file mode 100644 (file)
index 0000000..e91d058
--- /dev/null
@@ -0,0 +1,39 @@
+import React, { RefObject, useCallback, useState } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './style.css';
+import {
+  getIntersectionObserverEntry,
+  useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+
+export const ScrollTopContainer = as<
+  'div',
+  {
+    scrollRef?: RefObject<HTMLElement>;
+    anchorRef: RefObject<HTMLElement>;
+    onVisibilityChange?: (onTop: boolean) => void;
+  }
+>(({ className, scrollRef, anchorRef, onVisibilityChange, ...props }, ref) => {
+  const [onTop, setOnTop] = useState(true);
+
+  useIntersectionObserver(
+    useCallback(
+      (intersectionEntries) => {
+        if (!anchorRef.current) return;
+        const entry = getIntersectionObserverEntry(anchorRef.current, intersectionEntries);
+        if (entry) {
+          setOnTop(entry.isIntersecting);
+          onVisibilityChange?.(entry.isIntersecting);
+        }
+      },
+      [anchorRef, onVisibilityChange]
+    ),
+    useCallback(() => ({ root: scrollRef?.current }), [scrollRef]),
+    useCallback(() => anchorRef.current, [anchorRef])
+  );
+
+  if (onTop) return null;
+
+  return <Box className={classNames(css.ScrollTopContainer, className)} {...props} ref={ref} />;
+});
diff --git a/src/app/components/scroll-top-container/index.ts b/src/app/components/scroll-top-container/index.ts
new file mode 100644 (file)
index 0000000..392e1b2
--- /dev/null
@@ -0,0 +1 @@
+export * from './ScrollTopContainer';
diff --git a/src/app/components/scroll-top-container/style.css.ts b/src/app/components/scroll-top-container/style.css.ts
new file mode 100644 (file)
index 0000000..39a3cc9
--- /dev/null
@@ -0,0 +1,20 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+const ScrollContainerAnime = keyframes({
+  '0%': {
+    transform: `translate(-50%, -100%) scale(0)`,
+  },
+  '100%': {
+    transform: `translate(-50%, 0) scale(1)`,
+  },
+});
+
+export const ScrollTopContainer = style({
+  position: 'absolute',
+  top: config.space.S200,
+  left: '50%',
+  transform: 'translateX(-50%)',
+  zIndex: config.zIndex.Z100,
+  animation: `${ScrollContainerAnime} 100ms`,
+});
diff --git a/src/app/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx
new file mode 100644 (file)
index 0000000..4036b96
--- /dev/null
@@ -0,0 +1,18 @@
+import React, { ComponentProps } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import { ContainerColor, ContainerColorVariants } from '../../styles/ContainerColor.css';
+import * as css from './style.css';
+
+export const SequenceCard = as<
+  'div',
+  ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
+>(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
+  <Box
+    className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)}
+    data-first-child={firstChild}
+    data-last-child={lastChild}
+    {...props}
+    ref={ref}
+  />
+));
diff --git a/src/app/components/sequence-card/index.ts b/src/app/components/sequence-card/index.ts
new file mode 100644 (file)
index 0000000..98ec5b7
--- /dev/null
@@ -0,0 +1 @@
+export * from './SequenceCard';
diff --git a/src/app/components/sequence-card/style.css.ts b/src/app/components/sequence-card/style.css.ts
new file mode 100644 (file)
index 0000000..c8ed48b
--- /dev/null
@@ -0,0 +1,52 @@
+import { createVar } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { config } from 'folds';
+
+const outlinedWidth = createVar('0');
+export const SequenceCard = recipe({
+  base: {
+    vars: {
+      [outlinedWidth]: '0',
+    },
+    borderStyle: 'solid',
+    borderWidth: outlinedWidth,
+    borderBottomWidth: 0,
+    selectors: {
+      '&:first-child, :not(&) + &': {
+        borderTopLeftRadius: config.radii.R400,
+        borderTopRightRadius: config.radii.R400,
+      },
+      '&:last-child, &:not(:has(+&))': {
+        borderBottomLeftRadius: config.radii.R400,
+        borderBottomRightRadius: config.radii.R400,
+        borderBottomWidth: outlinedWidth,
+      },
+      [`&[data-first-child="true"]`]: {
+        borderTopLeftRadius: config.radii.R400,
+        borderTopRightRadius: config.radii.R400,
+      },
+      [`&[data-first-child="false"]`]: {
+        borderTopLeftRadius: 0,
+        borderTopRightRadius: 0,
+      },
+      [`&[data-last-child="true"]`]: {
+        borderBottomLeftRadius: config.radii.R400,
+        borderBottomRightRadius: config.radii.R400,
+      },
+      [`&[data-last-child="false"]`]: {
+        borderBottomLeftRadius: 0,
+        borderBottomRightRadius: 0,
+      },
+    },
+  },
+  variants: {
+    outlined: {
+      true: {
+        vars: {
+          [outlinedWidth]: config.borderWidth.B300,
+        },
+      },
+    },
+  },
+});
+export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
index e62ed6f55c136fe10a748cf19c12f373b830d107..c36862232bb61dd5e17101b9921b4a6f5ec17b54 100644 (file)
@@ -1,6 +1,7 @@
-import { style } from '@vanilla-extract/css';
+import { createVar, style } from '@vanilla-extract/css';
 import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
-import { color, config, DefaultReset, toRem } from 'folds';
+import { color, config, DefaultReset, Disabled, FocusOutline, toRem } from 'folds';
+import { ContainerColor } from '../../styles/ContainerColor.css';
 
 export const Sidebar = style([
   DefaultReset,
@@ -28,13 +29,49 @@ export const SidebarStack = style([
   },
 ]);
 
+const DropLineDist = createVar();
+export const DropTarget = style({
+  vars: {
+    [DropLineDist]: toRem(-8),
+  },
+
+  selectors: {
+    '&[data-inside-folder=true]': {
+      vars: {
+        [DropLineDist]: toRem(-6),
+      },
+    },
+    '&[data-drop-child=true]': {
+      outline: `${config.borderWidth.B700} solid ${color.Success.Main}`,
+      borderRadius: config.radii.R400,
+    },
+    '&[data-drop-above=true]::after, &[data-drop-below=true]::after': {
+      content: '',
+      display: 'block',
+      position: 'absolute',
+      left: toRem(0),
+      width: '100%',
+      height: config.borderWidth.B700,
+      backgroundColor: color.Success.Main,
+    },
+    '&[data-drop-above=true]::after': {
+      top: DropLineDist,
+    },
+    '&[data-drop-below=true]::after': {
+      bottom: DropLineDist,
+    },
+  },
+});
+
 const PUSH_X = 2;
-export const SidebarAvatarBox = recipe({
+export const SidebarItem = recipe({
   base: [
     DefaultReset,
     {
+      minWidth: toRem(42),
       display: 'flex',
       alignItems: 'center',
+      justifyContent: 'center',
       position: 'relative',
       transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
 
@@ -59,6 +96,8 @@ export const SidebarAvatarBox = recipe({
         },
       },
     },
+    Disabled,
+    DropTarget,
   ],
   variants: {
     active: {
@@ -76,26 +115,27 @@ export const SidebarAvatarBox = recipe({
     },
   },
 });
+export type SidebarItemVariants = RecipeVariants<typeof SidebarItem>;
 
-export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
-
-export const SidebarBadgeBox = recipe({
+export const SidebarItemBadge = recipe({
   base: [
     DefaultReset,
     {
+      pointerEvents: 'none',
       position: 'absolute',
       zIndex: 1,
+      lineHeight: 0,
     },
   ],
   variants: {
     hasCount: {
       true: {
         top: toRem(-6),
-        right: toRem(-6),
+        left: toRem(-6),
       },
       false: {
         top: toRem(-2),
-        right: toRem(-2),
+        left: toRem(-2),
       },
     },
   },
@@ -103,9 +143,107 @@ export const SidebarBadgeBox = recipe({
     hasCount: false,
   },
 });
+export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
+
+export const SidebarAvatar = recipe({
+  base: [
+    {
+      selectors: {
+        'button&': {
+          cursor: 'pointer',
+        },
+      },
+    },
+  ],
+  variants: {
+    size: {
+      '200': {
+        width: toRem(16),
+        height: toRem(16),
+        fontSize: toRem(10),
+        lineHeight: config.lineHeight.T200,
+        letterSpacing: config.letterSpacing.T200,
+      },
+      '300': {
+        width: toRem(34),
+        height: toRem(34),
+      },
+      '400': {
+        width: toRem(42),
+        height: toRem(42),
+      },
+    },
+    outlined: {
+      true: {
+        border: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
+      },
+    },
+  },
+  defaultVariants: {
+    size: '400',
+  },
+});
+export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
 
-export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
+export const SidebarFolder = recipe({
+  base: [
+    ContainerColor({ variant: 'Background' }),
+    {
+      padding: config.space.S100,
+      width: toRem(42),
+      minHeight: toRem(42),
+      display: 'flex',
+      flexWrap: 'wrap',
+      outline: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
+      position: 'relative',
 
-export const SidebarBadgeOutline = style({
-  boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
+      selectors: {
+        'button&': {
+          cursor: 'pointer',
+        },
+      },
+    },
+    FocusOutline,
+    DropTarget,
+  ],
+  variants: {
+    state: {
+      Close: {
+        gap: toRem(2),
+        borderRadius: config.radii.R400,
+      },
+      Open: {
+        paddingLeft: 0,
+        paddingRight: 0,
+        flexDirection: 'column',
+        alignItems: 'center',
+        gap: config.space.S200,
+        borderRadius: config.radii.R500,
+      },
+    },
+  },
+  defaultVariants: {
+    state: 'Close',
+  },
+});
+export type SidebarFolderVariants = RecipeVariants<typeof SidebarFolder>;
+
+export const SidebarFolderDropTarget = recipe({
+  base: {
+    width: '100%',
+    height: toRem(8),
+    position: 'absolute',
+    left: 0,
+  },
+  variants: {
+    position: {
+      Top: {
+        top: toRem(-4),
+      },
+      Bottom: {
+        bottom: toRem(-4),
+      },
+    },
+  },
 });
+export type SidebarFolderDropTargetVariants = RecipeVariants<typeof SidebarFolderDropTarget>;
diff --git a/src/app/components/sidebar/SidebarAvatar.tsx b/src/app/components/sidebar/SidebarAvatar.tsx
deleted file mode 100644 (file)
index 86665f7..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-import classNames from 'classnames';
-import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds';
-import React, { forwardRef, MouseEventHandler, ReactNode } from 'react';
-import * as css from './Sidebar.css';
-
-const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>(
-  ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
-    <AsSidebarAvatarBox
-      className={classNames(css.SidebarAvatarBox({ active }), className)}
-      {...props}
-      ref={ref}
-    />
-  )
-);
-
-export const SidebarAvatar = forwardRef<
-  HTMLDivElement,
-  css.SidebarAvatarBoxVariants &
-    css.SidebarBadgeBoxVariants & {
-      outlined?: boolean;
-      avatarChildren: ReactNode;
-      tooltip: ReactNode | string;
-      notificationBadge?: (badgeClassName: string) => ReactNode;
-      onClick?: MouseEventHandler<HTMLButtonElement>;
-      onContextMenu?: MouseEventHandler<HTMLButtonElement>;
-    }
->(
-  (
-    {
-      active,
-      hasCount,
-      outlined,
-      avatarChildren,
-      tooltip,
-      notificationBadge,
-      onClick,
-      onContextMenu,
-    },
-    ref
-  ) => (
-    <SidebarAvatarBox active={active} ref={ref}>
-      <TooltipProvider
-        delay={0}
-        position="Right"
-        tooltip={
-          <Tooltip>
-            <Text size="T300">{tooltip}</Text>
-          </Tooltip>
-        }
-      >
-        {(avRef) => (
-          <Avatar
-            ref={avRef}
-            as="button"
-            onClick={onClick}
-            onContextMenu={onContextMenu}
-            style={{
-              border: outlined
-                ? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
-                : undefined,
-              cursor: 'pointer',
-            }}
-          >
-            {avatarChildren}
-          </Avatar>
-        )}
-      </TooltipProvider>
-      {notificationBadge && (
-        <Box className={css.SidebarBadgeBox({ hasCount })}>
-          {notificationBadge(css.SidebarBadgeOutline)}
-        </Box>
-      )}
-    </SidebarAvatarBox>
-  )
-);
index 4f4058718ea133d2ca21058f1fb68027feceacec..b09ea64a738d5d1f32c9b5c26571c6ecfd4de44d 100644 (file)
@@ -1,5 +1,5 @@
 import React, { ReactNode } from 'react';
-import { Box, Scroll } from 'folds';
+import { Box } from 'folds';
 
 type SidebarContentProps = {
   scrollable: ReactNode;
@@ -9,9 +9,7 @@ export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
   return (
     <>
       <Box direction="Column" grow="Yes">
-        <Scroll variant="Background" size="0">
-          {scrollable}
-        </Scroll>
+        {scrollable}
       </Box>
       <Box direction="Column" shrink="No">
         {sticky}
diff --git a/src/app/components/sidebar/SidebarItem.tsx b/src/app/components/sidebar/SidebarItem.tsx
new file mode 100644 (file)
index 0000000..b23772b
--- /dev/null
@@ -0,0 +1,81 @@
+import classNames from 'classnames';
+import { as, Avatar, Text, Tooltip, TooltipProvider, toRem } from 'folds';
+import React, { ComponentProps, ReactNode, RefCallback } from 'react';
+import * as css from './Sidebar.css';
+
+export const SidebarItem = as<'div', css.SidebarItemVariants>(
+  ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
+    <AsSidebarAvatarBox
+      className={classNames(css.SidebarItem({ active }), className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export const SidebarItemBadge = as<'div', css.SidebarItemBadgeVariants>(
+  ({ as: AsSidebarBadgeBox = 'div', className, hasCount, ...props }, ref) => (
+    <AsSidebarBadgeBox
+      className={classNames(css.SidebarItemBadge({ hasCount }), className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export function SidebarItemTooltip({
+  tooltip,
+  children,
+}: {
+  tooltip?: ReactNode | string;
+  children: (triggerRef: RefCallback<HTMLElement | SVGElement>) => ReactNode;
+}) {
+  if (!tooltip) {
+    return children(() => undefined);
+  }
+
+  return (
+    <TooltipProvider
+      delay={400}
+      position="Right"
+      tooltip={
+        <Tooltip style={{ maxWidth: toRem(280) }}>
+          <Text size="H5">{tooltip}</Text>
+        </Tooltip>
+      }
+    >
+      {children}
+    </TooltipProvider>
+  );
+}
+
+export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProps<typeof Avatar>>(
+  ({ className, size, outlined, radii, ...props }, ref) => (
+    <Avatar
+      className={classNames(css.SidebarAvatar({ size, outlined }), className)}
+      radii={radii}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
+  ({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
+    <AsSidebarFolder
+      className={classNames(css.SidebarFolder({ state }), className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export const SidebarFolderDropTarget = as<'div', css.SidebarFolderDropTargetVariants>(
+  ({ as: AsSidebarFolderDropTarget = 'div', className, position, ...props }, ref) => (
+    <AsSidebarFolderDropTarget
+      className={classNames(css.SidebarFolderDropTarget({ position }), className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
index f744628ec8737f7f9b323e9c3b3e2c8c2bfa7feb..49e15b3e10edfd7596ede69502589995360383dd 100644 (file)
@@ -1,5 +1,5 @@
 export * from './Sidebar';
-export * from './SidebarAvatar';
+export * from './SidebarItem';
 export * from './SidebarContent';
 export * from './SidebarStack';
 export * from './SidebarStackSeparator';
index 63a855628d5684252b5371e8bd73926718fb93bb..662fe0556e76887416d66e97a033ab4a51ac7f88 100644 (file)
@@ -17,10 +17,14 @@ export const TypingDot = recipe({
       backgroundColor: 'currentColor',
       borderRadius: '50%',
       transform: 'translateY(15%)',
-      animation: `${TypingDotAnime} 0.6s infinite alternate`,
     },
   ],
   variants: {
+    animated: {
+      true: {
+        animation: `${TypingDotAnime} 0.6s infinite alternate`,
+      },
+    },
     size: {
       '300': {
         width: toRem(4),
@@ -45,5 +49,6 @@ export const TypingDot = recipe({
   },
   defaultVariants: {
     size: '400',
+    animated: true,
   },
 });
index e52558738e44730efc0d6e0e822c71eb15c190a9..f8daa45e5c83b697405f0929ea76bae99dbd1224 100644 (file)
@@ -4,19 +4,22 @@ import * as css from './TypingIndicator.css';
 
 export type TypingIndicatorProps = {
   size?: '300' | '400';
+  disableAnimation?: boolean;
 };
 
-export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
-  <Box
-    as="span"
-    alignItems="Center"
-    shrink="No"
-    style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
-    {...props}
-    ref={ref}
-  >
-    <span className={css.TypingDot({ size, index: '0' })} />
-    <span className={css.TypingDot({ size, index: '1' })} />
-    <span className={css.TypingDot({ size, index: '2' })} />
-  </Box>
-));
+export const TypingIndicator = as<'div', TypingIndicatorProps>(
+  ({ size, disableAnimation, style, ...props }, ref) => (
+    <Box
+      as="span"
+      alignItems="Center"
+      shrink="No"
+      style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
+      {...props}
+      ref={ref}
+    >
+      <span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
+      <span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
+      <span className={css.TypingDot({ size, index: '2', animated: !disableAnimation })} />
+    </Box>
+  )
+);
diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx
new file mode 100644 (file)
index 0000000..f5dc09f
--- /dev/null
@@ -0,0 +1,36 @@
+import React, { CSSProperties, ReactNode } from 'react';
+import { Box, Badge, toRem, Text } from 'folds';
+import { millify } from '../../plugins/millify';
+
+type UnreadBadgeProps = {
+  highlight?: boolean;
+  count: number;
+};
+const styles: CSSProperties = {
+  minWidth: toRem(16),
+};
+export function UnreadBadgeCenter({ children }: { children: ReactNode }) {
+  return (
+    <Box as="span" style={styles} shrink="No" alignItems="Center" justifyContent="Center">
+      {children}
+    </Box>
+  );
+}
+
+export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
+  return (
+    <Badge
+      variant={highlight ? 'Success' : 'Secondary'}
+      size={count > 0 ? '400' : '200'}
+      fill={count > 0 ? 'Solid' : 'Soft'}
+      radii="Pill"
+      outlined
+    >
+      {count > 0 && (
+        <Text as="span" size="L400">
+          {millify(count)}
+        </Text>
+      )}
+    </Badge>
+  );
+}
diff --git a/src/app/components/unread-badge/index.ts b/src/app/components/unread-badge/index.ts
new file mode 100644 (file)
index 0000000..7d77fe9
--- /dev/null
@@ -0,0 +1 @@
+export * from './UnreadBadge';
diff --git a/src/app/components/url-preview/UrlPreviewCard.css.tsx b/src/app/components/url-preview/UrlPreviewCard.css.tsx
new file mode 100644 (file)
index 0000000..ff71154
--- /dev/null
@@ -0,0 +1,47 @@
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, toRem } from 'folds';
+
+export const UrlPreviewHolderGradient = recipe({
+  base: [
+    DefaultReset,
+    {
+      position: 'absolute',
+      height: '100%',
+      width: toRem(10),
+      zIndex: 1,
+    },
+  ],
+  variants: {
+    position: {
+      Left: {
+        left: 0,
+        background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
+      },
+      Right: {
+        right: 0,
+        background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
+      },
+    },
+  },
+});
+export const UrlPreviewHolderBtn = recipe({
+  base: [
+    DefaultReset,
+    {
+      position: 'absolute',
+      zIndex: 1,
+    },
+  ],
+  variants: {
+    position: {
+      Left: {
+        left: 0,
+        transform: 'translateX(-25%)',
+      },
+      Right: {
+        right: 0,
+        transform: 'translateX(25%)',
+      },
+    },
+  },
+});
diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx
new file mode 100644 (file)
index 0000000..fc9229f
--- /dev/null
@@ -0,0 +1,181 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { IPreviewUrlResponse } from 'matrix-js-sdk';
+import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
+import {
+  getIntersectionObserverEntry,
+  useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import * as css from './UrlPreviewCard.css';
+
+const linkStyles = { color: color.Success.Main };
+
+export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
+  ({ url, ts, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [previewStatus, loadPreview] = useAsyncCallback(
+      useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
+    );
+
+    useEffect(() => {
+      loadPreview();
+    }, [loadPreview]);
+
+    if (previewStatus.status === AsyncStatus.Error) return null;
+
+    const renderContent = (prev: IPreviewUrlResponse) => {
+      const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
+
+      return (
+        <>
+          {imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
+          <UrlPreviewContent>
+            <Text
+              style={linkStyles}
+              truncate
+              as="a"
+              href={url}
+              target="_blank"
+              rel="no-referrer"
+              size="T200"
+              priority="300"
+            >
+              {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
+              {decodeURIComponent(url)}
+            </Text>
+            <Text truncate priority="400">
+              <b>{prev['og:title']}</b>
+            </Text>
+            <Text size="T200" priority="300">
+              <UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
+            </Text>
+          </UrlPreviewContent>
+        </>
+      );
+    };
+
+    return (
+      <UrlPreview {...props} ref={ref}>
+        {previewStatus.status === AsyncStatus.Success ? (
+          renderContent(previewStatus.data)
+        ) : (
+          <Box grow="Yes" alignItems="Center" justifyContent="Center">
+            <Spinner variant="Secondary" size="400" />
+          </Box>
+        )}
+      </UrlPreview>
+    );
+  }
+);
+
+export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const backAnchorRef = useRef<HTMLDivElement>(null);
+  const frontAnchorRef = useRef<HTMLDivElement>(null);
+  const [backVisible, setBackVisible] = useState(true);
+  const [frontVisible, setFrontVisible] = useState(true);
+
+  const intersectionObserver = useIntersectionObserver(
+    useCallback((entries) => {
+      const backAnchor = backAnchorRef.current;
+      const frontAnchor = frontAnchorRef.current;
+      const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
+      const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
+      if (backEntry) {
+        setBackVisible(backEntry.isIntersecting);
+      }
+      if (frontEntry) {
+        setFrontVisible(frontEntry.isIntersecting);
+      }
+    }, []),
+    useCallback(
+      () => ({
+        root: scrollRef.current,
+        rootMargin: '10px',
+      }),
+      []
+    )
+  );
+
+  useEffect(() => {
+    const backAnchor = backAnchorRef.current;
+    const frontAnchor = frontAnchorRef.current;
+    if (backAnchor) intersectionObserver?.observe(backAnchor);
+    if (frontAnchor) intersectionObserver?.observe(frontAnchor);
+    return () => {
+      if (backAnchor) intersectionObserver?.unobserve(backAnchor);
+      if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
+    };
+  }, [intersectionObserver]);
+
+  const handleScrollBack = () => {
+    const scroll = scrollRef.current;
+    if (!scroll) return;
+    const { offsetWidth, scrollLeft } = scroll;
+    scroll.scrollTo({
+      left: scrollLeft - offsetWidth / 1.3,
+      behavior: 'smooth',
+    });
+  };
+  const handleScrollFront = () => {
+    const scroll = scrollRef.current;
+    if (!scroll) return;
+    const { offsetWidth, scrollLeft } = scroll;
+    scroll.scrollTo({
+      left: scrollLeft + offsetWidth / 1.3,
+      behavior: 'smooth',
+    });
+  };
+
+  return (
+    <Box
+      direction="Column"
+      {...props}
+      ref={ref}
+      style={{ marginTop: config.space.S200, position: 'relative' }}
+    >
+      <Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
+        <Box shrink="No" alignItems="Center">
+          <div ref={backAnchorRef} />
+          {!backVisible && (
+            <>
+              <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
+              <IconButton
+                className={css.UrlPreviewHolderBtn({ position: 'Left' })}
+                variant="Secondary"
+                radii="Pill"
+                size="300"
+                outlined
+                onClick={handleScrollBack}
+              >
+                <Icon size="300" src={Icons.ArrowLeft} />
+              </IconButton>
+            </>
+          )}
+          <Box alignItems="Inherit" gap="200">
+            {children}
+
+            {!frontVisible && (
+              <>
+                <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
+                <IconButton
+                  className={css.UrlPreviewHolderBtn({ position: 'Right' })}
+                  variant="Primary"
+                  radii="Pill"
+                  size="300"
+                  outlined
+                  onClick={handleScrollFront}
+                >
+                  <Icon size="300" src={Icons.ArrowRight} />
+                </IconButton>
+              </>
+            )}
+            <div ref={frontAnchorRef} />
+          </Box>
+        </Box>
+      </Scroll>
+    </Box>
+  );
+});
index 6d4dc333cc6b37587f0076ad3c1ea5a1bb0bea58..90dc2ffddc0095b843f4f1d63c1dab985265ae7a 100644 (file)
@@ -1 +1,2 @@
 export * from './UrlPreview';
+export * from './UrlPreviewCard';
diff --git a/src/app/components/user-avatar/UserAvatar.css.ts b/src/app/components/user-avatar/UserAvatar.css.ts
new file mode 100644 (file)
index 0000000..0a3684b
--- /dev/null
@@ -0,0 +1,14 @@
+import { style } from '@vanilla-extract/css';
+import { color } from 'folds';
+
+export const UserAvatar = style({
+  backgroundColor: color.Secondary.Container,
+  color: color.Secondary.OnContainer,
+  textTransform: 'capitalize',
+
+  selectors: {
+    '&[data-image-loaded="true"]': {
+      backgroundColor: 'transparent',
+    },
+  },
+});
diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx
new file mode 100644 (file)
index 0000000..98067ad
--- /dev/null
@@ -0,0 +1,40 @@
+import { AvatarFallback, AvatarImage, color } from 'folds';
+import React, { ReactEventHandler, ReactNode, useState } from 'react';
+import * as css from './UserAvatar.css';
+import colorMXID from '../../../util/colorMXID';
+
+type UserAvatarProps = {
+  userId: string;
+  src?: string;
+  alt?: string;
+  renderFallback: () => ReactNode;
+};
+export function UserAvatar({ userId, src, alt, renderFallback }: UserAvatarProps) {
+  const [error, setError] = useState(false);
+
+  const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
+    evt.currentTarget.setAttribute('data-image-loaded', 'true');
+  };
+
+  if (!src || error) {
+    return (
+      <AvatarFallback
+        style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
+        className={css.UserAvatar}
+      >
+        {renderFallback()}
+      </AvatarFallback>
+    );
+  }
+
+  return (
+    <AvatarImage
+      className={css.UserAvatar}
+      src={src}
+      alt={alt}
+      onError={() => setError(true)}
+      onLoad={handleLoad}
+      draggable={false}
+    />
+  );
+}
diff --git a/src/app/components/user-avatar/index.ts b/src/app/components/user-avatar/index.ts
new file mode 100644 (file)
index 0000000..d1b24f3
--- /dev/null
@@ -0,0 +1 @@
+export * from './UserAvatar';
diff --git a/src/app/components/virtualizer/VirtualTile.tsx b/src/app/components/virtualizer/VirtualTile.tsx
new file mode 100644 (file)
index 0000000..0c6a5bd
--- /dev/null
@@ -0,0 +1,20 @@
+import { VirtualItem } from '@tanstack/react-virtual';
+import { as } from 'folds';
+import React from 'react';
+import classNames from 'classnames';
+import * as css from './style.css';
+
+type VirtualTileProps = {
+  virtualItem: VirtualItem;
+};
+export const VirtualTile = as<'div', VirtualTileProps>(
+  ({ className, virtualItem, style, ...props }, ref) => (
+    <div
+      className={classNames(css.VirtualTile, className)}
+      style={{ top: virtualItem.start, ...style }}
+      data-index={virtualItem.index}
+      {...props}
+      ref={ref}
+    />
+  )
+);
diff --git a/src/app/components/virtualizer/index.ts b/src/app/components/virtualizer/index.ts
new file mode 100644 (file)
index 0000000..44a5b09
--- /dev/null
@@ -0,0 +1 @@
+export * from './VirtualTile';
diff --git a/src/app/components/virtualizer/style.css.ts b/src/app/components/virtualizer/style.css.ts
new file mode 100644 (file)
index 0000000..962550c
--- /dev/null
@@ -0,0 +1,11 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset } from 'folds';
+
+export const VirtualTile = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    width: '100%',
+    left: 0,
+  },
+]);
index b9c677192bf8c0fceea5482a94ed42a7296d6df2..b9aac06ab4d2901dc5ce7a64596caa3a64064e1e 100644 (file)
@@ -104,7 +104,7 @@ export const specVersions = async (
   request: typeof fetch,
   baseUrl: string
 ): Promise<SpecVersions> => {
-  const res = await request(`${baseUrl}/_matrix/client/versions`);
+  const res = await request(`${trimTrailingSlash(baseUrl)}/_matrix/client/versions`);
 
   const data = (await res.json()) as unknown;
 
diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
new file mode 100644 (file)
index 0000000..2b9c3e5
--- /dev/null
@@ -0,0 +1,61 @@
+import React from 'react';
+import { Box, Scroll, Text, toRem } from 'folds';
+import { useAtomValue } from 'jotai';
+import { RoomCard } from '../../components/room-card';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import { Page, PageHeader } from '../../components/page';
+import { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+
+type JoinBeforeNavigateProps = { roomIdOrAlias: string };
+export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
+  const mx = useMatrixClient();
+  const allRooms = useAtomValue(allRoomsAtom);
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+  const handleView = (roomId: string) => {
+    if (mx.getRoom(roomId)?.isSpaceRoom()) {
+      navigateSpace(roomId);
+      return;
+    }
+    navigateRoom(roomId);
+  };
+
+  return (
+    <Page>
+      <PageHeader>
+        <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+          <Text size="H3" truncate>
+            {roomIdOrAlias}
+          </Text>
+        </Box>
+      </PageHeader>
+      <Box grow="Yes">
+        <Scroll hideTrack visibility="Hover" size="0">
+          <Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
+            <RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
+              {(summary) => (
+                <RoomCard
+                  style={{ maxWidth: toRem(364), width: '100%' }}
+                  roomIdOrAlias={roomIdOrAlias}
+                  allRooms={allRooms}
+                  avatarUrl={summary?.avatar_url}
+                  name={summary?.name}
+                  topic={summary?.topic}
+                  memberCount={summary?.num_joined_members}
+                  roomType={summary?.room_type}
+                  renderTopicViewer={(name, topic, requestClose) => (
+                    <RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
+                  )}
+                  onView={handleView}
+                />
+              )}
+            </RoomSummaryLoader>
+          </Box>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/join-before-navigate/index.ts b/src/app/features/join-before-navigate/index.ts
new file mode 100644 (file)
index 0000000..9093325
--- /dev/null
@@ -0,0 +1 @@
+export * from './JoinBeforeNavigate';
diff --git a/src/app/features/lobby/DnD.css.ts b/src/app/features/lobby/DnD.css.ts
new file mode 100644 (file)
index 0000000..76a0e2b
--- /dev/null
@@ -0,0 +1,91 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+
+export const ItemDraggableTarget = style([
+  ContainerColor({ variant: 'SurfaceVariant' }),
+  {
+    height: '100%',
+    position: 'absolute',
+    left: 0,
+    top: 0,
+    zIndex: 1,
+    cursor: 'grab',
+    borderRadius: config.radii.R400,
+    opacity: config.opacity.P300,
+
+    ':active': {
+      cursor: 'ns-resize',
+    },
+  },
+]);
+
+const LineHeight = 4;
+const DropTargetLine = style({
+  selectors: {
+    '&[data-hover=true]:before': {
+      content: '',
+      display: 'block',
+      width: '100%',
+
+      position: 'absolute',
+      left: 0,
+      top: '50%',
+      zIndex: 1,
+      transform: 'translateY(-50%)',
+
+      borderBottom: `${toRem(LineHeight)} solid currentColor`,
+    },
+    '&[data-hover=true]:after': {
+      content: '',
+      display: 'block',
+      width: toRem(LineHeight * 3),
+      height: toRem(LineHeight * 3),
+
+      position: 'absolute',
+      left: 0,
+      top: '50%',
+      zIndex: 1,
+      transform: 'translate(-50%, -50%)',
+
+      backgroundColor: color.Surface.Container,
+      border: `${toRem(LineHeight)} solid currentColor`,
+      borderRadius: '50%',
+    },
+  },
+});
+
+const BaseAfterRoomItemDropTarget = style({
+  width: '100%',
+
+  position: 'absolute',
+  left: 0,
+  bottom: 0,
+  zIndex: 99,
+
+  color: color.Success.Main,
+
+  selectors: {
+    '&[data-error=true]': {
+      color: color.Critical.Main,
+    },
+  },
+});
+const RoomTargetHeight = 32;
+export const AfterRoomItemDropTarget = style([
+  BaseAfterRoomItemDropTarget,
+  {
+    height: toRem(RoomTargetHeight),
+    transform: `translateY(${toRem(RoomTargetHeight / 2 + LineHeight / 2)})`,
+  },
+  DropTargetLine,
+]);
+const SpaceTargetHeight = 14;
+export const AfterSpaceItemDropTarget = style([
+  BaseAfterRoomItemDropTarget,
+  {
+    height: toRem(SpaceTargetHeight),
+    transform: `translateY(calc(100% - ${toRem(4)}))`,
+  },
+  DropTargetLine,
+]);
diff --git a/src/app/features/lobby/DnD.tsx b/src/app/features/lobby/DnD.tsx
new file mode 100644 (file)
index 0000000..5fd7e90
--- /dev/null
@@ -0,0 +1,146 @@
+import React, { RefObject, useEffect, useRef, useState } from 'react';
+import {
+  dropTargetForElements,
+  draggable,
+  monitorForElements,
+} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import classNames from 'classnames';
+import { Box, Icon, Icons, as } from 'folds';
+import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
+import * as css from './DnD.css';
+
+export type DropContainerData = {
+  item: HierarchyItem;
+  nextRoomId?: string;
+};
+export type CanDropCallback = (item: HierarchyItem, container: DropContainerData) => boolean;
+
+export const useDraggableItem = (
+  item: HierarchyItem,
+  targetRef: RefObject<HTMLElement>,
+  onDragging: (item?: HierarchyItem) => void,
+  dragHandleRef?: RefObject<HTMLElement>
+): boolean => {
+  const [dragging, setDragging] = useState(false);
+
+  useEffect(() => {
+    const target = targetRef.current;
+    const dragHandle = dragHandleRef?.current ?? undefined;
+
+    return !target
+      ? undefined
+      : draggable({
+          element: target,
+          dragHandle,
+          getInitialData: () => item,
+          onDragStart: () => {
+            setDragging(true);
+            onDragging(item);
+          },
+          onDrop: () => {
+            setDragging(false);
+            onDragging(undefined);
+          },
+        });
+  }, [targetRef, dragHandleRef, item, onDragging]);
+
+  return dragging;
+};
+
+export const ItemDraggableTarget = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    justifyContent="Center"
+    alignItems="Center"
+    className={classNames(css.ItemDraggableTarget, className)}
+    ref={ref}
+    {...props}
+  >
+    <Icon size="50" src={Icons.VerticalDots} />
+  </Box>
+));
+
+type AfterItemDropTargetProps = {
+  item: HierarchyItem;
+  afterSpace?: boolean;
+  nextRoomId?: string;
+  canDrop: CanDropCallback;
+};
+export function AfterItemDropTarget({
+  item,
+  afterSpace,
+  nextRoomId,
+  canDrop,
+}: AfterItemDropTargetProps) {
+  const targetRef = useRef<HTMLDivElement>(null);
+  const [dropState, setDropState] = useState<'idle' | 'allow' | 'not-allow'>('idle');
+
+  useEffect(() => {
+    const target = targetRef.current;
+    if (!target) {
+      throw Error('drop target ref is not set properly');
+    }
+
+    return dropTargetForElements({
+      element: target,
+      getData: () => {
+        const container: DropContainerData = {
+          item,
+          nextRoomId,
+        };
+        return container;
+      },
+      onDragEnter: ({ source }) => {
+        if (
+          canDrop(source.data as HierarchyItem, {
+            item,
+            nextRoomId,
+          })
+        ) {
+          setDropState('allow');
+        } else {
+          setDropState('not-allow');
+        }
+      },
+      onDragLeave: () => setDropState('idle'),
+      onDrop: () => setDropState('idle'),
+    });
+  }, [item, nextRoomId, canDrop]);
+
+  return (
+    <div
+      className={afterSpace ? css.AfterSpaceItemDropTarget : css.AfterRoomItemDropTarget}
+      data-hover={dropState !== 'idle'}
+      data-error={dropState === 'not-allow'}
+      ref={targetRef}
+    />
+  );
+}
+
+export const useDnDMonitor = (
+  scrollRef: RefObject<HTMLElement>,
+  onDragging: (item?: HierarchyItem) => void,
+  onReorder: (item: HierarchyItem, container: DropContainerData) => void
+) => {
+  useEffect(() => {
+    const scrollElement = scrollRef.current;
+    if (!scrollElement) {
+      throw Error('Scroll element ref not configured');
+    }
+
+    return combine(
+      monitorForElements({
+        onDrop: ({ source, location }) => {
+          onDragging(undefined);
+          const { dropTargets } = location.current;
+          if (dropTargets.length === 0) return;
+          onReorder(source.data as HierarchyItem, dropTargets[0].data as DropContainerData);
+        },
+      }),
+      autoScrollForElements({
+        element: scrollElement,
+      })
+    );
+  }, [scrollRef, onDragging, onReorder]);
+};
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
new file mode 100644 (file)
index 0000000..489bb9b
--- /dev/null
@@ -0,0 +1,306 @@
+import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+  Box,
+  IconButton,
+  Icon,
+  Icons,
+  PopOut,
+  Menu,
+  MenuItem,
+  Text,
+  RectCords,
+  config,
+  Line,
+  Spinner,
+  toRem,
+} from 'folds';
+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 { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
+import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
+
+type HierarchyItemWithParent = HierarchyItem & {
+  parentId: string;
+};
+
+function SuggestMenuItem({
+  item,
+  requestClose,
+}: {
+  item: HierarchyItemWithParent;
+  requestClose: () => void;
+}) {
+  const mx = useMatrixClient();
+  const { roomId, parentId, content } = item;
+
+  const [toggleState, handleToggleSuggested] = useAsyncCallback(
+    useCallback(() => {
+      const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
+      return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId);
+    }, [mx, parentId, roomId, content])
+  );
+
+  useEffect(() => {
+    if (toggleState.status === AsyncStatus.Success) {
+      requestClose();
+    }
+  }, [requestClose, toggleState]);
+
+  return (
+    <MenuItem
+      onClick={handleToggleSuggested}
+      size="300"
+      radii="300"
+      before={toggleState.status === AsyncStatus.Loading && <Spinner size="100" />}
+      disabled={toggleState.status === AsyncStatus.Loading}
+    >
+      <Text as="span" size="T300" truncate>
+        {content.suggested ? 'Unset Suggested' : 'Set Suggested'}
+      </Text>
+    </MenuItem>
+  );
+}
+
+function RemoveMenuItem({
+  item,
+  requestClose,
+}: {
+  item: HierarchyItemWithParent;
+  requestClose: () => void;
+}) {
+  const mx = useMatrixClient();
+  const { roomId, parentId } = item;
+
+  const [removeState, handleRemove] = useAsyncCallback(
+    useCallback(
+      () => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId),
+      [mx, parentId, roomId]
+    )
+  );
+
+  useEffect(() => {
+    if (removeState.status === AsyncStatus.Success) {
+      requestClose();
+    }
+  }, [requestClose, removeState]);
+
+  return (
+    <MenuItem
+      onClick={handleRemove}
+      variant="Critical"
+      fill="None"
+      size="300"
+      radii="300"
+      before={
+        removeState.status === AsyncStatus.Loading && (
+          <Spinner variant="Critical" fill="Soft" size="100" />
+        )
+      }
+      disabled={removeState.status === AsyncStatus.Loading}
+    >
+      <Text as="span" size="T300" truncate>
+        Remove
+      </Text>
+    </MenuItem>
+  );
+}
+
+function InviteMenuItem({
+  item,
+  requestClose,
+  disabled,
+}: {
+  item: HierarchyItemWithParent;
+  requestClose: () => void;
+  disabled?: boolean;
+}) {
+  const handleInvite = () => {
+    openInviteUser(item.roomId);
+    requestClose();
+  };
+
+  return (
+    <MenuItem
+      onClick={handleInvite}
+      size="300"
+      radii="300"
+      variant="Primary"
+      fill="None"
+      disabled={disabled}
+    >
+      <Text as="span" size="T300" truncate>
+        Invite
+      </Text>
+    </MenuItem>
+  );
+}
+
+function SettingsMenuItem({
+  item,
+  requestClose,
+  disabled,
+}: {
+  item: HierarchyItemWithParent;
+  requestClose: () => void;
+  disabled?: boolean;
+}) {
+  const handleSettings = () => {
+    if (item.space) {
+      openSpaceSettings(item.roomId);
+    } else {
+      toggleRoomSettings(item.roomId);
+    }
+    requestClose();
+  };
+
+  return (
+    <MenuItem onClick={handleSettings} size="300" radii="300" disabled={disabled}>
+      <Text as="span" size="T300" truncate>
+        Settings
+      </Text>
+    </MenuItem>
+  );
+}
+
+type HierarchyItemMenuProps = {
+  item: HierarchyItem & {
+    parentId: string;
+  };
+  joined: boolean;
+  canInvite: boolean;
+  canEditChild: boolean;
+  pinned?: boolean;
+  onTogglePin?: (roomId: string) => void;
+};
+export function HierarchyItemMenu({
+  item,
+  joined,
+  canInvite,
+  canEditChild,
+  pinned,
+  onTogglePin,
+}: HierarchyItemMenuProps) {
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []);
+
+  if (!joined && !canEditChild) {
+    return null;
+  }
+
+  return (
+    <Box gap="200" alignItems="Center" shrink="No">
+      <IconButton
+        onClick={handleOpenMenu}
+        size="300"
+        variant="SurfaceVariant"
+        fill="None"
+        radii="300"
+        aria-pressed={!!menuAnchor}
+      >
+        <Icon size="50" src={Icons.VerticalDots} />
+      </IconButton>
+      {menuAnchor && (
+        <PopOut
+          anchor={menuAnchor}
+          position="Bottom"
+          align="End"
+          content={
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                returnFocusOnDeactivate: false,
+                onDeactivate: () => setMenuAnchor(undefined),
+                clickOutsideDeactivates: true,
+                isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+              }}
+            >
+              <Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
+                {joined && (
+                  <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+                    {onTogglePin && (
+                      <MenuItem
+                        size="300"
+                        radii="300"
+                        onClick={() => {
+                          onTogglePin(item.roomId);
+                          handleRequestClose();
+                        }}
+                      >
+                        <Text as="span" size="T300" truncate>
+                          {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
+                        </Text>
+                      </MenuItem>
+                    )}
+                    <InviteMenuItem
+                      item={item}
+                      requestClose={handleRequestClose}
+                      disabled={!canInvite}
+                    />
+                    <SettingsMenuItem item={item} requestClose={handleRequestClose} />
+                    <UseStateProvider initial={false}>
+                      {(promptLeave, setPromptLeave) => (
+                        <>
+                          <MenuItem
+                            onClick={() => setPromptLeave(true)}
+                            variant="Critical"
+                            fill="None"
+                            size="300"
+                            after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+                            radii="300"
+                            aria-pressed={promptLeave}
+                          >
+                            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                              Leave
+                            </Text>
+                          </MenuItem>
+                          {promptLeave &&
+                            (item.space ? (
+                              <LeaveSpacePrompt
+                                roomId={item.roomId}
+                                onDone={handleRequestClose}
+                                onCancel={() => setPromptLeave(false)}
+                              />
+                            ) : (
+                              <LeaveRoomPrompt
+                                roomId={item.roomId}
+                                onDone={handleRequestClose}
+                                onCancel={() => setPromptLeave(false)}
+                              />
+                            ))}
+                        </>
+                      )}
+                    </UseStateProvider>
+                  </Box>
+                )}
+                {(joined || canEditChild) && (
+                  <Line size="300" variant="Surface" direction="Horizontal" />
+                )}
+                {canEditChild && (
+                  <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+                    <SuggestMenuItem item={item} requestClose={handleRequestClose} />
+                    <RemoveMenuItem item={item} requestClose={handleRequestClose} />
+                  </Box>
+                )}
+              </Menu>
+            </FocusTrap>
+          }
+        />
+      )}
+    </Box>
+  );
+}
diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx
new file mode 100644 (file)
index 0000000..62df276
--- /dev/null
@@ -0,0 +1,528 @@
+import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
+import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useAtom, useAtomValue } from 'jotai';
+import { useNavigate } from 'react-router-dom';
+import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
+import { useSpace } from '../../hooks/useSpace';
+import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
+import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
+import { VirtualTile } from '../../components/virtualizer';
+import { spaceRoomsAtom } from '../../state/spaceRooms';
+import { MembersDrawer } from '../room/MembersDrawer';
+import { useSetting } from '../../state/hooks/settings';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { settingsAtom } from '../../state/settings';
+import { LobbyHeader } from './LobbyHeader';
+import { LobbyHero } from './LobbyHero';
+import { ScrollTopContainer } from '../../components/scroll-top-container';
+import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
+import {
+  IPowerLevels,
+  PowerLevelsContextProvider,
+  powerLevelAPI,
+  usePowerLevels,
+  useRoomsPowerLevels,
+} from '../../hooks/usePowerLevels';
+import { RoomItemCard } from './RoomItem';
+import { mDirectAtom } from '../../state/mDirectList';
+import { SpaceItemCard } from './SpaceItem';
+import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
+import { useCategoryHandler } from '../../hooks/useCategoryHandler';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { getSpaceRoomPath } from '../../pages/pathUtils';
+import { HierarchyItemMenu } from './HierarchyItemMenu';
+import { StateEvent } from '../../../types/matrix/room';
+import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
+import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
+import { getStateEvent } from '../../utils/room';
+import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
+import {
+  makeCinnySpacesContent,
+  sidebarItemWithout,
+  useSidebarItems,
+} from '../../hooks/useSidebarItems';
+import { useOrphanSpaces } from '../../state/hooks/roomList';
+import { roomToParentsAtom } from '../../state/room/roomToParents';
+import { AccountDataEvent } from '../../../types/matrix/accountData';
+
+export function Lobby() {
+  const navigate = useNavigate();
+  const mx = useMatrixClient();
+  const mDirects = useAtomValue(mDirectAtom);
+  const allRooms = useAtomValue(allRoomsAtom);
+  const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
+  const space = useSpace();
+  const spacePowerLevels = usePowerLevels(space);
+  const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const heroSectionRef = useRef<HTMLDivElement>(null);
+  const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
+  const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
+  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+  const screenSize = useScreenSizeContext();
+  const [onTop, setOnTop] = useState(true);
+  const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
+  const [sidebarItems] = useSidebarItems(
+    useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
+  );
+  const sidebarSpaces = useMemo(() => {
+    const sideSpaces = sidebarItems.flatMap((item) => {
+      if (typeof item === 'string') return item;
+      return item.content;
+    });
+
+    return new Set(sideSpaces);
+  }, [sidebarItems]);
+
+  useElementSizeObserver(
+    useCallback(() => heroSectionRef.current, []),
+    useCallback((w, height) => setHeroSectionHeight(height), [])
+  );
+
+  const getRoom = useCallback(
+    (rId: string) => {
+      if (allJoinedRooms.has(rId)) {
+        return mx.getRoom(rId) ?? undefined;
+      }
+      return undefined;
+    },
+    [mx, allJoinedRooms]
+  );
+
+  const canEditSpaceChild = useCallback(
+    (powerLevels: IPowerLevels) =>
+      powerLevelAPI.canSendStateEvent(
+        powerLevels,
+        StateEvent.SpaceChild,
+        powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined)
+      ),
+    [mx]
+  );
+
+  const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
+  const flattenHierarchy = useSpaceHierarchy(
+    space.roomId,
+    spaceRooms,
+    getRoom,
+    useCallback(
+      (childId) =>
+        closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
+      [closedCategories, space.roomId, draggingItem]
+    )
+  );
+
+  const virtualizer = useVirtualizer({
+    count: flattenHierarchy.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 1,
+    overscan: 2,
+    paddingStart: heroSectionHeight ?? 258,
+  });
+  const vItems = virtualizer.getVirtualItems();
+
+  const roomsPowerLevels = useRoomsPowerLevels(
+    useMemo(
+      () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
+      [mx, flattenHierarchy]
+    )
+  );
+
+  const canDrop: CanDropCallback = useCallback(
+    (item, container): boolean => {
+      const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
+      if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
+        // can not drop before or after itself
+        return false;
+      }
+
+      if (item.space) {
+        if (!container.item.space) return false;
+        const containerSpaceId = space.roomId;
+
+        if (
+          getRoom(containerSpaceId) === undefined ||
+          !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+        ) {
+          return false;
+        }
+
+        return true;
+      }
+
+      const containerSpaceId = container.item.space
+        ? container.item.roomId
+        : container.item.parentId;
+
+      const dropOutsideSpace = item.parentId !== containerSpaceId;
+
+      if (dropOutsideSpace && restrictedItem) {
+        // do not allow restricted room to drop outside
+        // current space if can't change join rule allow
+        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
+        const userPLInItem = powerLevelAPI.getPowerLevel(
+          itemPowerLevel,
+          mx.getUserId() ?? undefined
+        );
+        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
+          itemPowerLevel,
+          StateEvent.RoomJoinRules,
+          userPLInItem
+        );
+        if (!canChangeJoinRuleAllow) {
+          return false;
+        }
+      }
+
+      if (
+        getRoom(containerSpaceId) === undefined ||
+        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+      ) {
+        return false;
+      }
+      return true;
+    },
+    [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
+  );
+
+  const reorderSpace = useCallback(
+    (item: HierarchyItem, containerItem: HierarchyItem) => {
+      if (!item.parentId) return;
+
+      const childItems = flattenHierarchy
+        .filter((i) => i.parentId && i.space)
+        .filter((i) => i.roomId !== item.roomId);
+
+      const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
+      const insertIndex = beforeIndex + 1;
+
+      childItems.splice(insertIndex, 0, {
+        ...item,
+        content: { ...item.content, order: undefined },
+      });
+
+      const currentOrders = childItems.map((i) => {
+        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
+          return i.content.order;
+        }
+        return undefined;
+      });
+
+      const newOrders = orderKeys(lex, currentOrders);
+
+      newOrders?.forEach((orderKey, index) => {
+        const itm = childItems[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,
+            { ...itm.content, order: orderKey },
+            itm.roomId
+          );
+        }
+      });
+    },
+    [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+  );
+
+  const reorderRoom = useCallback(
+    (item: HierarchyItem, containerItem: HierarchyItem): void => {
+      const itemRoom = mx.getRoom(item.roomId);
+      if (!item.parentId) {
+        return;
+      }
+      const containerParentId: string = containerItem.space
+        ? containerItem.roomId
+        : containerItem.parentId;
+      const itemContent = item.content;
+
+      if (item.parentId !== containerParentId) {
+        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
+      }
+
+      if (
+        itemRoom &&
+        itemRoom.getJoinRule() === JoinRule.Restricted &&
+        item.parentId !== containerParentId
+      ) {
+        // change join rule allow parameter when dragging
+        // restricted room from one space to another
+        const joinRuleContent = getStateEvent(
+          itemRoom,
+          StateEvent.RoomJoinRules
+        )?.getContent<IJoinRuleEventContent>();
+
+        if (joinRuleContent) {
+          const allow =
+            joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
+          allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
+          mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
+            ...joinRuleContent,
+            allow,
+          });
+        }
+      }
+
+      const childItems = flattenHierarchy
+        .filter((i) => i.parentId === containerParentId && !i.space)
+        .filter((i) => i.roomId !== item.roomId);
+
+      const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
+      const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
+      const insertIndex = beforeIndex + 1;
+
+      childItems.splice(insertIndex, 0, {
+        ...item,
+        parentId: containerParentId,
+        content: { ...itemContent, order: undefined },
+      });
+
+      const currentOrders = childItems.map((i) => {
+        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
+          return i.content.order;
+        }
+        return undefined;
+      });
+
+      const newOrders = orderKeys(lex, currentOrders);
+
+      newOrders?.forEach((orderKey, index) => {
+        const itm = childItems[index];
+        if (itm && orderKey !== currentOrders[index]) {
+          mx.sendStateEvent(
+            containerParentId,
+            StateEvent.SpaceChild,
+            { ...itm.content, order: orderKey },
+            itm.roomId
+          );
+        }
+      });
+    },
+    [mx, flattenHierarchy, lex]
+  );
+
+  useDnDMonitor(
+    scrollRef,
+    setDraggingItem,
+    useCallback(
+      (item, container) => {
+        if (!canDrop(item, container)) {
+          return;
+        }
+        if (item.space) {
+          reorderSpace(item, container.item);
+        } else {
+          reorderRoom(item, container.item);
+        }
+      },
+      [reorderRoom, reorderSpace, canDrop]
+    )
+  );
+
+  const addSpaceRoom = useCallback(
+    (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
+    [setSpaceRooms]
+  );
+
+  const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+    closedCategories.has(categoryId)
+  );
+
+  const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const rId = evt.currentTarget.getAttribute('data-room-id');
+    if (!rId) return;
+    const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
+    navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
+  };
+
+  const togglePinToSidebar = useCallback(
+    (rId: string) => {
+      const newItems = sidebarItemWithout(sidebarItems, rId);
+      if (!sidebarSpaces.has(rId)) {
+        newItems.push(rId);
+      }
+      const newSpacesContent = makeCinnySpacesContent(mx, newItems);
+      mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+    },
+    [mx, sidebarItems, sidebarSpaces]
+  );
+
+  return (
+    <PowerLevelsContextProvider value={spacePowerLevels}>
+      <Box grow="Yes">
+        <Page>
+          <LobbyHeader
+            showProfile={!onTop}
+            powerLevels={roomsPowerLevels.get(space.roomId) ?? {}}
+          />
+          <Box style={{ position: 'relative' }} grow="Yes">
+            <Scroll ref={scrollRef} hideTrack visibility="Hover">
+              <PageContent>
+                <PageContentCenter>
+                  <ScrollTopContainer
+                    scrollRef={scrollRef}
+                    anchorRef={heroSectionRef}
+                    onVisibilityChange={setOnTop}
+                  >
+                    <IconButton
+                      onClick={() => virtualizer.scrollToOffset(0)}
+                      variant="SurfaceVariant"
+                      radii="Pill"
+                      outlined
+                      size="300"
+                      aria-label="Scroll to Top"
+                    >
+                      <Icon src={Icons.ChevronTop} size="300" />
+                    </IconButton>
+                  </ScrollTopContainer>
+                  <div
+                    style={{
+                      position: 'relative',
+                      height: virtualizer.getTotalSize(),
+                    }}
+                  >
+                    <PageHeroSection ref={heroSectionRef} style={{ paddingTop: 0 }}>
+                      <LobbyHero />
+                    </PageHeroSection>
+                    {vItems.map((vItem) => {
+                      const item = flattenHierarchy[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 nextRoomId: string | undefined =
+                        flattenHierarchy[vItem.index + 1]?.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 (
+                          <VirtualTile
+                            virtualItem={vItem}
+                            style={{
+                              paddingTop: vItem.index === 0 ? 0 : config.space.S500,
+                            }}
+                            ref={virtualizer.measureElement}
+                            key={vItem.index}
+                          >
+                            <SpaceItemCard
+                              item={item}
+                              joined={allJoinedRooms.has(item.roomId)}
+                              categoryId={categoryId}
+                              closed={closedCategories.has(categoryId) || !!draggingItem?.space}
+                              handleClose={handleCategoryClick}
+                              getRoom={getRoom}
+                              canEditChild={canEditSpaceChild(
+                                roomsPowerLevels.get(item.roomId) ?? {}
+                              )}
+                              canReorder={
+                                parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
+                              }
+                              options={
+                                parentId &&
+                                parentPowerLevels && (
+                                  <HierarchyItemMenu
+                                    item={{ ...item, parentId }}
+                                    canInvite={canInvite}
+                                    joined={isJoined}
+                                    canEditChild={canEditSpaceChild(parentPowerLevels)}
+                                    pinned={sidebarSpaces.has(item.roomId)}
+                                    onTogglePin={togglePinToSidebar}
+                                  />
+                                )
+                              }
+                              before={item.parentId ? undefined : undefined}
+                              after={
+                                <AfterItemDropTarget
+                                  item={item}
+                                  nextRoomId={nextRoomId}
+                                  afterSpace
+                                  canDrop={canDrop}
+                                />
+                              }
+                              onDragging={setDraggingItem}
+                              data-dragging={dragging}
+                            />
+                          </VirtualTile>
+                        );
+                      }
+
+                      const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
+                      const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
+                      const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
+                      return (
+                        <VirtualTile
+                          virtualItem={vItem}
+                          style={{ paddingTop: config.space.S100 }}
+                          ref={virtualizer.measureElement}
+                          key={vItem.index}
+                        >
+                          <RoomItemCard
+                            item={item}
+                            onSpaceFound={addSpaceRoom}
+                            dm={mDirects.has(item.roomId)}
+                            firstChild={!prevItem || prevItem.space === true}
+                            lastChild={!nextItem || nextItem.space === true}
+                            onOpen={handleOpenRoom}
+                            getRoom={getRoom}
+                            canReorder={canEditSpaceChild(parentPowerLevels)}
+                            options={
+                              <HierarchyItemMenu
+                                item={item}
+                                canInvite={canInvite}
+                                joined={isJoined}
+                                canEditChild={canEditSpaceChild(parentPowerLevels)}
+                              />
+                            }
+                            after={
+                              <AfterItemDropTarget
+                                item={item}
+                                nextRoomId={nextRoomId}
+                                canDrop={canDrop}
+                              />
+                            }
+                            data-dragging={dragging}
+                            onDragging={setDraggingItem}
+                          />
+                        </VirtualTile>
+                      );
+                    })}
+                  </div>
+                </PageContentCenter>
+              </PageContent>
+            </Scroll>
+          </Box>
+        </Page>
+        {screenSize === ScreenSize.Desktop && isDrawer && (
+          <>
+            <Line variant="Background" direction="Vertical" size="300" />
+            <MembersDrawer room={space} />
+          </>
+        )}
+      </Box>
+    </PowerLevelsContextProvider>
+  );
+}
diff --git a/src/app/features/lobby/LobbyHeader.css.ts b/src/app/features/lobby/LobbyHeader.css.ts
new file mode 100644 (file)
index 0000000..c6784de
--- /dev/null
@@ -0,0 +1,13 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const Header = style({
+  borderBottomColor: 'transparent',
+});
+export const HeaderTopic = style({
+  ':hover': {
+    cursor: 'pointer',
+    opacity: config.opacity.P500,
+    textDecoration: 'underline',
+  },
+});
diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx
new file mode 100644 (file)
index 0000000..a23faad
--- /dev/null
@@ -0,0 +1,214 @@
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import {
+  Avatar,
+  Box,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  config,
+  toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { PageHeader } from '../../components/page';
+import { useSetSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
+import { useSpace } from '../../hooks/useSpace';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { RoomAvatar } from '../../components/room-avatar';
+import { nameInitials } from '../../utils/common';
+import * as css from './LobbyHeader.css';
+import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
+import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
+
+type LobbyMenuProps = {
+  roomId: string;
+  powerLevels: IPowerLevels;
+  requestClose: () => void;
+};
+const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
+  ({ roomId, powerLevels, requestClose }, ref) => {
+    const mx = useMatrixClient();
+    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+    const handleInvite = () => {
+      openInviteUser(roomId);
+      requestClose();
+    };
+
+    const handleRoomSettings = () => {
+      openSpaceSettings(roomId);
+      requestClose();
+    };
+
+    return (
+      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleInvite}
+            variant="Primary"
+            fill="None"
+            size="300"
+            after={<Icon size="100" src={Icons.UserPlus} />}
+            radii="300"
+            disabled={!canInvite}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Invite
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleRoomSettings}
+            size="300"
+            after={<Icon size="100" src={Icons.Setting} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Space Settings
+            </Text>
+          </MenuItem>
+        </Box>
+        <Line variant="Surface" size="300" />
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <UseStateProvider initial={false}>
+            {(promptLeave, setPromptLeave) => (
+              <>
+                <MenuItem
+                  onClick={() => setPromptLeave(true)}
+                  variant="Critical"
+                  fill="None"
+                  size="300"
+                  after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+                  radii="300"
+                  aria-pressed={promptLeave}
+                >
+                  <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                    Leave Space
+                  </Text>
+                </MenuItem>
+                {promptLeave && (
+                  <LeaveSpacePrompt
+                    roomId={roomId}
+                    onDone={requestClose}
+                    onCancel={() => setPromptLeave(false)}
+                  />
+                )}
+              </>
+            )}
+          </UseStateProvider>
+        </Box>
+      </Menu>
+    );
+  }
+);
+
+type LobbyHeaderProps = {
+  showProfile?: boolean;
+  powerLevels: IPowerLevels;
+};
+export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
+  const mx = useMatrixClient();
+  const space = useSpace();
+  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const name = useRoomName(space);
+  const avatarMxc = useRoomAvatar(space);
+  const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PageHeader className={showProfile ? undefined : css.Header}>
+      <Box grow="Yes" alignItems="Center" gap="200">
+        <Box grow="Yes" basis="No" />
+        <Box justifyContent="Center" alignItems="Center" gap="300">
+          {showProfile && (
+            <>
+              <Avatar size="300">
+                <RoomAvatar
+                  roomId={space.roomId}
+                  src={avatarUrl}
+                  alt={name}
+                  renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
+                />
+              </Avatar>
+              <Text size="H3" truncate>
+                {name}
+              </Text>
+            </>
+          )}
+        </Box>
+        <Box shrink="No" grow="Yes" basis="No" justifyContent="End">
+          <TooltipProvider
+            position="Bottom"
+            offset={4}
+            tooltip={
+              <Tooltip>
+                <Text>Members</Text>
+              </Tooltip>
+            }
+          >
+            {(triggerRef) => (
+              <IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
+                <Icon size="400" src={Icons.User} />
+              </IconButton>
+            )}
+          </TooltipProvider>
+          <TooltipProvider
+            position="Bottom"
+            align="End"
+            offset={4}
+            tooltip={
+              <Tooltip>
+                <Text>More Options</Text>
+              </Tooltip>
+            }
+          >
+            {(triggerRef) => (
+              <IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
+                <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
+              </IconButton>
+            )}
+          </TooltipProvider>
+          <PopOut
+            anchor={menuAnchor}
+            position="Bottom"
+            align="End"
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setMenuAnchor(undefined),
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                }}
+              >
+                <LobbyMenu
+                  roomId={space.roomId}
+                  powerLevels={powerLevels}
+                  requestClose={() => setMenuAnchor(undefined)}
+                />
+              </FocusTrap>
+            }
+          />
+        </Box>
+      </Box>
+    </PageHeader>
+  );
+}
diff --git a/src/app/features/lobby/LobbyHero.css.tsx b/src/app/features/lobby/LobbyHero.css.tsx
new file mode 100644 (file)
index 0000000..ad7e938
--- /dev/null
@@ -0,0 +1,15 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const LobbyHeroTopic = style({
+  display: '-webkit-box',
+  WebkitLineClamp: 3,
+  WebkitBoxOrient: 'vertical',
+  overflow: 'hidden',
+
+  ':hover': {
+    cursor: 'pointer',
+    opacity: config.opacity.P500,
+    textDecoration: 'underline',
+  },
+});
diff --git a/src/app/features/lobby/LobbyHero.tsx b/src/app/features/lobby/LobbyHero.tsx
new file mode 100644 (file)
index 0000000..a92a49f
--- /dev/null
@@ -0,0 +1,77 @@
+import React from 'react';
+import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
+import { useSpace } from '../../hooks/useSpace';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { RoomAvatar } from '../../components/room-avatar';
+import { nameInitials } from '../../utils/common';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import * as css from './LobbyHero.css';
+import { PageHero } from '../../components/page';
+import { onEnterOrSpace } from '../../utils/keyboard';
+
+export function LobbyHero() {
+  const mx = useMatrixClient();
+  const space = useSpace();
+
+  const name = useRoomName(space);
+  const topic = useRoomTopic(space);
+  const avatarMxc = useRoomAvatar(space);
+  const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
+
+  return (
+    <PageHero
+      icon={
+        <Avatar size="500">
+          <RoomAvatar
+            roomId={space.roomId}
+            src={avatarUrl}
+            alt={name}
+            renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
+          />
+        </Avatar>
+      }
+      title={name}
+      subTitle={
+        topic && (
+          <UseStateProvider initial={false}>
+            {(viewTopic, setViewTopic) => (
+              <>
+                <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+                  <OverlayCenter>
+                    <FocusTrap
+                      focusTrapOptions={{
+                        initialFocus: false,
+                        clickOutsideDeactivates: true,
+                        onDeactivate: () => setViewTopic(false),
+                      }}
+                    >
+                      <RoomTopicViewer
+                        name={name}
+                        topic={topic}
+                        requestClose={() => setViewTopic(false)}
+                      />
+                    </FocusTrap>
+                  </OverlayCenter>
+                </Overlay>
+                <Text
+                  as="span"
+                  onClick={() => setViewTopic(true)}
+                  onKeyDown={onEnterOrSpace(() => setViewTopic(true))}
+                  tabIndex={0}
+                  className={css.LobbyHeroTopic}
+                  size="Inherit"
+                  priority="300"
+                >
+                  {topic}
+                </Text>
+              </>
+            )}
+          </UseStateProvider>
+        )
+      }
+    />
+  );
+}
diff --git a/src/app/features/lobby/RoomItem.css.ts b/src/app/features/lobby/RoomItem.css.ts
new file mode 100644 (file)
index 0000000..812ff1d
--- /dev/null
@@ -0,0 +1,22 @@
+import { style } from '@vanilla-extract/css';
+import { config, toRem } from 'folds';
+
+export const RoomItemCard = style({
+  padding: config.space.S400,
+  borderRadius: 0,
+  position: 'relative',
+  selectors: {
+    '&[data-dragging=true]': {
+      opacity: config.opacity.Disabled,
+    },
+  },
+});
+export const RoomProfileTopic = style({
+  cursor: 'pointer',
+  ':hover': {
+    textDecoration: 'underline',
+  },
+});
+export const ErrorNameContainer = style({
+  gap: toRem(2),
+});
diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx
new file mode 100644 (file)
index 0000000..4e7dd6a
--- /dev/null
@@ -0,0 +1,441 @@
+import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
+import {
+  Avatar,
+  Badge,
+  Box,
+  Chip,
+  Icon,
+  Icons,
+  Line,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+  color,
+  toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
+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 { UseStateProvider } from '../../components/UseStateProvider';
+import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import { onEnterOrSpace } from '../../utils/keyboard';
+import { Membership, RoomType } 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';
+
+type RoomJoinButtonProps = {
+  roomId: string;
+  via?: string[];
+};
+function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
+  const mx = useMatrixClient();
+
+  const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
+    useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
+  );
+
+  const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
+
+  return (
+    <Box shrink="No" gap="200" alignItems="Center">
+      {joinState.status === AsyncStatus.Error && (
+        <TooltipProvider
+          tooltip={
+            <Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
+              <Box direction="Column" gap="100">
+                <Text style={{ wordBreak: 'break-word' }} size="T400">
+                  {joinState.error.data?.error || joinState.error.message}
+                </Text>
+                <Text size="T200">{joinState.error.name}</Text>
+              </Box>
+            </Tooltip>
+          }
+        >
+          {(triggerRef) => (
+            <Icon
+              ref={triggerRef}
+              style={{ color: color.Critical.Main, cursor: 'pointer' }}
+              src={Icons.Warning}
+              size="400"
+              filled
+              tabIndex={0}
+              aria-label={joinState.error.data?.error || joinState.error.message}
+            />
+          )}
+        </TooltipProvider>
+      )}
+      <Chip
+        variant="Secondary"
+        fill="Soft"
+        size="400"
+        radii="Pill"
+        before={
+          canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="100" />
+        }
+        onClick={join}
+        disabled={!canJoin}
+      >
+        <Text size="B300">Join</Text>
+      </Chip>
+    </Box>
+  );
+}
+
+function RoomProfileLoading() {
+  return (
+    <Box grow="Yes" gap="300">
+      <Avatar className={styleCss.AvatarPlaceholder} />
+      <Box grow="Yes" direction="Column" gap="100">
+        <Box gap="200" alignItems="Center">
+          <Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(80) }} />
+        </Box>
+        <Box gap="200" alignItems="Center">
+          <Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(40) }} />
+          <Box
+            className={styleCss.LinePlaceholder}
+            shrink="No"
+            style={{
+              maxWidth: toRem(120),
+            }}
+          />
+        </Box>
+      </Box>
+    </Box>
+  );
+}
+
+type RoomProfileErrorProps = {
+  roomId: string;
+  error: Error;
+  suggested?: boolean;
+  via?: string[];
+};
+function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
+  const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
+
+  return (
+    <Box grow="Yes" gap="300">
+      <Avatar>
+        <RoomAvatar
+          roomId={roomId}
+          src={undefined}
+          alt={roomId}
+          renderFallback={() => (
+            <RoomIcon
+              size="300"
+              joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
+              filled
+            />
+          )}
+        />
+      </Avatar>
+      <Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
+        <Box gap="200" alignItems="Center">
+          <Text size="H5" truncate>
+            Unknown
+          </Text>
+          {suggested && (
+            <Box shrink="No" alignItems="Center">
+              <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+                <Text size="L400">Suggested</Text>
+              </Badge>
+            </Box>
+          )}
+        </Box>
+        <Box gap="200" alignItems="Center">
+          {privateRoom && (
+            <>
+              <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
+                <Text size="L400">Private Room</Text>
+              </Badge>
+              <Line
+                variant="SurfaceVariant"
+                style={{ height: toRem(12) }}
+                direction="Vertical"
+                size="400"
+              />
+            </>
+          )}
+          <Text size="T200" truncate>
+            {roomId}
+          </Text>
+        </Box>
+      </Box>
+      {!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
+    </Box>
+  );
+}
+
+type RoomProfileProps = {
+  roomId: string;
+  name: string;
+  topic?: string;
+  avatarUrl?: string;
+  suggested?: boolean;
+  memberCount?: number;
+  joinRule?: JoinRule;
+  options?: ReactNode;
+};
+function RoomProfile({
+  roomId,
+  name,
+  topic,
+  avatarUrl,
+  suggested,
+  memberCount,
+  joinRule,
+  options,
+}: RoomProfileProps) {
+  return (
+    <Box grow="Yes" gap="300">
+      <Avatar>
+        <RoomAvatar
+          roomId={roomId}
+          src={avatarUrl}
+          alt={name}
+          renderFallback={() => (
+            <RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
+          )}
+        />
+      </Avatar>
+      <Box grow="Yes" direction="Column">
+        <Box gap="200" alignItems="Center">
+          <Text size="H5" truncate>
+            {name}
+          </Text>
+          {suggested && (
+            <Box shrink="No" alignItems="Center">
+              <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+                <Text size="L400">Suggested</Text>
+              </Badge>
+            </Box>
+          )}
+        </Box>
+        <Box gap="200" alignItems="Center">
+          {memberCount && (
+            <Box shrink="No" gap="200">
+              <Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
+            </Box>
+          )}
+          {memberCount && topic && (
+            <Line
+              variant="SurfaceVariant"
+              style={{ height: toRem(12) }}
+              direction="Vertical"
+              size="400"
+            />
+          )}
+          {topic && (
+            <UseStateProvider initial={false}>
+              {(view, setView) => (
+                <>
+                  <Text
+                    className={css.RoomProfileTopic}
+                    size="T200"
+                    priority="300"
+                    truncate
+                    onClick={() => setView(true)}
+                    onKeyDown={onEnterOrSpace(() => setView(true))}
+                    tabIndex={0}
+                  >
+                    {topic}
+                  </Text>
+                  <Overlay open={view} backdrop={<OverlayBackdrop />}>
+                    <OverlayCenter>
+                      <FocusTrap
+                        focusTrapOptions={{
+                          initialFocus: false,
+                          clickOutsideDeactivates: true,
+                          onDeactivate: () => setView(false),
+                        }}
+                      >
+                        <RoomTopicViewer
+                          name={name}
+                          topic={topic}
+                          requestClose={() => setView(false)}
+                        />
+                      </FocusTrap>
+                    </OverlayCenter>
+                  </Overlay>
+                </>
+              )}
+            </UseStateProvider>
+          )}
+        </Box>
+      </Box>
+      {options}
+    </Box>
+  );
+}
+
+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;
+  dm?: boolean;
+  firstChild?: boolean;
+  lastChild?: boolean;
+  onOpen: MouseEventHandler<HTMLButtonElement>;
+  options?: ReactNode;
+  before?: ReactNode;
+  after?: ReactNode;
+  onDragging: (item?: HierarchyItem) => void;
+  canReorder: boolean;
+  getRoom: (roomId: string) => Room | undefined;
+};
+export const RoomItemCard = as<'div', RoomItemCardProps>(
+  (
+    {
+      item,
+      onSpaceFound,
+      dm,
+      firstChild,
+      lastChild,
+      onOpen,
+      options,
+      before,
+      after,
+      onDragging,
+      canReorder,
+      getRoom,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const { roomId, content } = item;
+    const room = getRoom(roomId);
+    const targetRef = useRef<HTMLDivElement>(null);
+    const targetHandleRef = useRef<HTMLDivElement>(null);
+    useDraggableItem(item, targetRef, onDragging, targetHandleRef);
+
+    const joined = room?.getMyMembership() === Membership.Join;
+
+    return (
+      <SequenceCard
+        className={css.RoomItemCard}
+        firstChild={firstChild}
+        lastChild={lastChild}
+        variant="SurfaceVariant"
+        gap="300"
+        alignItems="Center"
+        {...props}
+        ref={ref}
+      >
+        {before}
+        <Box ref={canReorder ? targetRef : null} grow="Yes">
+          {canReorder && <ItemDraggableTarget ref={targetHandleRef} />}
+          {room ? (
+            <LocalRoomSummaryLoader room={room}>
+              {(localSummary) => (
+                <RoomProfile
+                  roomId={roomId}
+                  name={localSummary.name}
+                  topic={localSummary.topic}
+                  avatarUrl={
+                    dm ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
+                  }
+                  memberCount={localSummary.memberCount}
+                  suggested={content.suggested}
+                  joinRule={localSummary.joinRule}
+                  options={
+                    joined ? (
+                      <Box shrink="No" gap="100" alignItems="Center">
+                        <Chip
+                          data-room-id={roomId}
+                          onClick={onOpen}
+                          variant="Secondary"
+                          fill="None"
+                          size="400"
+                          radii="Pill"
+                          aria-label="Open Room"
+                        >
+                          <Icon size="50" src={Icons.ArrowRight} />
+                        </Chip>
+                      </Box>
+                    ) : (
+                      <RoomJoinButton roomId={roomId} via={content.via} />
+                    )
+                  }
+                />
+              )}
+            </LocalRoomSummaryLoader>
+          ) : (
+            <HierarchyRoomSummaryLoader roomId={roomId}>
+              {(summaryState) => (
+                <>
+                  {summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
+                  {summaryState.status === AsyncStatus.Error && (
+                    <RoomProfileError
+                      roomId={roomId}
+                      error={summaryState.error}
+                      suggested={content.suggested}
+                      via={content.via}
+                    />
+                  )}
+                  {summaryState.status === AsyncStatus.Success && (
+                    <>
+                      {summaryState.data.room_type === RoomType.Space && (
+                        <CallbackOnFoundSpace
+                          roomId={summaryState.data.room_id}
+                          onSpaceFound={onSpaceFound}
+                        />
+                      )}
+                      <RoomProfile
+                        roomId={roomId}
+                        name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
+                        topic={summaryState.data.topic}
+                        avatarUrl={
+                          summaryState.data?.avatar_url
+                            ? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
+                              undefined
+                            : undefined
+                        }
+                        memberCount={summaryState.data.num_joined_members}
+                        suggested={content.suggested}
+                        joinRule={summaryState.data.join_rule}
+                        options={<RoomJoinButton roomId={roomId} via={content.via} />}
+                      />
+                    </>
+                  )}
+                </>
+              )}
+            </HierarchyRoomSummaryLoader>
+          )}
+        </Box>
+        {options}
+        {after}
+      </SequenceCard>
+    );
+  }
+);
diff --git a/src/app/features/lobby/SpaceItem.css.ts b/src/app/features/lobby/SpaceItem.css.ts
new file mode 100644 (file)
index 0000000..0574363
--- /dev/null
@@ -0,0 +1,39 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+import { recipe } from '@vanilla-extract/recipes';
+
+export const SpaceItemCard = recipe({
+  base: {
+    paddingBottom: config.space.S100,
+    borderBottom: `${config.borderWidth.B300} solid transparent`,
+    position: 'relative',
+    selectors: {
+      '&[data-dragging=true]': {
+        opacity: config.opacity.Disabled,
+      },
+    },
+  },
+  variants: {
+    outlined: {
+      true: {
+        borderBottomColor: color.Surface.ContainerLine,
+      },
+    },
+  },
+});
+export const HeaderChip = style({
+  paddingLeft: config.space.S200,
+  selectors: {
+    [`&[data-ui-before="true"]`]: {
+      paddingLeft: config.space.S100,
+    },
+  },
+});
+export const HeaderChipPlaceholder = style([
+  {
+    borderRadius: config.radii.R400,
+    paddingLeft: config.space.S100,
+    paddingRight: config.space.S300,
+    height: toRem(32),
+  },
+]);
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
new file mode 100644 (file)
index 0000000..4924f45
--- /dev/null
@@ -0,0 +1,493 @@
+import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react';
+import {
+  Box,
+  Avatar,
+  Text,
+  Chip,
+  Icon,
+  Icons,
+  as,
+  Badge,
+  toRem,
+  Spinner,
+  PopOut,
+  Menu,
+  MenuItem,
+  RectCords,
+  config,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import classNames from 'classnames';
+import { MatrixError, Room } from 'matrix-js-sdk';
+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 { 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';
+
+function SpaceProfileLoading() {
+  return (
+    <Box gap="200" alignItems="Center">
+      <Box grow="Yes" gap="200" alignItems="Center" className={css.HeaderChipPlaceholder}>
+        <Avatar className={styleCss.AvatarPlaceholder} size="200" radii="300" />
+        <Box
+          className={styleCss.LinePlaceholder}
+          shrink="No"
+          style={{ width: '100vw', maxWidth: toRem(120) }}
+        />
+      </Box>
+    </Box>
+  );
+}
+
+type UnknownPrivateSpaceProfileProps = {
+  roomId: string;
+  name?: string;
+  avatarUrl?: string;
+  suggested?: boolean;
+};
+function UnknownPrivateSpaceProfile({
+  roomId,
+  name,
+  avatarUrl,
+  suggested,
+}: UnknownPrivateSpaceProfileProps) {
+  return (
+    <Chip
+      as="span"
+      className={css.HeaderChip}
+      variant="Surface"
+      size="500"
+      before={
+        <Avatar size="200" radii="300">
+          <RoomAvatar
+            roomId={roomId}
+            src={avatarUrl}
+            alt={name}
+            renderFallback={() => (
+              <Text as="span" size="H6">
+                {nameInitials(name)}
+              </Text>
+            )}
+          />
+        </Avatar>
+      }
+    >
+      <Box alignItems="Center" gap="200">
+        <Text size="H4" truncate>
+          {name || 'Unknown'}
+        </Text>
+
+        <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
+          <Text size="L400">Private Space</Text>
+        </Badge>
+        {suggested && (
+          <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+            <Text size="L400">Suggested</Text>
+          </Badge>
+        )}
+      </Box>
+    </Chip>
+  );
+}
+
+type UnknownSpaceProfileProps = {
+  roomId: string;
+  via?: string[];
+  name?: string;
+  avatarUrl?: string;
+  suggested?: boolean;
+};
+function UnknownSpaceProfile({
+  roomId,
+  via,
+  name,
+  avatarUrl,
+  suggested,
+}: UnknownSpaceProfileProps) {
+  const mx = useMatrixClient();
+
+  const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
+    useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
+  );
+
+  const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
+  return (
+    <Chip
+      className={css.HeaderChip}
+      variant="Surface"
+      size="500"
+      onClick={join}
+      disabled={!canJoin}
+      before={
+        <Avatar size="200" radii="300">
+          <RoomAvatar
+            roomId={roomId}
+            src={avatarUrl}
+            alt={name}
+            renderFallback={() => (
+              <Text as="span" size="H6">
+                {nameInitials(name)}
+              </Text>
+            )}
+          />
+        </Avatar>
+      }
+      after={
+        canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="200" />
+      }
+    >
+      <Box alignItems="Center" gap="200">
+        <Text size="H4" truncate>
+          {name || 'Unknown'}
+        </Text>
+        {suggested && (
+          <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+            <Text size="L400">Suggested</Text>
+          </Badge>
+        )}
+        {joinState.status === AsyncStatus.Error && (
+          <Badge variant="Critical" fill="Soft" radii="Pill" outlined>
+            <Text size="L400" truncate>
+              {joinState.error.name}
+            </Text>
+          </Badge>
+        )}
+      </Box>
+    </Chip>
+  );
+}
+
+type SpaceProfileProps = {
+  roomId: string;
+  name: string;
+  avatarUrl?: string;
+  suggested?: boolean;
+  closed: boolean;
+  categoryId: string;
+  handleClose?: MouseEventHandler<HTMLButtonElement>;
+};
+function SpaceProfile({
+  roomId,
+  name,
+  avatarUrl,
+  suggested,
+  closed,
+  categoryId,
+  handleClose,
+}: SpaceProfileProps) {
+  return (
+    <Chip
+      data-category-id={categoryId}
+      onClick={handleClose}
+      className={css.HeaderChip}
+      variant="Surface"
+      size="500"
+      before={
+        <Avatar size="200" radii="300">
+          <RoomAvatar
+            roomId={roomId}
+            src={avatarUrl}
+            alt={name}
+            renderFallback={() => (
+              <Text as="span" size="H6">
+                {nameInitials(name)}
+              </Text>
+            )}
+          />
+        </Avatar>
+      }
+      after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
+    >
+      <Box alignItems="Center" gap="200">
+        <Text size="H4" truncate>
+          {name}
+        </Text>
+        {suggested && (
+          <Badge variant="Success" fill="Soft" radii="Pill" outlined>
+            <Text size="L400">Suggested</Text>
+          </Badge>
+        )}
+      </Box>
+    </Chip>
+  );
+}
+
+type RootSpaceProfileProps = {
+  closed: boolean;
+  categoryId: string;
+  handleClose?: MouseEventHandler<HTMLButtonElement>;
+};
+function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
+  return (
+    <Chip
+      data-category-id={categoryId}
+      onClick={handleClose}
+      className={css.HeaderChip}
+      variant="Surface"
+      size="500"
+      after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
+    >
+      <Box alignItems="Center" gap="200">
+        <Text size="H4" truncate>
+          Rooms
+        </Text>
+      </Box>
+    </Chip>
+  );
+}
+
+function AddRoomButton({ item }: { item: HierarchyItem }) {
+  const [cords, setCords] = useState<RectCords>();
+
+  const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const handleCreateRoom = () => {
+    openCreateRoom(false, item.roomId as any);
+    setCords(undefined);
+  };
+
+  const handleAddExisting = () => {
+    openSpaceAddExisting(item.roomId);
+    setCords(undefined);
+  };
+
+  return (
+    <PopOut
+      anchor={cords}
+      position="Bottom"
+      align="End"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setCords(undefined),
+            clickOutsideDeactivates: true,
+            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+          }}
+        >
+          <Menu style={{ padding: config.space.S100 }}>
+            <MenuItem
+              size="300"
+              radii="300"
+              variant="Primary"
+              fill="None"
+              onClick={handleCreateRoom}
+            >
+              <Text size="T300">New Room</Text>
+            </MenuItem>
+            <MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
+              <Text size="T300">Existing Room</Text>
+            </MenuItem>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        variant="Primary"
+        radii="Pill"
+        before={<Icon src={Icons.Plus} size="50" />}
+        onClick={handleAddRoom}
+        aria-pressed={!!cords}
+      >
+        <Text size="B300">Add Room</Text>
+      </Chip>
+    </PopOut>
+  );
+}
+
+function AddSpaceButton({ item }: { item: HierarchyItem }) {
+  const [cords, setCords] = useState<RectCords>();
+
+  const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const handleCreateSpace = () => {
+    openCreateRoom(true, item.roomId as any);
+    setCords(undefined);
+  };
+
+  const handleAddExisting = () => {
+    openSpaceAddExisting(item.roomId, true);
+    setCords(undefined);
+  };
+  return (
+    <PopOut
+      anchor={cords}
+      position="Bottom"
+      align="End"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setCords(undefined),
+            clickOutsideDeactivates: true,
+            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+          }}
+        >
+          <Menu style={{ padding: config.space.S100 }}>
+            <MenuItem
+              size="300"
+              radii="300"
+              variant="Primary"
+              fill="None"
+              onClick={handleCreateSpace}
+            >
+              <Text size="T300">New Space</Text>
+            </MenuItem>
+            <MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
+              <Text size="T300">Existing Space</Text>
+            </MenuItem>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        variant="SurfaceVariant"
+        radii="Pill"
+        before={<Icon src={Icons.Plus} size="50" />}
+        onClick={handleAddSpace}
+        aria-pressed={!!cords}
+      >
+        <Text size="B300">Add Space</Text>
+      </Chip>
+    </PopOut>
+  );
+}
+
+type SpaceItemCardProps = {
+  item: HierarchyItem;
+  joined?: boolean;
+  categoryId: string;
+  closed: boolean;
+  handleClose?: MouseEventHandler<HTMLButtonElement>;
+  options?: ReactNode;
+  before?: ReactNode;
+  after?: ReactNode;
+  canEditChild: boolean;
+  canReorder: boolean;
+  onDragging: (item?: HierarchyItem) => void;
+  getRoom: (roomId: string) => Room | undefined;
+};
+export const SpaceItemCard = as<'div', SpaceItemCardProps>(
+  (
+    {
+      className,
+      joined,
+      closed,
+      categoryId,
+      item,
+      handleClose,
+      options,
+      before,
+      after,
+      canEditChild,
+      canReorder,
+      onDragging,
+      getRoom,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const { roomId, content } = item;
+    const space = getRoom(roomId);
+    const targetRef = useRef<HTMLDivElement>(null);
+    useDraggableItem(item, targetRef, onDragging);
+
+    return (
+      <Box
+        shrink="No"
+        alignItems="Center"
+        gap="200"
+        className={classNames(css.SpaceItemCard({ outlined: !joined || closed }), className)}
+        {...props}
+        ref={ref}
+      >
+        {before}
+        <Box grow="Yes" gap="100" alignItems="Inherit" justifyContent="SpaceBetween">
+          <Box ref={canReorder ? targetRef : null}>
+            {space ? (
+              <LocalRoomSummaryLoader room={space}>
+                {(localSummary) =>
+                  item.parentId ? (
+                    <SpaceProfile
+                      roomId={roomId}
+                      name={localSummary.name}
+                      avatarUrl={getRoomAvatarUrl(mx, space, 96)}
+                      suggested={content.suggested}
+                      closed={closed}
+                      categoryId={categoryId}
+                      handleClose={handleClose}
+                    />
+                  ) : (
+                    <RootSpaceProfile
+                      closed={closed}
+                      categoryId={categoryId}
+                      handleClose={handleClose}
+                    />
+                  )
+                }
+              </LocalRoomSummaryLoader>
+            ) : (
+              <HierarchyRoomSummaryLoader roomId={roomId}>
+                {(summaryState) => (
+                  <>
+                    {summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
+                    {summaryState.status === AsyncStatus.Error &&
+                      (summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
+                        <UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
+                      ) : (
+                        <UnknownSpaceProfile
+                          roomId={roomId}
+                          via={item.content.via}
+                          suggested={content.suggested}
+                        />
+                      ))}
+                    {summaryState.status === AsyncStatus.Success && (
+                      <UnknownSpaceProfile
+                        roomId={roomId}
+                        via={item.content.via}
+                        name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
+                        avatarUrl={
+                          summaryState.data?.avatar_url
+                            ? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
+                              undefined
+                            : undefined
+                        }
+                        suggested={content.suggested}
+                      />
+                    )}
+                  </>
+                )}
+              </HierarchyRoomSummaryLoader>
+            )}
+          </Box>
+          {canEditChild && (
+            <Box alignItems="Inherit" gap="200">
+              <AddRoomButton item={item} />
+              {item.parentId === undefined && <AddSpaceButton item={item} />}
+            </Box>
+          )}
+        </Box>
+        {options}
+        {after}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/features/lobby/index.ts b/src/app/features/lobby/index.ts
new file mode 100644 (file)
index 0000000..08355c7
--- /dev/null
@@ -0,0 +1 @@
+export * from './Lobby';
diff --git a/src/app/features/lobby/style.css.ts b/src/app/features/lobby/style.css.ts
new file mode 100644 (file)
index 0000000..1c7891b
--- /dev/null
@@ -0,0 +1,15 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const AvatarPlaceholder = style({
+  backgroundColor: color.Secondary.Container,
+});
+export const LinePlaceholder = style([
+  DefaultReset,
+  {
+    width: '100%',
+    height: config.lineHeight.T200,
+    borderRadius: config.radii.R300,
+    backgroundColor: color.Secondary.Container,
+  },
+]);
diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
new file mode 100644 (file)
index 0000000..5793ed9
--- /dev/null
@@ -0,0 +1,329 @@
+import React, { RefObject, useEffect, useMemo, useRef } from 'react';
+import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
+import { useAtomValue } from 'jotai';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useSearchParams } from 'react-router-dom';
+import { SearchOrderBy } from 'matrix-js-sdk';
+import { PageHero, PageHeroSection } from '../../components/page';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { _SearchPathSearchParams } from '../../pages/paths';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { SequenceCard } from '../../components/sequence-card';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { ScrollTopContainer } from '../../components/scroll-top-container';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
+import { useRooms } from '../../state/hooks/roomList';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+import { mDirectAtom } from '../../state/mDirectList';
+import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
+import { SearchResultGroup } from './SearchResultGroup';
+import { SearchInput } from './SearchInput';
+import { SearchFilters } from './SearchFilters';
+import { VirtualTile } from '../../components/virtualizer';
+
+const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
+  useMemo(
+    () => ({
+      global: searchParams.get('global') ?? undefined,
+      term: searchParams.get('term') ?? undefined,
+      order: searchParams.get('order') ?? undefined,
+      rooms: searchParams.get('rooms') ?? undefined,
+      senders: searchParams.get('senders') ?? undefined,
+    }),
+    [searchParams]
+  );
+
+type MessageSearchProps = {
+  defaultRoomsFilterName: string;
+  allowGlobal?: boolean;
+  rooms: string[];
+  senders?: string[];
+  scrollRef: RefObject<HTMLDivElement>;
+};
+export function MessageSearch({
+  defaultRoomsFilterName,
+  allowGlobal,
+  rooms,
+  senders,
+  scrollRef,
+}: MessageSearchProps) {
+  const mx = useMatrixClient();
+  const mDirects = useAtomValue(mDirectAtom);
+  const allRooms = useRooms(mx, allRoomsAtom, mDirects);
+  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+  const [searchParams, setSearchParams] = useSearchParams();
+  const searchPathSearchParams = useSearchPathSearchParams(searchParams);
+  const { navigateRoom } = useRoomNavigate();
+
+  const searchParamRooms = useMemo(() => {
+    if (searchPathSearchParams.rooms) {
+      const joinedRoomIds = decodeSearchParamValueArray(searchPathSearchParams.rooms).filter(
+        (rId) => allRooms.includes(rId)
+      );
+      return joinedRoomIds;
+    }
+    return undefined;
+  }, [allRooms, searchPathSearchParams.rooms]);
+  const searchParamsSenders = useMemo(() => {
+    if (searchPathSearchParams.senders) {
+      return decodeSearchParamValueArray(searchPathSearchParams.senders);
+    }
+    return undefined;
+  }, [searchPathSearchParams.senders]);
+
+  const msgSearchParams: MessageSearchParams = useMemo(() => {
+    const isGlobal = searchPathSearchParams.global === 'true';
+    const defaultRooms = isGlobal ? undefined : rooms;
+
+    return {
+      term: searchPathSearchParams.term,
+      order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
+      rooms: searchParamRooms ?? defaultRooms,
+      senders: searchParamsSenders ?? senders,
+    };
+  }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
+
+  const searchMessages = useMessageSearch(msgSearchParams);
+
+  const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
+    enabled: !!msgSearchParams.term,
+    queryKey: [
+      'search',
+      msgSearchParams.term,
+      msgSearchParams.order,
+      msgSearchParams.rooms,
+      msgSearchParams.senders,
+    ],
+    queryFn: ({ pageParam }) => searchMessages(pageParam),
+    initialPageParam: '',
+    getNextPageParam: (lastPage) => lastPage.nextToken,
+  });
+
+  const groups = useMemo(() => data?.pages.flatMap((result) => result.groups) ?? [], [data]);
+  const highlights = useMemo(() => {
+    const mixed = data?.pages.flatMap((result) => result.highlights);
+    return Array.from(new Set(mixed));
+  }, [data]);
+
+  const virtualizer = useVirtualizer({
+    count: groups.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 40,
+    overscan: 1,
+  });
+  const vItems = virtualizer.getVirtualItems();
+
+  const handleSearch = (term: string) => {
+    setSearchParams((prevParams) => {
+      const newParams = new URLSearchParams(prevParams);
+      newParams.delete('term');
+      newParams.append('term', term);
+      return newParams;
+    });
+  };
+  const handleSearchClear = () => {
+    if (searchInputRef.current) {
+      searchInputRef.current.value = '';
+    }
+    setSearchParams((prevParams) => {
+      const newParams = new URLSearchParams(prevParams);
+      newParams.delete('term');
+      return newParams;
+    });
+  };
+
+  const handleSelectedRoomsChange = (selectedRooms?: string[]) => {
+    setSearchParams((prevParams) => {
+      const newParams = new URLSearchParams(prevParams);
+      newParams.delete('rooms');
+      if (selectedRooms && selectedRooms.length > 0) {
+        newParams.append('rooms', encodeSearchParamValueArray(selectedRooms));
+      }
+      return newParams;
+    });
+  };
+  const handleGlobalChange = (global?: boolean) => {
+    setSearchParams((prevParams) => {
+      const newParams = new URLSearchParams(prevParams);
+      newParams.delete('global');
+      if (global) {
+        newParams.append('global', 'true');
+      }
+      return newParams;
+    });
+  };
+
+  const handleOrderChange = (order?: string) => {
+    setSearchParams((prevParams) => {
+      const newParams = new URLSearchParams(prevParams);
+      newParams.delete('order');
+      if (order) {
+        newParams.append('order', order);
+      }
+      return newParams;
+    });
+  };
+
+  const lastVItem = vItems[vItems.length - 1];
+  const lastVItemIndex: number | undefined = lastVItem?.index;
+  const lastGroupIndex = groups.length - 1;
+  useEffect(() => {
+    if (
+      lastGroupIndex > -1 &&
+      lastGroupIndex === lastVItemIndex &&
+      !isFetchingNextPage &&
+      hasNextPage
+    ) {
+      fetchNextPage();
+    }
+  }, [lastVItemIndex, lastGroupIndex, fetchNextPage, isFetchingNextPage, hasNextPage]);
+
+  return (
+    <Box direction="Column" gap="700">
+      <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
+        <IconButton
+          onClick={() => virtualizer.scrollToOffset(0)}
+          variant="SurfaceVariant"
+          radii="Pill"
+          outlined
+          size="300"
+          aria-label="Scroll to Top"
+        >
+          <Icon src={Icons.ChevronTop} size="300" />
+        </IconButton>
+      </ScrollTopContainer>
+      <Box ref={scrollTopAnchorRef} direction="Column" gap="300">
+        <SearchInput
+          active={!!msgSearchParams.term}
+          loading={status === 'pending'}
+          searchInputRef={searchInputRef}
+          onSearch={handleSearch}
+          onReset={handleSearchClear}
+        />
+        <SearchFilters
+          defaultRoomsFilterName={defaultRoomsFilterName}
+          allowGlobal={allowGlobal}
+          roomList={searchPathSearchParams.global === 'true' ? allRooms : rooms}
+          selectedRooms={searchParamRooms}
+          onSelectedRoomsChange={handleSelectedRoomsChange}
+          global={searchPathSearchParams.global === 'true'}
+          onGlobalChange={handleGlobalChange}
+          order={msgSearchParams.order}
+          onOrderChange={handleOrderChange}
+        />
+      </Box>
+
+      {!msgSearchParams.term && status === 'pending' && (
+        <Box
+          className={ContainerColor({ variant: 'SurfaceVariant' })}
+          style={{
+            padding: config.space.S400,
+            borderRadius: config.radii.R400,
+            minHeight: toRem(450),
+          }}
+          direction="Column"
+          alignItems="Center"
+          justifyContent="Center"
+          gap="200"
+        >
+          <PageHeroSection>
+            <PageHero
+              icon={<Icon size="600" src={Icons.Message} />}
+              title="Search Messages"
+              subTitle="Find helpful messages in your community by searching with related keywords."
+            />
+          </PageHeroSection>
+        </Box>
+      )}
+
+      {msgSearchParams.term && groups.length === 0 && status === 'success' && (
+        <Box
+          className={ContainerColor({ variant: 'Warning' })}
+          style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
+          alignItems="Center"
+          gap="200"
+        >
+          <Icon size="200" src={Icons.Info} />
+          <Text>
+            No results found for <b>{`"${msgSearchParams.term}"`}</b>
+          </Text>
+        </Box>
+      )}
+
+      {((msgSearchParams.term && status === 'pending') ||
+        (groups.length > 0 && vItems.length === 0)) && (
+        <Box direction="Column" gap="100">
+          {[...Array(8).keys()].map((key) => (
+            <SequenceCard variant="SurfaceVariant" key={key} style={{ minHeight: toRem(80) }} />
+          ))}
+        </Box>
+      )}
+
+      {vItems.length > 0 && (
+        <Box direction="Column" gap="300">
+          <Box direction="Column" gap="200">
+            <Text size="H5">{`Results for "${msgSearchParams.term}"`}</Text>
+            <Line size="300" variant="Surface" />
+          </Box>
+          <div
+            style={{
+              position: 'relative',
+              height: virtualizer.getTotalSize(),
+            }}
+          >
+            {vItems.map((vItem) => {
+              const group = groups[vItem.index];
+              if (!group) return null;
+              const groupRoom = mx.getRoom(group.roomId);
+              if (!groupRoom) return null;
+
+              return (
+                <VirtualTile
+                  virtualItem={vItem}
+                  style={{ paddingBottom: config.space.S500 }}
+                  ref={virtualizer.measureElement}
+                  key={vItem.index}
+                >
+                  <SearchResultGroup
+                    room={groupRoom}
+                    highlights={highlights}
+                    items={group.items}
+                    mediaAutoLoad={mediaAutoLoad}
+                    urlPreview={urlPreview}
+                    onOpen={navigateRoom}
+                  />
+                </VirtualTile>
+              );
+            })}
+          </div>
+          {isFetchingNextPage && (
+            <Box justifyContent="Center" alignItems="Center">
+              <Spinner size="600" variant="Secondary" />
+            </Box>
+          )}
+        </Box>
+      )}
+
+      {error && (
+        <Box
+          className={ContainerColor({ variant: 'Critical' })}
+          style={{
+            padding: config.space.S300,
+            borderRadius: config.radii.R400,
+          }}
+          direction="Column"
+          gap="200"
+        >
+          <Text size="L400">{error.name}</Text>
+          <Text size="T300">{error.message}</Text>
+        </Box>
+      )}
+    </Box>
+  );
+}
diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx
new file mode 100644 (file)
index 0000000..5de188d
--- /dev/null
@@ -0,0 +1,413 @@
+import React, {
+  ChangeEventHandler,
+  MouseEventHandler,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Box,
+  Chip,
+  Text,
+  Icon,
+  Icons,
+  Line,
+  config,
+  PopOut,
+  Menu,
+  MenuItem,
+  Header,
+  toRem,
+  Scroll,
+  Button,
+  Input,
+  Badge,
+  RectCords,
+} from 'folds';
+import { SearchOrderBy } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { joinRuleToIconSrc } from '../../utils/room';
+import { factoryRoomIdByAtoZ } from '../../utils/sort';
+import {
+  SearchItemStrGetter,
+  UseAsyncSearchOptions,
+  useAsyncSearch,
+} from '../../hooks/useAsyncSearch';
+import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
+import { VirtualTile } from '../../components/virtualizer';
+
+type OrderButtonProps = {
+  order?: string;
+  onChange: (order?: string) => void;
+};
+function OrderButton({ order, onChange }: OrderButtonProps) {
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+  const rankOrder = order === SearchOrderBy.Rank;
+
+  const setOrder = (o?: string) => {
+    setMenuAnchor(undefined);
+    onChange(o);
+  };
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PopOut
+      anchor={menuAnchor}
+      align="End"
+      position="Bottom"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setMenuAnchor(undefined),
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Menu variant="Surface">
+            <Header size="300" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
+              <Text size="L400">Sort by</Text>
+            </Header>
+            <Line variant="Surface" size="300" />
+            <div style={{ padding: config.space.S100 }}>
+              <MenuItem
+                onClick={() => setOrder()}
+                variant="Surface"
+                size="300"
+                radii="300"
+                aria-pressed={!rankOrder}
+              >
+                <Text size="T300">Recent</Text>
+              </MenuItem>
+              <MenuItem
+                onClick={() => setOrder(SearchOrderBy.Rank)}
+                variant="Surface"
+                size="300"
+                radii="300"
+                aria-pressed={rankOrder}
+              >
+                <Text size="T300">Relevance</Text>
+              </MenuItem>
+            </div>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        variant="SurfaceVariant"
+        radii="Pill"
+        after={<Icon size="50" src={Icons.Sort} />}
+        onClick={handleOpenMenu}
+      >
+        {rankOrder ? <Text size="T200">Relevance</Text> : <Text size="T200">Recent</Text>}
+      </Chip>
+    </PopOut>
+  );
+}
+
+const SEARCH_OPTS: UseAsyncSearchOptions = {
+  limit: 20,
+  matchOptions: {
+    contain: true,
+  },
+};
+const SEARCH_DEBOUNCE_OPTS: DebounceOptions = {
+  wait: 200,
+};
+
+type SelectRoomButtonProps = {
+  roomList: string[];
+  selectedRooms?: string[];
+  onChange: (rooms?: string[]) => void;
+};
+function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
+  const mx = useMatrixClient();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+  const [localSelected, setLocalSelected] = useState(selectedRooms);
+
+  const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
+    (rId) => mx.getRoom(rId)?.name ?? rId,
+    [mx]
+  );
+
+  const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
+    roomList,
+    getRoomNameStr,
+    SEARCH_OPTS
+  );
+  const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
+
+  const virtualizer = useVirtualizer({
+    count: rooms.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 32,
+    overscan: 5,
+  });
+  const vItems = virtualizer.getVirtualItems();
+
+  const searchRoom = useDebounce(_searchRoom, SEARCH_DEBOUNCE_OPTS);
+  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+    const value = evt.currentTarget.value.trim();
+    if (!value) {
+      resetSearch();
+      return;
+    }
+    searchRoom(value);
+  };
+
+  const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const roomId = evt.currentTarget.getAttribute('data-room-id');
+    if (!roomId) return;
+    if (localSelected?.includes(roomId)) {
+      setLocalSelected(localSelected?.filter((rId) => rId !== roomId));
+      return;
+    }
+    const addedRooms = [...(localSelected ?? [])];
+    addedRooms.push(roomId);
+    setLocalSelected(addedRooms);
+  };
+
+  const handleSave = () => {
+    setMenuAnchor(undefined);
+    onChange(localSelected);
+  };
+
+  const handleDeselectAll = () => {
+    setMenuAnchor(undefined);
+    onChange(undefined);
+  };
+
+  useEffect(() => {
+    setLocalSelected(selectedRooms);
+    resetSearch();
+  }, [menuAnchor, selectedRooms, resetSearch]);
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PopOut
+      anchor={menuAnchor}
+      align="Center"
+      position="Bottom"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setMenuAnchor(undefined),
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Menu variant="Surface" style={{ width: toRem(250) }}>
+            <Box direction="Column" style={{ maxHeight: toRem(450), maxWidth: toRem(300) }}>
+              <Box
+                shrink="No"
+                direction="Column"
+                gap="100"
+                style={{ padding: config.space.S200, paddingBottom: 0 }}
+              >
+                <Text size="L400">Search</Text>
+                <Input
+                  onChange={handleSearchChange}
+                  size="300"
+                  radii="300"
+                  after={
+                    searchResult && searchResult.items.length > 0 ? (
+                      <Badge variant="Secondary" size="400" radii="Pill">
+                        <Text size="L400">{searchResult.items.length}</Text>
+                      </Badge>
+                    ) : null
+                  }
+                />
+              </Box>
+              <Scroll ref={scrollRef} size="300" hideTrack>
+                <Box
+                  direction="Column"
+                  gap="100"
+                  style={{
+                    padding: config.space.S200,
+                    paddingRight: 0,
+                  }}
+                >
+                  {!searchResult && <Text size="L400">Rooms</Text>}
+                  {searchResult && <Text size="L400">{`Rooms for "${searchResult.query}"`}</Text>}
+                  {searchResult && searchResult.items.length === 0 && (
+                    <Text style={{ padding: config.space.S400 }} size="T300" align="Center">
+                      No match found!
+                    </Text>
+                  )}
+                  <div
+                    style={{
+                      position: 'relative',
+                      height: virtualizer.getTotalSize(),
+                    }}
+                  >
+                    {vItems.map((vItem) => {
+                      const roomId = rooms[vItem.index];
+                      const room = mx.getRoom(roomId);
+                      if (!room) return null;
+                      const selected = localSelected?.includes(roomId);
+
+                      return (
+                        <VirtualTile
+                          virtualItem={vItem}
+                          style={{ paddingBottom: config.space.S100 }}
+                          ref={virtualizer.measureElement}
+                          key={vItem.index}
+                        >
+                          <MenuItem
+                            data-room-id={roomId}
+                            onClick={handleRoomClick}
+                            variant={selected ? 'Success' : 'Surface'}
+                            size="300"
+                            radii="300"
+                            aria-pressed={selected}
+                            before={
+                              <Icon
+                                size="50"
+                                src={
+                                  joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
+                                }
+                              />
+                            }
+                          >
+                            <Text truncate size="T300">
+                              {room.name}
+                            </Text>
+                          </MenuItem>
+                        </VirtualTile>
+                      );
+                    })}
+                  </div>
+                </Box>
+              </Scroll>
+              <Line variant="Surface" size="300" />
+              <Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
+                <Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
+                  {localSelected && localSelected.length > 0 ? (
+                    <Text size="B300">Save ({localSelected.length})</Text>
+                  ) : (
+                    <Text size="B300">Save</Text>
+                  )}
+                </Button>
+                <Button
+                  size="300"
+                  radii="300"
+                  variant="Secondary"
+                  fill="Soft"
+                  onClick={handleDeselectAll}
+                  disabled={!localSelected || localSelected.length === 0}
+                >
+                  <Text size="B300">Deselect All</Text>
+                </Button>
+              </Box>
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        onClick={handleOpenMenu}
+        variant="SurfaceVariant"
+        radii="Pill"
+        before={<Icon size="100" src={Icons.PlusCircle} />}
+      >
+        <Text size="T200">Select Rooms</Text>
+      </Chip>
+    </PopOut>
+  );
+}
+
+type SearchFiltersProps = {
+  defaultRoomsFilterName: string;
+  allowGlobal?: boolean;
+  roomList: string[];
+  selectedRooms?: string[];
+  onSelectedRoomsChange: (selectedRooms?: string[]) => void;
+  global?: boolean;
+  onGlobalChange: (global?: boolean) => void;
+  order?: string;
+  onOrderChange: (order?: string) => void;
+};
+export function SearchFilters({
+  defaultRoomsFilterName,
+  allowGlobal,
+  roomList,
+  selectedRooms,
+  onSelectedRoomsChange,
+  global,
+  order,
+  onGlobalChange,
+  onOrderChange,
+}: SearchFiltersProps) {
+  const mx = useMatrixClient();
+
+  return (
+    <Box direction="Column" gap="100">
+      <Text size="L400">Filter</Text>
+      <Box gap="200" wrap="Wrap">
+        <Chip
+          variant={!global ? 'Success' : 'Surface'}
+          aria-pressed={!global}
+          before={!global && <Icon size="100" src={Icons.Check} />}
+          outlined
+          onClick={() => onGlobalChange()}
+        >
+          <Text size="T200">{defaultRoomsFilterName}</Text>
+        </Chip>
+        {allowGlobal && (
+          <Chip
+            variant={global ? 'Success' : 'Surface'}
+            aria-pressed={global}
+            before={global && <Icon size="100" src={Icons.Check} />}
+            outlined
+            onClick={() => onGlobalChange(true)}
+          >
+            <Text size="T200">Global</Text>
+          </Chip>
+        )}
+        <Line
+          style={{ margin: `${config.space.S100} 0` }}
+          direction="Vertical"
+          variant="Surface"
+          size="300"
+        />
+        {selectedRooms?.map((roomId) => {
+          const room = mx.getRoom(roomId);
+          if (!room) return null;
+
+          return (
+            <Chip
+              key={roomId}
+              variant="Success"
+              onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
+              radii="Pill"
+              before={
+                <Icon
+                  size="50"
+                  src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
+                />
+              }
+              after={<Icon size="50" src={Icons.Cross} />}
+            >
+              <Text size="T200">{room.name}</Text>
+            </Chip>
+          );
+        })}
+        <SelectRoomButton
+          roomList={roomList}
+          selectedRooms={selectedRooms}
+          onChange={onSelectedRoomsChange}
+        />
+        <Box grow="Yes" data-spacing-node />
+        <OrderButton order={order} onChange={onOrderChange} />
+      </Box>
+    </Box>
+  );
+}
diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx
new file mode 100644 (file)
index 0000000..db646c2
--- /dev/null
@@ -0,0 +1,66 @@
+import React, { FormEventHandler, RefObject } from 'react';
+import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds';
+
+type SearchProps = {
+  active?: boolean;
+  loading?: boolean;
+  searchInputRef: RefObject<HTMLInputElement>;
+  onSearch: (term: string) => void;
+  onReset: () => void;
+};
+export function SearchInput({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
+  const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const { searchInput } = evt.target as HTMLFormElement & {
+      searchInput: HTMLInputElement;
+    };
+
+    const searchTerm = searchInput.value.trim() || undefined;
+    if (searchTerm) {
+      onSearch(searchTerm);
+    }
+  };
+
+  return (
+    <Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
+      <span data-spacing-node />
+      <Text size="L400">Search</Text>
+      <Input
+        ref={searchInputRef}
+        style={{ paddingRight: config.space.S300 }}
+        name="searchInput"
+        size="500"
+        variant="Background"
+        placeholder="Search for keyword"
+        autoComplete="off"
+        before={
+          active && loading ? (
+            <Spinner variant="Secondary" size="200" />
+          ) : (
+            <Icon size="200" src={Icons.Search} />
+          )
+        }
+        after={
+          active ? (
+            <Chip
+              key="resetButton"
+              type="reset"
+              variant="Secondary"
+              size="400"
+              radii="Pill"
+              outlined
+              after={<Icon size="50" src={Icons.Cross} />}
+              onClick={onReset}
+            >
+              <Text size="B300">Clear</Text>
+            </Chip>
+          ) : (
+            <Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
+              <Text size="B300">Enter</Text>
+            </Chip>
+          )
+        }
+      />
+    </Box>
+  );
+}
diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx
new file mode 100644 (file)
index 0000000..6f84f62
--- /dev/null
@@ -0,0 +1,262 @@
+/* eslint-disable react/destructuring-assignment */
+import React, { MouseEventHandler, useMemo } from 'react';
+import { IEventWithRoomId, JoinRule, RelationType, Room } from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import {
+  getReactCustomHtmlParser,
+  makeHighlightRegex,
+} from '../../plugins/react-custom-html-parser';
+import { getMxIdLocalPart, isRoomId, isUserId } from '../../utils/matrix';
+import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
+import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
+import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
+import {
+  AvatarBase,
+  ImageContent,
+  MSticker,
+  ModernLayout,
+  RedactedContent,
+  Reply,
+  Time,
+  Username,
+} from '../../components/message';
+import { RenderMessageContent } from '../../components/RenderMessageContent';
+import { Image } from '../../components/media';
+import { ImageViewer } from '../../components/image-viewer';
+import * as customHtmlCss from '../../styles/CustomHtml.css';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
+import colorMXID from '../../../util/colorMXID';
+import { ResultItem } from './useMessageSearch';
+import { SequenceCard } from '../../components/sequence-card';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { UserAvatar } from '../../components/user-avatar';
+
+type SearchResultGroupProps = {
+  room: Room;
+  highlights: string[];
+  items: ResultItem[];
+  mediaAutoLoad?: boolean;
+  urlPreview?: boolean;
+  onOpen: (roomId: string, eventId: string) => void;
+};
+export function SearchResultGroup({
+  room,
+  highlights,
+  items,
+  mediaAutoLoad,
+  urlPreview,
+  onOpen,
+}: SearchResultGroupProps) {
+  const mx = useMatrixClient();
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+  const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
+
+  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+    () =>
+      getReactCustomHtmlParser(mx, room, {
+        highlightRegex,
+        handleSpoilerClick: (evt) => {
+          const target = evt.currentTarget;
+          if (target.getAttribute('aria-pressed') === 'true') {
+            evt.stopPropagation();
+            target.setAttribute('aria-pressed', 'false');
+            target.style.cursor = 'initial';
+          }
+        },
+        handleMentionClick: (evt) => {
+          const target = evt.currentTarget;
+          const mentionId = target.getAttribute('data-mention-id');
+          if (typeof mentionId !== 'string') return;
+          if (isUserId(mentionId)) {
+            openProfileViewer(mentionId, room.roomId);
+            return;
+          }
+          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+            if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
+            else navigateRoom(mentionId);
+            return;
+          }
+          openJoinAlias(mentionId);
+        },
+      }),
+    [mx, room, highlightRegex, navigateRoom, navigateSpace]
+  );
+
+  const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
+    {
+      [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
+        if (event.unsigned?.redacted_because) {
+          return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+        }
+
+        return (
+          <RenderMessageContent
+            displayName={displayName}
+            msgType={event.content.msgtype ?? ''}
+            ts={event.origin_server_ts}
+            getContent={getContent}
+            mediaAutoLoad={mediaAutoLoad}
+            urlPreview={urlPreview}
+            htmlReactParserOptions={htmlReactParserOptions}
+            highlightRegex={highlightRegex}
+            outlineAttachment
+          />
+        );
+      },
+      [MessageEvent.Reaction]: (event, displayName, getContent) => {
+        if (event.unsigned?.redacted_because) {
+          return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+        }
+        return (
+          <MSticker
+            content={getContent()}
+            renderImageContent={(props) => (
+              <ImageContent
+                {...props}
+                autoPlay={mediaAutoLoad}
+                renderImage={(p) => <Image {...p} loading="lazy" />}
+                renderViewer={(p) => <ImageViewer {...p} />}
+              />
+            )}
+          />
+        );
+      },
+      [StateEvent.RoomTombstone]: (event) => {
+        const { content } = event;
+        return (
+          <Box grow="Yes" direction="Column">
+            <Text size="T400" priority="300">
+              Room Tombstone. {content.body}
+            </Text>
+          </Box>
+        );
+      },
+    },
+    undefined,
+    (event) => {
+      if (event.unsigned?.redacted_because) {
+        return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+      }
+      return (
+        <Box grow="Yes" direction="Column">
+          <Text size="T400" priority="300">
+            <code className={customHtmlCss.Code}>{event.type}</code>
+            {' event'}
+          </Text>
+        </Box>
+      );
+    }
+  );
+
+  const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const eventId = evt.currentTarget.getAttribute('data-event-id');
+    if (!eventId) return;
+    onOpen(room.roomId, eventId);
+  };
+
+  return (
+    <Box direction="Column" gap="200">
+      <Header size="300">
+        <Box gap="200" grow="Yes">
+          <Avatar size="200" radii="300">
+            <RoomAvatar
+              roomId={room.roomId}
+              src={getRoomAvatarUrl(mx, room, 96)}
+              alt={room.name}
+              renderFallback={() => (
+                <RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
+              )}
+            />
+          </Avatar>
+          <Text size="H4" truncate>
+            {room.name}
+          </Text>
+        </Box>
+      </Header>
+      <Box direction="Column" gap="100">
+        {items.map((item) => {
+          const { event } = item;
+
+          const displayName =
+            getMemberDisplayName(room, event.sender) ??
+            getMxIdLocalPart(event.sender) ??
+            event.sender;
+          const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+
+          const mainEventId =
+            event.content['m.relates_to']?.rel_type === RelationType.Replace
+              ? event.content['m.relates_to'].event_id
+              : event.event_id;
+
+          const getContent = (() =>
+            event.content['m.new_content'] ?? event.content) as GetContentCallback;
+
+          const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
+
+          return (
+            <SequenceCard
+              key={event.event_id}
+              style={{ padding: config.space.S400 }}
+              variant="SurfaceVariant"
+              direction="Column"
+            >
+              <ModernLayout
+                before={
+                  <AvatarBase>
+                    <Avatar size="300">
+                      <UserAvatar
+                        userId={event.sender}
+                        src={
+                          senderAvatarMxc
+                            ? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
+                            : undefined
+                        }
+                        alt={displayName}
+                        renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+                      />
+                    </Avatar>
+                  </AvatarBase>
+                }
+              >
+                <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+                  <Box gap="200" alignItems="Baseline">
+                    <Username style={{ color: colorMXID(event.sender) }}>
+                      <Text as="span" truncate>
+                        <b>{displayName}</b>
+                      </Text>
+                    </Username>
+                    <Time ts={event.origin_server_ts} />
+                  </Box>
+                  <Box shrink="No" gap="200" alignItems="Center">
+                    <Chip
+                      data-event-id={mainEventId}
+                      onClick={handleOpenClick}
+                      variant="Secondary"
+                      radii="400"
+                    >
+                      <Text size="T200">Open</Text>
+                    </Chip>
+                  </Box>
+                </Box>
+                {replyEventId && (
+                  <Reply
+                    as="button"
+                    mx={mx}
+                    room={room}
+                    eventId={replyEventId}
+                    data-event-id={replyEventId}
+                    onClick={handleOpenClick}
+                  />
+                )}
+                {renderMatrixEvent(event.type, false, event, displayName, getContent)}
+              </ModernLayout>
+            </SequenceCard>
+          );
+        })}
+      </Box>
+    </Box>
+  );
+}
diff --git a/src/app/features/message-search/index.ts b/src/app/features/message-search/index.ts
new file mode 100644 (file)
index 0000000..5160d12
--- /dev/null
@@ -0,0 +1 @@
+export * from './MessageSearch';
diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts
new file mode 100644 (file)
index 0000000..ded6d4b
--- /dev/null
@@ -0,0 +1,115 @@
+import {
+  IEventWithRoomId,
+  IResultContext,
+  ISearchRequestBody,
+  ISearchResponse,
+  ISearchResult,
+  SearchOrderBy,
+} from 'matrix-js-sdk';
+import { useCallback } from 'react';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+
+export type ResultItem = {
+  rank: number;
+  event: IEventWithRoomId;
+  context: IResultContext;
+};
+
+export type ResultGroup = {
+  roomId: string;
+  items: ResultItem[];
+};
+
+export type SearchResult = {
+  nextToken?: string;
+  highlights: string[];
+  groups: ResultGroup[];
+};
+
+const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
+  const groups: ResultGroup[] = [];
+
+  results.forEach((item) => {
+    const roomId = item.result.room_id;
+    const resultItem: ResultItem = {
+      rank: item.rank,
+      event: item.result,
+      context: item.context,
+    };
+
+    const lastAddedGroup: ResultGroup | undefined = groups[groups.length - 1];
+    if (lastAddedGroup && roomId === lastAddedGroup.roomId) {
+      lastAddedGroup.items.push(resultItem);
+      return;
+    }
+    groups.push({
+      roomId,
+      items: [resultItem],
+    });
+  });
+
+  return groups;
+};
+
+const parseSearchResult = (result: ISearchResponse): SearchResult => {
+  const roomEvents = result.search_categories.room_events;
+
+  const searchResult: SearchResult = {
+    nextToken: roomEvents?.next_batch,
+    highlights: roomEvents?.highlights ?? [],
+    groups: groupSearchResult(roomEvents?.results ?? []),
+  };
+
+  return searchResult;
+};
+
+export type MessageSearchParams = {
+  term?: string;
+  order?: string;
+  rooms?: string[];
+  senders?: string[];
+};
+export const useMessageSearch = (params: MessageSearchParams) => {
+  const mx = useMatrixClient();
+  const { term, order, rooms, senders } = params;
+
+  const searchMessages = useCallback(
+    async (nextBatch?: string) => {
+      if (!term)
+        return {
+          highlights: [],
+          groups: [],
+        };
+      const limit = 20;
+
+      const requestBody: ISearchRequestBody = {
+        search_categories: {
+          room_events: {
+            event_context: {
+              before_limit: 0,
+              after_limit: 0,
+              include_profile: false,
+            },
+            filter: {
+              limit,
+              rooms,
+              senders,
+            },
+            include_state: false,
+            order_by: order as SearchOrderBy.Recent,
+            search_term: term,
+          },
+        },
+      };
+
+      const r = await mx.search({
+        body: requestBody,
+        next_batch: nextBatch === '' ? undefined : nextBatch,
+      });
+      return parseSearchResult(r);
+    },
+    [mx, term, order, rooms, senders]
+  );
+
+  return searchMessages;
+};
diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx
new file mode 100644 (file)
index 0000000..7acbbe2
--- /dev/null
@@ -0,0 +1,27 @@
+import React from 'react';
+import { as, Chip, Icon, Icons, Text } from 'folds';
+import classNames from 'classnames';
+import * as css from './styles.css';
+
+export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
+  ({ className, closed, children, ...props }, ref) => (
+    <Chip
+      className={classNames(css.CategoryButton, className)}
+      variant="Background"
+      radii="Pill"
+      before={
+        <Icon
+          className={css.CategoryButtonIcon}
+          size="50"
+          src={closed ? Icons.ChevronRight : Icons.ChevronBottom}
+        />
+      }
+      {...props}
+      ref={ref}
+    >
+      <Text size="O400" priority="400" truncate>
+        {children}
+      </Text>
+    </Chip>
+  )
+);
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
new file mode 100644 (file)
index 0000000..fce6237
--- /dev/null
@@ -0,0 +1,297 @@
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import { Room } from 'matrix-js-sdk';
+import {
+  Avatar,
+  Box,
+  Icon,
+  IconButton,
+  Icons,
+  Text,
+  Menu,
+  MenuItem,
+  config,
+  PopOut,
+  toRem,
+  Line,
+  RectCords,
+  Badge,
+} from 'folds';
+import { useFocusWithin, useHover } from 'react-aria';
+import FocusTrap from 'focus-trap-react';
+import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
+import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
+import { nameInitials } from '../../utils/common';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomUnread } from '../../state/hooks/unread';
+import { roomToUnreadAtom } from '../../state/room/roomToUnread';
+import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { copyToClipboard } from '../../utils/dom';
+import { getOriginBaseUrl, withOriginBaseUrl } from '../../pages/pathUtils';
+import { markAsRead } from '../../../client/action/notifications';
+import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
+import { useClientConfig } from '../../hooks/useClientConfig';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+
+type RoomNavItemMenuProps = {
+  room: Room;
+  linkPath: string;
+  requestClose: () => void;
+};
+const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
+  ({ room, linkPath, requestClose }, ref) => {
+    const mx = useMatrixClient();
+    const { hashRouter } = useClientConfig();
+    const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+    const powerLevels = usePowerLevels(room);
+    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+    const handleMarkAsRead = () => {
+      markAsRead(room.roomId);
+      requestClose();
+    };
+
+    const handleInvite = () => {
+      openInviteUser(room.roomId);
+      requestClose();
+    };
+
+    const handleCopyLink = () => {
+      copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
+      requestClose();
+    };
+
+    const handleRoomSettings = () => {
+      toggleRoomSettings(room.roomId);
+      requestClose();
+    };
+
+    return (
+      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleMarkAsRead}
+            size="300"
+            after={<Icon size="100" src={Icons.CheckTwice} />}
+            radii="300"
+            disabled={!unread}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Mark as Read
+            </Text>
+          </MenuItem>
+        </Box>
+        <Line variant="Surface" size="300" />
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleInvite}
+            variant="Primary"
+            fill="None"
+            size="300"
+            after={<Icon size="100" src={Icons.UserPlus} />}
+            radii="300"
+            disabled={!canInvite}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Invite
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleCopyLink}
+            size="300"
+            after={<Icon size="100" src={Icons.Link} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Copy Link
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleRoomSettings}
+            size="300"
+            after={<Icon size="100" src={Icons.Setting} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Room Settings
+            </Text>
+          </MenuItem>
+        </Box>
+        <Line variant="Surface" size="300" />
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <UseStateProvider initial={false}>
+            {(promptLeave, setPromptLeave) => (
+              <>
+                <MenuItem
+                  onClick={() => setPromptLeave(true)}
+                  variant="Critical"
+                  fill="None"
+                  size="300"
+                  after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+                  radii="300"
+                  aria-pressed={promptLeave}
+                >
+                  <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                    Leave Room
+                  </Text>
+                </MenuItem>
+                {promptLeave && (
+                  <LeaveRoomPrompt
+                    roomId={room.roomId}
+                    onDone={requestClose}
+                    onCancel={() => setPromptLeave(false)}
+                  />
+                )}
+              </>
+            )}
+          </UseStateProvider>
+        </Box>
+      </Menu>
+    );
+  }
+);
+
+type RoomNavItemProps = {
+  room: Room;
+  selected: boolean;
+  linkPath: string;
+  muted?: boolean;
+  showAvatar?: boolean;
+  direct?: boolean;
+};
+export function RoomNavItem({
+  room,
+  selected,
+  showAvatar,
+  direct,
+  muted,
+  linkPath,
+}: RoomNavItemProps) {
+  const mx = useMatrixClient();
+  const [hover, setHover] = useState(false);
+  const { hoverProps } = useHover({ onHoverChange: setHover });
+  const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+  const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+  const typingMember = useRoomTypingMember(room.roomId);
+
+  const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
+    evt.preventDefault();
+    setMenuAnchor({
+      x: evt.clientX,
+      y: evt.clientY,
+      width: 0,
+      height: 0,
+    });
+  };
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const optionsVisible = hover || !!menuAnchor;
+
+  return (
+    <NavItem
+      variant="Background"
+      radii="400"
+      highlight={unread !== undefined}
+      aria-selected={selected}
+      data-hover={!!menuAnchor}
+      onContextMenu={handleContextMenu}
+      {...hoverProps}
+      {...focusWithinProps}
+    >
+      <NavLink to={linkPath}>
+        <NavItemContent>
+          <Box as="span" grow="Yes" alignItems="Center" gap="200">
+            <Avatar size="200" radii="400">
+              {showAvatar ? (
+                <RoomAvatar
+                  roomId={room.roomId}
+                  src={
+                    direct ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
+                  }
+                  alt={room.name}
+                  renderFallback={() => (
+                    <Text as="span" size="H6">
+                      {nameInitials(room.name)}
+                    </Text>
+                  )}
+                />
+              ) : (
+                <RoomIcon
+                  style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
+                  filled={selected}
+                  size="100"
+                  joinRule={room.getJoinRule()}
+                />
+              )}
+            </Avatar>
+            <Box as="span" grow="Yes">
+              <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
+                {room.name}
+              </Text>
+            </Box>
+            {!optionsVisible && !unread && !selected && typingMember.length > 0 && (
+              <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+                <TypingIndicator size="300" disableAnimation />
+              </Badge>
+            )}
+            {!optionsVisible && unread && (
+              <UnreadBadgeCenter>
+                <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
+              </UnreadBadgeCenter>
+            )}
+            {muted && !optionsVisible && <Icon size="50" src={Icons.BellMute} />}
+          </Box>
+        </NavItemContent>
+      </NavLink>
+      {optionsVisible && (
+        <NavItemOptions>
+          <PopOut
+            anchor={menuAnchor}
+            offset={menuAnchor?.width === 0 ? 0 : undefined}
+            alignOffset={menuAnchor?.width === 0 ? 0 : -5}
+            position="Bottom"
+            align={menuAnchor?.width === 0 ? 'Start' : 'End'}
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setMenuAnchor(undefined),
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                }}
+              >
+                <RoomNavItemMenu
+                  room={room}
+                  linkPath={linkPath}
+                  requestClose={() => setMenuAnchor(undefined)}
+                />
+              </FocusTrap>
+            }
+          >
+            <IconButton
+              onClick={handleOpenMenu}
+              aria-pressed={!!menuAnchor}
+              variant="Background"
+              fill="None"
+              size="300"
+              radii="300"
+            >
+              <Icon size="50" src={Icons.VerticalDots} />
+            </IconButton>
+          </PopOut>
+        </NavItemOptions>
+      )}
+    </NavItem>
+  );
+}
diff --git a/src/app/features/room-nav/index.ts b/src/app/features/room-nav/index.ts
new file mode 100644 (file)
index 0000000..d1ea3ec
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './RoomNavItem';
+export * from './RoomNavCategoryButton';
diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts
new file mode 100644 (file)
index 0000000..b5701a6
--- /dev/null
@@ -0,0 +1,9 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const CategoryButton = style({
+  flexGrow: 1,
+});
+export const CategoryButtonIcon = style({
+  opacity: config.opacity.P400,
+});
diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx
new file mode 100644 (file)
index 0000000..31903ac
--- /dev/null
@@ -0,0 +1,109 @@
+import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
+import { Editor } from 'slate';
+import { Box, MenuItem, Text } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { Command, useCommands } from '../../hooks/useCommands';
+import {
+  AutocompleteMenu,
+  AutocompleteQuery,
+  createCommandElement,
+  moveCursor,
+  replaceWithElement,
+} from '../../components/editor';
+import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useKeyDown } from '../../hooks/useKeyDown';
+import { onTabPress } from '../../utils/keyboard';
+
+type CommandAutoCompleteHandler = (commandName: string) => void;
+
+type CommandAutocompleteProps = {
+  room: Room;
+  editor: Editor;
+  query: AutocompleteQuery<string>;
+  requestClose: () => void;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  matchOptions: {
+    contain: true,
+  },
+};
+
+export function CommandAutocomplete({
+  room,
+  editor,
+  query,
+  requestClose,
+}: CommandAutocompleteProps) {
+  const mx = useMatrixClient();
+  const commands = useCommands(mx, room);
+  const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
+
+  const [result, search, resetSearch] = useAsyncSearch(
+    commandNames,
+    useCallback((commandName: string) => commandName, []),
+    SEARCH_OPTIONS
+  );
+
+  const autoCompleteNames = result ? result.items : commandNames;
+
+  useEffect(() => {
+    if (query.text) search(query.text);
+    else resetSearch();
+  }, [query.text, search, resetSearch]);
+
+  const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
+    const cmdEl = createCommandElement(commandName);
+    replaceWithElement(editor, query.range, cmdEl);
+    moveCursor(editor, true);
+    requestClose();
+  };
+
+  useKeyDown(window, (evt: KeyboardEvent) => {
+    onTabPress(evt, () => {
+      if (autoCompleteNames.length === 0) {
+        return;
+      }
+      const cmdName = autoCompleteNames[0];
+      handleAutocomplete(cmdName);
+    });
+  });
+
+  return autoCompleteNames.length === 0 ? null : (
+    <AutocompleteMenu
+      headerContent={
+        <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
+          <Text size="L400">Commands</Text>
+          <Text size="T200" priority="300" truncate>
+            Begin your message with command
+          </Text>
+        </Box>
+      }
+      requestClose={requestClose}
+    >
+      {autoCompleteNames.map((commandName) => (
+        <MenuItem
+          key={commandName}
+          as="button"
+          radii="300"
+          onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+            onTabPress(evt, () => handleAutocomplete(commandName))
+          }
+          onClick={() => handleAutocomplete(commandName)}
+        >
+          <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
+            <Box shrink="No">
+              <Text style={{ flexGrow: 1 }} size="B400" truncate>
+                {`/${commandName}`}
+              </Text>
+            </Box>
+            <Text truncate priority="300" size="T200">
+              {commands[commandName].description}
+            </Text>
+          </Box>
+        </MenuItem>
+      ))}
+    </AutocompleteMenu>
+  );
+}
diff --git a/src/app/features/room/MembersDrawer.css.ts b/src/app/features/room/MembersDrawer.css.ts
new file mode 100644 (file)
index 0000000..a1f4153
--- /dev/null
@@ -0,0 +1,64 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const MembersDrawer = style({
+  width: toRem(266),
+  backgroundColor: color.Background.Container,
+  color: color.Background.OnContainer,
+});
+
+export const MembersDrawerHeader = style({
+  flexShrink: 0,
+  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+  borderBottomWidth: config.borderWidth.B300,
+});
+
+export const MemberDrawerContentBase = style({
+  position: 'relative',
+  overflow: 'hidden',
+});
+
+export const MemberDrawerContent = style({
+  padding: `${config.space.S200} 0`,
+});
+
+const ScrollBtnAnime = keyframes({
+  '0%': {
+    transform: `translate(-50%, -100%) scale(0)`,
+  },
+  '100%': {
+    transform: `translate(-50%, 0) scale(1)`,
+  },
+});
+
+export const DrawerScrollTop = style({
+  position: 'absolute',
+  top: config.space.S200,
+  left: '50%',
+  transform: 'translateX(-50%)',
+  zIndex: 1,
+  animation: `${ScrollBtnAnime} 100ms`,
+});
+
+export const DrawerGroup = style({
+  paddingLeft: config.space.S200,
+});
+
+export const MembersGroup = style({
+  paddingLeft: config.space.S200,
+});
+export const MembersGroupLabel = style({
+  padding: config.space.S200,
+  selectors: {
+    '&:not(:first-child)': {
+      paddingTop: config.space.S500,
+    },
+  },
+});
+
+export const DrawerVirtualItem = style({
+  position: 'absolute',
+  top: 0,
+  left: 0,
+  width: '100%',
+});
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
new file mode 100644 (file)
index 0000000..8a96b84
--- /dev/null
@@ -0,0 +1,546 @@
+import React, {
+  ChangeEventHandler,
+  MouseEventHandler,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Avatar,
+  Badge,
+  Box,
+  Chip,
+  ContainerColor,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Scroll,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import classNames from 'classnames';
+
+import { openProfileViewer } from '../../../client/action/navigation';
+import * as css from './MembersDrawer.css';
+import { useRoomMembers } from '../../hooks/useRoomMembers';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { Membership } from '../../../types/matrix/room';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import {
+  SearchItemStrGetter,
+  UseAsyncSearchOptions,
+  useAsyncSearch,
+} from '../../hooks/useAsyncSearch';
+import { useDebounce } from '../../hooks/useDebounce';
+import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { useSetSetting, useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { millify } from '../../plugins/millify';
+import { ScrollTopContainer } from '../../components/scroll-top-container';
+import { UserAvatar } from '../../components/user-avatar';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+
+export const MembershipFilters = {
+  filterJoined: (m: RoomMember) => m.membership === Membership.Join,
+  filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
+  filterLeaved: (m: RoomMember) =>
+    m.membership === Membership.Leave &&
+    m.events.member?.getStateKey() === m.events.member?.getSender(),
+  filterKicked: (m: RoomMember) =>
+    m.membership === Membership.Leave &&
+    m.events.member?.getStateKey() !== m.events.member?.getSender(),
+  filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
+};
+
+export type MembershipFilterFn = (m: RoomMember) => boolean;
+
+export type MembershipFilter = {
+  name: string;
+  filterFn: MembershipFilterFn;
+  color: ContainerColor;
+};
+
+const useMembershipFilterMenu = (): MembershipFilter[] =>
+  useMemo(
+    () => [
+      {
+        name: 'Joined',
+        filterFn: MembershipFilters.filterJoined,
+        color: 'Background',
+      },
+      {
+        name: 'Invited',
+        filterFn: MembershipFilters.filterInvited,
+        color: 'Success',
+      },
+      {
+        name: 'Left',
+        filterFn: MembershipFilters.filterLeaved,
+        color: 'Secondary',
+      },
+      {
+        name: 'Kicked',
+        filterFn: MembershipFilters.filterKicked,
+        color: 'Warning',
+      },
+      {
+        name: 'Banned',
+        filterFn: MembershipFilters.filterBanned,
+        color: 'Critical',
+      },
+    ],
+    []
+  );
+
+export const SortFilters = {
+  filterAscending: (a: RoomMember, b: RoomMember) =>
+    a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
+  filterDescending: (a: RoomMember, b: RoomMember) =>
+    a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
+  filterNewestFirst: (a: RoomMember, b: RoomMember) =>
+    (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
+  filterOldest: (a: RoomMember, b: RoomMember) =>
+    (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
+};
+
+export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
+
+export type SortFilter = {
+  name: string;
+  filterFn: SortFilterFn;
+};
+
+const useSortFilterMenu = (): SortFilter[] =>
+  useMemo(
+    () => [
+      {
+        name: 'A to Z',
+        filterFn: SortFilters.filterAscending,
+      },
+      {
+        name: 'Z to A',
+        filterFn: SortFilters.filterDescending,
+      },
+      {
+        name: 'Newest',
+        filterFn: SortFilters.filterNewestFirst,
+      },
+      {
+        name: 'Oldest',
+        filterFn: SortFilters.filterOldest,
+      },
+    ],
+    []
+  );
+
+export type MembersFilterOptions = {
+  membershipFilter: MembershipFilter;
+  sortFilter: SortFilter;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  limit: 100,
+  matchOptions: {
+    contain: true,
+  },
+};
+
+const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
+const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
+  getMemberSearchStr(m, query, mxIdToName);
+
+type MembersDrawerProps = {
+  room: Room;
+};
+export function MembersDrawer({ room }: MembersDrawerProps) {
+  const mx = useMatrixClient();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+  const members = useRoomMembers(mx, room.roomId);
+  const getPowerLevelTag = usePowerLevelTags();
+  const fetchingMembers = members.length < room.getJoinedMemberCount();
+  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+
+  const membershipFilterMenu = useMembershipFilterMenu();
+  const sortFilterMenu = useSortFilterMenu();
+  const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
+  const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
+
+  const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
+  const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
+
+  const typingMembers = useRoomTypingMember(room.roomId);
+
+  const filteredMembers = useMemo(
+    () =>
+      members
+        .filter(membershipFilter.filterFn)
+        .sort(sortFilter.filterFn)
+        .sort((a, b) => b.powerLevel - a.powerLevel),
+    [members, membershipFilter, sortFilter]
+  );
+
+  const [result, search, resetSearch] = useAsyncSearch(
+    filteredMembers,
+    getRoomMemberStr,
+    SEARCH_OPTIONS
+  );
+  if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
+
+  const processMembers = result ? result.items : filteredMembers;
+
+  const PLTagOrRoomMember = useMemo(() => {
+    let prevTag: PowerLevelTag | undefined;
+    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
+    processMembers.forEach((m) => {
+      const plTag = getPowerLevelTag(m.powerLevel);
+      if (plTag !== prevTag) {
+        prevTag = plTag;
+        tagOrMember.push(plTag);
+      }
+      tagOrMember.push(m);
+    });
+    return tagOrMember;
+  }, [processMembers, getPowerLevelTag]);
+
+  const virtualizer = useVirtualizer({
+    count: PLTagOrRoomMember.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 40,
+    overscan: 10,
+  });
+
+  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
+    useCallback(
+      (evt) => {
+        if (evt.target.value) search(evt.target.value);
+        else resetSearch();
+      },
+      [search, resetSearch]
+    ),
+    { wait: 200 }
+  );
+
+  const getName = (member: RoomMember) =>
+    getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+  const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const btn = evt.currentTarget as HTMLButtonElement;
+    const userId = btn.getAttribute('data-user-id');
+    openProfileViewer(userId, room.roomId);
+  };
+
+  return (
+    <Box className={css.MembersDrawer} shrink="No" direction="Column">
+      <Header className={css.MembersDrawerHeader} variant="Background" size="600">
+        <Box grow="Yes" alignItems="Center" gap="200">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
+              {`${millify(room.getJoinedMemberCount())} Members`}
+            </Text>
+          </Box>
+          <Box shrink="No" alignItems="Center">
+            <TooltipProvider
+              position="Bottom"
+              align="End"
+              offset={4}
+              tooltip={
+                <Tooltip>
+                  <Text>Close</Text>
+                </Tooltip>
+              }
+            >
+              {(triggerRef) => (
+                <IconButton
+                  ref={triggerRef}
+                  variant="Background"
+                  onClick={() => setPeopleDrawer(false)}
+                >
+                  <Icon src={Icons.Cross} />
+                </IconButton>
+              )}
+            </TooltipProvider>
+          </Box>
+        </Box>
+      </Header>
+      <Box className={css.MemberDrawerContentBase} grow="Yes">
+        <Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
+          <Box className={css.MemberDrawerContent} direction="Column" gap="200">
+            <Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
+              <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
+                <UseStateProvider initial={undefined}>
+                  {(anchor: RectCords | undefined, setAnchor) => (
+                    <PopOut
+                      anchor={anchor}
+                      position="Bottom"
+                      align="Start"
+                      offset={4}
+                      content={
+                        <FocusTrap
+                          focusTrapOptions={{
+                            initialFocus: false,
+                            onDeactivate: () => setAnchor(undefined),
+                            clickOutsideDeactivates: true,
+                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                          }}
+                        >
+                          <Menu style={{ padding: config.space.S100 }}>
+                            {membershipFilterMenu.map((menuItem, index) => (
+                              <MenuItem
+                                key={menuItem.name}
+                                variant={
+                                  menuItem.name === membershipFilter.name
+                                    ? menuItem.color
+                                    : 'Surface'
+                                }
+                                aria-pressed={menuItem.name === membershipFilter.name}
+                                size="300"
+                                radii="300"
+                                onClick={() => {
+                                  setMembershipFilterIndex(index);
+                                  setAnchor(undefined);
+                                }}
+                              >
+                                <Text size="T300">{menuItem.name}</Text>
+                              </MenuItem>
+                            ))}
+                          </Menu>
+                        </FocusTrap>
+                      }
+                    >
+                      <Chip
+                        onClick={
+                          ((evt) =>
+                            setAnchor(
+                              evt.currentTarget.getBoundingClientRect()
+                            )) as MouseEventHandler<HTMLButtonElement>
+                        }
+                        variant={membershipFilter.color}
+                        size="400"
+                        radii="300"
+                        before={<Icon src={Icons.Filter} size="50" />}
+                      >
+                        <Text size="T200">{membershipFilter.name}</Text>
+                      </Chip>
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+                <UseStateProvider initial={undefined}>
+                  {(anchor: RectCords | undefined, setAnchor) => (
+                    <PopOut
+                      anchor={anchor}
+                      position="Bottom"
+                      align="End"
+                      offset={4}
+                      content={
+                        <FocusTrap
+                          focusTrapOptions={{
+                            initialFocus: false,
+                            onDeactivate: () => setAnchor(undefined),
+                            clickOutsideDeactivates: true,
+                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                          }}
+                        >
+                          <Menu style={{ padding: config.space.S100 }}>
+                            {sortFilterMenu.map((menuItem, index) => (
+                              <MenuItem
+                                key={menuItem.name}
+                                variant="Surface"
+                                aria-pressed={menuItem.name === sortFilter.name}
+                                size="300"
+                                radii="300"
+                                onClick={() => {
+                                  setSortFilterIndex(index);
+                                  setAnchor(undefined);
+                                }}
+                              >
+                                <Text size="T300">{menuItem.name}</Text>
+                              </MenuItem>
+                            ))}
+                          </Menu>
+                        </FocusTrap>
+                      }
+                    >
+                      <Chip
+                        onClick={
+                          ((evt) =>
+                            setAnchor(
+                              evt.currentTarget.getBoundingClientRect()
+                            )) as MouseEventHandler<HTMLButtonElement>
+                        }
+                        variant="Background"
+                        size="400"
+                        radii="300"
+                        after={<Icon src={Icons.Sort} size="50" />}
+                      >
+                        <Text size="T200">{sortFilter.name}</Text>
+                      </Chip>
+                    </PopOut>
+                  )}
+                </UseStateProvider>
+              </Box>
+              <Box direction="Column" gap="100">
+                <Input
+                  ref={searchInputRef}
+                  onChange={handleSearchChange}
+                  style={{ paddingRight: config.space.S200 }}
+                  placeholder="Type name..."
+                  variant="Surface"
+                  size="400"
+                  radii="400"
+                  before={<Icon size="50" src={Icons.Search} />}
+                  after={
+                    result && (
+                      <Chip
+                        variant={result.items.length > 0 ? 'Success' : 'Critical'}
+                        size="400"
+                        radii="Pill"
+                        aria-pressed
+                        onClick={() => {
+                          if (searchInputRef.current) {
+                            searchInputRef.current.value = '';
+                            searchInputRef.current.focus();
+                          }
+                          resetSearch();
+                        }}
+                        after={<Icon size="50" src={Icons.Cross} />}
+                      >
+                        <Text size="B300">{`${result.items.length || 'No'} ${
+                          result.items.length === 1 ? 'Result' : 'Results'
+                        }`}</Text>
+                      </Chip>
+                    )
+                  }
+                />
+              </Box>
+            </Box>
+
+            <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
+              <IconButton
+                onClick={() => virtualizer.scrollToOffset(0)}
+                variant="Surface"
+                radii="Pill"
+                outlined
+                size="300"
+                aria-label="Scroll to Top"
+              >
+                <Icon src={Icons.ChevronTop} size="300" />
+              </IconButton>
+            </ScrollTopContainer>
+
+            {!fetchingMembers && !result && processMembers.length === 0 && (
+              <Text style={{ padding: config.space.S300 }} align="Center">
+                {`No "${membershipFilter.name}" Members`}
+              </Text>
+            )}
+
+            <Box className={css.MembersGroup} direction="Column" gap="100">
+              <div
+                style={{
+                  position: 'relative',
+                  height: virtualizer.getTotalSize(),
+                }}
+              >
+                {virtualizer.getVirtualItems().map((vItem) => {
+                  const tagOrMember = PLTagOrRoomMember[vItem.index];
+                  if (!('userId' in tagOrMember)) {
+                    return (
+                      <Text
+                        style={{
+                          transform: `translateY(${vItem.start}px)`,
+                        }}
+                        data-index={vItem.index}
+                        ref={virtualizer.measureElement}
+                        key={`${room.roomId}-${vItem.index}`}
+                        className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
+                        size="L400"
+                      >
+                        {tagOrMember.name}
+                      </Text>
+                    );
+                  }
+
+                  const member = tagOrMember;
+                  const name = getName(member);
+                  const avatarUrl = member.getAvatarUrl(
+                    mx.baseUrl,
+                    100,
+                    100,
+                    'crop',
+                    undefined,
+                    false
+                  );
+
+                  return (
+                    <MenuItem
+                      style={{
+                        padding: `0 ${config.space.S200}`,
+                        transform: `translateY(${vItem.start}px)`,
+                      }}
+                      data-index={vItem.index}
+                      data-user-id={member.userId}
+                      ref={virtualizer.measureElement}
+                      key={`${room.roomId}-${member.userId}`}
+                      className={css.DrawerVirtualItem}
+                      variant="Background"
+                      radii="400"
+                      onClick={handleMemberClick}
+                      before={
+                        <Avatar size="200">
+                          <UserAvatar
+                            userId={member.userId}
+                            src={avatarUrl ?? undefined}
+                            alt={name}
+                            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+                          />
+                        </Avatar>
+                      }
+                      after={
+                        typingMembers.find((receipt) => receipt.userId === member.userId) && (
+                          <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+                            <TypingIndicator size="300" />
+                          </Badge>
+                        )
+                      }
+                    >
+                      <Box grow="Yes">
+                        <Text size="T400" truncate>
+                          {name}
+                        </Text>
+                      </Box>
+                    </MenuItem>
+                  );
+                })}
+              </div>
+            </Box>
+
+            {fetchingMembers && (
+              <Box justifyContent="Center">
+                <Spinner />
+              </Box>
+            )}
+          </Box>
+        </Scroll>
+      </Box>
+    </Box>
+  );
+}
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
new file mode 100644 (file)
index 0000000..764e968
--- /dev/null
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Box, Line } from 'folds';
+import { useParams } from 'react-router-dom';
+import { RoomView } from './RoomView';
+import { MembersDrawer } from './MembersDrawer';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
+import { useRoom } from '../../hooks/useRoom';
+
+export function Room() {
+  const { eventId } = useParams();
+  const room = useRoom();
+
+  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+  const screenSize = useScreenSizeContext();
+  const powerLevels = usePowerLevels(room);
+
+  return (
+    <PowerLevelsContextProvider value={powerLevels}>
+      <Box grow="Yes">
+        <RoomView room={room} eventId={eventId} />
+        {screenSize === ScreenSize.Desktop && isDrawer && (
+          <>
+            <Line variant="Background" direction="Vertical" size="300" />
+            <MembersDrawer key={room.roomId} room={room} />
+          </>
+        )}
+      </Box>
+    </PowerLevelsContextProvider>
+  );
+}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
new file mode 100644 (file)
index 0000000..6f9a249
--- /dev/null
@@ -0,0 +1,609 @@
+import React, {
+  KeyboardEventHandler,
+  RefObject,
+  forwardRef,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { useAtom } from 'jotai';
+import { isKeyHotkey } from 'is-hotkey';
+import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
+import { ReactEditor } from 'slate-react';
+import { Transforms, Editor } from 'slate';
+import {
+  Box,
+  Dialog,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  PopOut,
+  Scroll,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import {
+  CustomEditor,
+  Toolbar,
+  toMatrixCustomHTML,
+  toPlainText,
+  AUTOCOMPLETE_PREFIXES,
+  AutocompletePrefix,
+  AutocompleteQuery,
+  getAutocompleteQuery,
+  getPrevWorldRange,
+  resetEditor,
+  RoomMentionAutocomplete,
+  UserMentionAutocomplete,
+  EmoticonAutocomplete,
+  createEmoticonElement,
+  moveCursor,
+  resetEditorHistory,
+  customHtmlEqualsPlainText,
+  trimCustomHtml,
+  isEmptyEditor,
+  getBeginCommand,
+  trimCommand,
+} from '../../components/editor';
+import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
+import { UseStateProvider } from '../../components/UseStateProvider';
+import initMatrix from '../../../client/initMatrix';
+import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
+import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
+import { useFilePicker } from '../../hooks/useFilePicker';
+import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
+import { useFileDropZone } from '../../hooks/useFileDrop';
+import {
+  TUploadItem,
+  roomIdToMsgDraftAtomFamily,
+  roomIdToReplyDraftAtomFamily,
+  roomIdToUploadItemsAtomFamily,
+  roomUploadAtomFamily,
+} from '../../state/room/roomInputDrafts';
+import { UploadCardRenderer } from '../../components/upload-card';
+import {
+  UploadBoard,
+  UploadBoardContent,
+  UploadBoardHeader,
+  UploadBoardImperativeHandlers,
+} from '../../components/upload-board';
+import {
+  Upload,
+  UploadStatus,
+  UploadSuccess,
+  createUploadFamilyObserverAtom,
+} from '../../state/upload';
+import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
+import { safeFile } from '../../utils/mimeTypes';
+import { fulfilledPromiseSettledResult } from '../../utils/common';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import {
+  getAudioMsgContent,
+  getFileMsgContent,
+  getImageMsgContent,
+  getVideoMsgContent,
+} from './msgContent';
+import colorMXID from '../../../util/colorMXID';
+import {
+  getMemberDisplayName,
+  parseReplyBody,
+  parseReplyFormattedBody,
+  trimReplyFromBody,
+  trimReplyFromFormattedBody,
+} from '../../utils/room';
+import { sanitizeText } from '../../utils/sanitize';
+import { CommandAutocomplete } from './CommandAutocomplete';
+import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
+import { mobileOrTablet } from '../../utils/user-agent';
+import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
+import { ReplyLayout } from '../../components/message';
+
+interface RoomInputProps {
+  editor: Editor;
+  fileDropContainerRef: RefObject<HTMLElement>;
+  roomId: string;
+  room: Room;
+}
+export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
+  ({ editor, fileDropContainerRef, roomId, room }, ref) => {
+    const mx = useMatrixClient();
+    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
+    const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+    const commands = useCommands(mx, room);
+    const emojiBtnRef = useRef<HTMLButtonElement>(null);
+
+    const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
+    const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
+    const [uploadBoard, setUploadBoard] = useState(true);
+    const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
+    const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
+      roomUploadAtomFamily,
+      selectedFiles.map((f) => f.file)
+    );
+    const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
+
+    const imagePackRooms: Room[] = useMemo(() => {
+      const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
+      return allParentSpaces.reduce<Room[]>((list, rId) => {
+        const r = mx.getRoom(rId);
+        if (r) list.push(r);
+        return list;
+      }, []);
+    }, [mx, roomId]);
+
+    const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
+    const [autocompleteQuery, setAutocompleteQuery] =
+      useState<AutocompleteQuery<AutocompletePrefix>>();
+
+    const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
+
+    const handleFiles = useCallback(
+      async (files: File[]) => {
+        setUploadBoard(true);
+        const safeFiles = files.map(safeFile);
+        const fileItems: TUploadItem[] = [];
+
+        if (mx.isRoomEncrypted(roomId)) {
+          const encryptFiles = fulfilledPromiseSettledResult(
+            await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
+          );
+          encryptFiles.forEach((ef) => fileItems.push(ef));
+        } else {
+          safeFiles.forEach((f) =>
+            fileItems.push({ file: f, originalFile: f, encInfo: undefined })
+          );
+        }
+        setSelectedFiles({
+          type: 'PUT',
+          item: fileItems,
+        });
+      },
+      [setSelectedFiles, roomId, mx]
+    );
+    const pickFile = useFilePicker(handleFiles, true);
+    const handlePaste = useFilePasteHandler(handleFiles);
+    const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
+    const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
+
+    useElementSizeObserver(
+      useCallback(() => document.body, []),
+      useCallback((width) => setHideStickerBtn(width < 500), [])
+    );
+
+    useEffect(() => {
+      Transforms.insertFragment(editor, msgDraft);
+    }, [editor, msgDraft]);
+
+    useEffect(() => {
+      if (!mobileOrTablet()) ReactEditor.focus(editor);
+      return () => {
+        if (!isEmptyEditor(editor)) {
+          const parsedDraft = JSON.parse(JSON.stringify(editor.children));
+          setMsgDraft(parsedDraft);
+        } else {
+          setMsgDraft([]);
+        }
+        resetEditor(editor);
+        resetEditorHistory(editor);
+      };
+    }, [roomId, editor, setMsgDraft]);
+
+    const handleRemoveUpload = useCallback(
+      (upload: TUploadContent | TUploadContent[]) => {
+        const uploads = Array.isArray(upload) ? upload : [upload];
+        setSelectedFiles({
+          type: 'DELETE',
+          item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
+        });
+        uploads.forEach((u) => roomUploadAtomFamily.remove(u));
+      },
+      [setSelectedFiles, selectedFiles]
+    );
+
+    const handleCancelUpload = (uploads: Upload[]) => {
+      uploads.forEach((upload) => {
+        if (upload.status === UploadStatus.Loading) {
+          mx.cancelUpload(upload.promise);
+        }
+      });
+      handleRemoveUpload(uploads.map((upload) => upload.file));
+    };
+
+    const handleSendUpload = async (uploads: UploadSuccess[]) => {
+      const contentsPromises = uploads.map(async (upload) => {
+        const fileItem = selectedFiles.find((f) => f.file === upload.file);
+        if (!fileItem) throw new Error('Broken upload');
+
+        if (fileItem.file.type.startsWith('image')) {
+          return getImageMsgContent(mx, fileItem, upload.mxc);
+        }
+        if (fileItem.file.type.startsWith('video')) {
+          return getVideoMsgContent(mx, fileItem, upload.mxc);
+        }
+        if (fileItem.file.type.startsWith('audio')) {
+          return getAudioMsgContent(fileItem, upload.mxc);
+        }
+        return getFileMsgContent(fileItem, upload.mxc);
+      });
+      handleCancelUpload(uploads);
+      const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
+      contents.forEach((content) => mx.sendMessage(roomId, content));
+    };
+
+    const submit = useCallback(() => {
+      uploadBoardHandlers.current?.handleSend();
+
+      const commandName = getBeginCommand(editor);
+
+      let plainText = toPlainText(editor.children).trim();
+      let customHtml = trimCustomHtml(
+        toMatrixCustomHTML(editor.children, {
+          allowTextFormatting: true,
+          allowBlockMarkdown: isMarkdown,
+          allowInlineMarkdown: isMarkdown,
+        })
+      );
+      let msgType = MsgType.Text;
+
+      if (commandName) {
+        plainText = trimCommand(commandName, plainText);
+        customHtml = trimCommand(commandName, customHtml);
+      }
+      if (commandName === Command.Me) {
+        msgType = MsgType.Emote;
+      } else if (commandName === Command.Notice) {
+        msgType = MsgType.Notice;
+      } else if (commandName === Command.Shrug) {
+        plainText = `${SHRUG} ${plainText}`;
+        customHtml = `${SHRUG} ${customHtml}`;
+      } else if (commandName) {
+        const commandContent = commands[commandName as Command];
+        if (commandContent) {
+          commandContent.exe(plainText);
+        }
+        resetEditor(editor);
+        resetEditorHistory(editor);
+        sendTypingStatus(false);
+        return;
+      }
+
+      if (plainText === '') return;
+
+      let body = plainText;
+      let formattedBody = customHtml;
+      if (replyDraft) {
+        body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
+        formattedBody =
+          parseReplyFormattedBody(
+            roomId,
+            replyDraft.userId,
+            replyDraft.eventId,
+            replyDraft.formattedBody
+              ? trimReplyFromFormattedBody(replyDraft.formattedBody)
+              : sanitizeText(replyDraft.body)
+          ) + formattedBody;
+      }
+
+      const content: IContent = {
+        msgtype: msgType,
+        body,
+      };
+      if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
+        content.format = 'org.matrix.custom.html';
+        content.formatted_body = formattedBody;
+      }
+      if (replyDraft) {
+        content['m.relates_to'] = {
+          'm.in_reply_to': {
+            event_id: replyDraft.eventId,
+          },
+        };
+      }
+      mx.sendMessage(roomId, content);
+      resetEditor(editor);
+      resetEditorHistory(editor);
+      setReplyDraft(undefined);
+      sendTypingStatus(false);
+    }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
+
+    const handleKeyDown: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
+          evt.preventDefault();
+          submit();
+        }
+        if (isKeyHotkey('escape', evt)) {
+          evt.preventDefault();
+          setReplyDraft(undefined);
+        }
+      },
+      [submit, setReplyDraft, enterForNewline]
+    );
+
+    const handleKeyUp: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isKeyHotkey('escape', evt)) {
+          evt.preventDefault();
+          return;
+        }
+
+        sendTypingStatus(!isEmptyEditor(editor));
+
+        const prevWordRange = getPrevWorldRange(editor);
+        const query = prevWordRange
+          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+          : undefined;
+        setAutocompleteQuery(query);
+      },
+      [editor, sendTypingStatus]
+    );
+
+    const handleCloseAutocomplete = useCallback(() => {
+      setAutocompleteQuery(undefined);
+      ReactEditor.focus(editor);
+    }, [editor]);
+
+    const handleEmoticonSelect = (key: string, shortcode: string) => {
+      editor.insertNode(createEmoticonElement(key, shortcode));
+      moveCursor(editor);
+    };
+
+    const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
+      const stickerUrl = mx.mxcUrlToHttp(mxc);
+      if (!stickerUrl) return;
+
+      const info = await getImageInfo(
+        await loadImageElement(stickerUrl),
+        await getImageUrlBlob(stickerUrl)
+      );
+
+      mx.sendEvent(roomId, EventType.Sticker, {
+        body: label,
+        url: mxc,
+        info,
+      });
+    };
+
+    return (
+      <div ref={ref}>
+        {selectedFiles.length > 0 && (
+          <UploadBoard
+            header={
+              <UploadBoardHeader
+                open={uploadBoard}
+                onToggle={() => setUploadBoard(!uploadBoard)}
+                uploadFamilyObserverAtom={uploadFamilyObserverAtom}
+                onSend={handleSendUpload}
+                imperativeHandlerRef={uploadBoardHandlers}
+                onCancel={handleCancelUpload}
+              />
+            }
+          >
+            {uploadBoard && (
+              <Scroll size="300" hideTrack visibility="Hover">
+                <UploadBoardContent>
+                  {Array.from(selectedFiles)
+                    .reverse()
+                    .map((fileItem, index) => (
+                      <UploadCardRenderer
+                        // eslint-disable-next-line react/no-array-index-key
+                        key={index}
+                        file={fileItem.file}
+                        isEncrypted={!!fileItem.encInfo}
+                        uploadAtom={roomUploadAtomFamily(fileItem.file)}
+                        onRemove={handleRemoveUpload}
+                      />
+                    ))}
+                </UploadBoardContent>
+              </Scroll>
+            )}
+          </UploadBoard>
+        )}
+        <Overlay
+          open={dropZoneVisible}
+          backdrop={<OverlayBackdrop />}
+          style={{ pointerEvents: 'none' }}
+        >
+          <OverlayCenter>
+            <Dialog variant="Primary">
+              <Box
+                direction="Column"
+                justifyContent="Center"
+                alignItems="Center"
+                gap="500"
+                style={{ padding: toRem(60) }}
+              >
+                <Icon size="600" src={Icons.File} />
+                <Text size="H4" align="Center">
+                  {`Drop Files in "${room?.name || 'Room'}"`}
+                </Text>
+                <Text align="Center">Drag and drop files here or click for selection dialog</Text>
+              </Box>
+            </Dialog>
+          </OverlayCenter>
+        </Overlay>
+        {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+          <RoomMentionAutocomplete
+            roomId={roomId}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+          <UserMentionAutocomplete
+            room={room}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+          <EmoticonAutocomplete
+            imagePackRooms={imagePackRooms}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
+          <CommandAutocomplete
+            room={room}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        <CustomEditor
+          editableName="RoomInput"
+          editor={editor}
+          placeholder="Send a message..."
+          onKeyDown={handleKeyDown}
+          onKeyUp={handleKeyUp}
+          onPaste={handlePaste}
+          top={
+            replyDraft && (
+              <div>
+                <Box
+                  alignItems="Center"
+                  gap="300"
+                  style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
+                >
+                  <IconButton
+                    onClick={() => setReplyDraft(undefined)}
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                  >
+                    <Icon src={Icons.Cross} size="50" />
+                  </IconButton>
+                  <ReplyLayout
+                    userColor={colorMXID(replyDraft.userId)}
+                    username={
+                      <Text size="T300" truncate>
+                        <b>
+                          {getMemberDisplayName(room, replyDraft.userId) ??
+                            getMxIdLocalPart(replyDraft.userId) ??
+                            replyDraft.userId}
+                        </b>
+                      </Text>
+                    }
+                  >
+                    <Text size="T300" truncate>
+                      {trimReplyFromBody(replyDraft.body)}
+                    </Text>
+                  </ReplyLayout>
+                </Box>
+              </div>
+            )
+          }
+          before={
+            <IconButton
+              onClick={() => pickFile('*')}
+              variant="SurfaceVariant"
+              size="300"
+              radii="300"
+            >
+              <Icon src={Icons.PlusCircle} />
+            </IconButton>
+          }
+          after={
+            <>
+              <IconButton
+                variant="SurfaceVariant"
+                size="300"
+                radii="300"
+                onClick={() => setToolbar(!toolbar)}
+              >
+                <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+              </IconButton>
+              <UseStateProvider initial={undefined}>
+                {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
+                  <PopOut
+                    offset={16}
+                    alignOffset={-44}
+                    position="Top"
+                    align="End"
+                    anchor={
+                      emojiBoardTab === undefined
+                        ? undefined
+                        : emojiBtnRef.current?.getBoundingClientRect() ?? undefined
+                    }
+                    content={
+                      <EmojiBoard
+                        tab={emojiBoardTab}
+                        onTabChange={setEmojiBoardTab}
+                        imagePackRooms={imagePackRooms}
+                        returnFocusOnDeactivate={false}
+                        onEmojiSelect={handleEmoticonSelect}
+                        onCustomEmojiSelect={handleEmoticonSelect}
+                        onStickerSelect={handleStickerSelect}
+                        requestClose={() => {
+                          setEmojiBoardTab(undefined);
+                          if (!mobileOrTablet()) ReactEditor.focus(editor);
+                        }}
+                      />
+                    }
+                  >
+                    {!hideStickerBtn && (
+                      <IconButton
+                        aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
+                        onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
+                        variant="SurfaceVariant"
+                        size="300"
+                        radii="300"
+                      >
+                        <Icon
+                          src={Icons.Sticker}
+                          filled={emojiBoardTab === EmojiBoardTab.Sticker}
+                        />
+                      </IconButton>
+                    )}
+                    <IconButton
+                      ref={emojiBtnRef}
+                      aria-pressed={
+                        hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+                      }
+                      onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
+                      variant="SurfaceVariant"
+                      size="300"
+                      radii="300"
+                    >
+                      <Icon
+                        src={Icons.Smile}
+                        filled={
+                          hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
+                        }
+                      />
+                    </IconButton>
+                  </PopOut>
+                )}
+              </UseStateProvider>
+              <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
+                <Icon src={Icons.Send} />
+              </IconButton>
+            </>
+          }
+          bottom={
+            toolbar && (
+              <div>
+                <Line variant="SurfaceVariant" size="300" />
+                <Toolbar />
+              </div>
+            )
+          }
+        />
+      </div>
+    );
+  }
+);
diff --git a/src/app/features/room/RoomInputPlaceholder.css.ts b/src/app/features/room/RoomInputPlaceholder.css.ts
new file mode 100644 (file)
index 0000000..d0873da
--- /dev/null
@@ -0,0 +1,10 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const RoomInputPlaceholder = style({
+  minHeight: toRem(48),
+  backgroundColor: color.SurfaceVariant.Container,
+  color: color.SurfaceVariant.OnContainer,
+  boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
+  borderRadius: config.radii.R400,
+});
diff --git a/src/app/features/room/RoomInputPlaceholder.tsx b/src/app/features/room/RoomInputPlaceholder.tsx
new file mode 100644 (file)
index 0000000..77c7ccf
--- /dev/null
@@ -0,0 +1,11 @@
+import React, { ComponentProps } from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+
+import * as css from './RoomInputPlaceholder.css';
+
+export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
+  ({ className, ...props }, ref) => (
+    <Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
+  )
+);
diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts
new file mode 100644 (file)
index 0000000..9cd428e
--- /dev/null
@@ -0,0 +1,30 @@
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, config } from 'folds';
+
+export const TimelineFloat = recipe({
+  base: [
+    DefaultReset,
+    {
+      position: 'absolute',
+      left: '50%',
+      transform: 'translateX(-50%)',
+      zIndex: 1,
+      minWidth: 'max-content',
+    },
+  ],
+  variants: {
+    position: {
+      Top: {
+        top: config.space.S400,
+      },
+      Bottom: {
+        bottom: config.space.S400,
+      },
+    },
+  },
+  defaultVariants: {
+    position: 'Top',
+  },
+});
+
+export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
new file mode 100644 (file)
index 0000000..29b874f
--- /dev/null
@@ -0,0 +1,1614 @@
+/* eslint-disable react/destructuring-assignment */
+import React, {
+  Dispatch,
+  MouseEventHandler,
+  RefObject,
+  SetStateAction,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Direction,
+  EventTimeline,
+  EventTimelineSet,
+  EventTimelineSetHandlerMap,
+  IEncryptedFile,
+  MatrixClient,
+  MatrixEvent,
+  Room,
+  RoomEvent,
+  RoomEventHandlerMap,
+} from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import classNames from 'classnames';
+import { ReactEditor } from 'slate-react';
+import { Editor } from 'slate';
+import to from 'await-to-js';
+import { useSetAtom } from 'jotai';
+import {
+  Badge,
+  Box,
+  Chip,
+  ContainerColor,
+  Icon,
+  Icons,
+  Line,
+  Scroll,
+  Text,
+  as,
+  color,
+  config,
+  toRem,
+} from 'folds';
+import { isKeyHotkey } from 'is-hotkey';
+import {
+  decryptFile,
+  eventWithShortcode,
+  factoryEventSentBy,
+  getMxIdLocalPart,
+  isRoomId,
+  isUserId,
+} from '../../utils/matrix';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
+import { useAlive } from '../../hooks/useAlive';
+import { editableActiveElement, scrollToBottom } from '../../utils/dom';
+import {
+  DefaultPlaceholder,
+  CompactPlaceholder,
+  Reply,
+  MessageBase,
+  MessageUnsupportedContent,
+  Time,
+  MessageNotDecryptedContent,
+  RedactedContent,
+  MSticker,
+  ImageContent,
+  EventContent,
+} from '../../components/message';
+import { getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
+import {
+  canEditEvent,
+  decryptAllTimelineEvent,
+  getEditedEvent,
+  getEventReactions,
+  getLatestEditableEvt,
+  getMemberDisplayName,
+  getReactionContent,
+  isMembershipChanged,
+  reactionOrEditEvent,
+} from '../../utils/room';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
+import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
+import { Reactions, Message, Event, EncryptedContent } from './message';
+import { useMemberEventParser } from '../../hooks/useMemberEventParser';
+import * as customHtmlCss from '../../styles/CustomHtml.css';
+import { RoomIntro } from '../../components/room-intro';
+import {
+  getIntersectionObserverEntry,
+  useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import { markAsRead } from '../../../client/action/notifications';
+import { useDebounce } from '../../hooks/useDebounce';
+import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
+import * as css from './RoomTimeline.css';
+import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
+import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
+import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
+import initMatrix from '../../../client/initMatrix';
+import { useKeyDown } from '../../hooks/useKeyDown';
+import cons from '../../../client/state/cons';
+import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
+import { RenderMessageContent } from '../../components/RenderMessageContent';
+import { Image } from '../../components/media';
+import { ImageViewer } from '../../components/image-viewer';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+
+const TimelineFloat = as<'div', css.TimelineFloatVariants>(
+  ({ position, className, ...props }, ref) => (
+    <Box
+      className={classNames(css.TimelineFloat({ position }), className)}
+      justifyContent="Center"
+      alignItems="Center"
+      gap="200"
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
+  ({ variant, children, ...props }, ref) => (
+    <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
+      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+      {children}
+      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+    </Box>
+  )
+);
+
+export const getLiveTimeline = (room: Room): EventTimeline =>
+  room.getUnfilteredTimelineSet().getLiveTimeline();
+
+export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
+  const timelineSet = room.getUnfilteredTimelineSet();
+  return timelineSet.getTimelineForEvent(eventId) ?? undefined;
+};
+
+export const getFirstLinkedTimeline = (
+  timeline: EventTimeline,
+  direction: Direction
+): EventTimeline => {
+  const linkedTm = timeline.getNeighbouringTimeline(direction);
+  if (!linkedTm) return timeline;
+  return getFirstLinkedTimeline(linkedTm, direction);
+};
+
+export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
+  const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
+  const timelines: EventTimeline[] = [];
+
+  for (
+    let nextTimeline: EventTimeline | null = firstTimeline;
+    nextTimeline;
+    nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
+  ) {
+    timelines.push(nextTimeline);
+  }
+  return timelines;
+};
+
+export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
+export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
+  const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
+    count + timelineToEventsCount(tm);
+  return timelines.reduce(timelineEventCountReducer, 0);
+};
+
+export const getTimelineAndBaseIndex = (
+  timelines: EventTimeline[],
+  index: number
+): [EventTimeline | undefined, number] => {
+  let uptoTimelineLen = 0;
+  const timeline = timelines.find((t) => {
+    uptoTimelineLen += t.getEvents().length;
+    if (index < uptoTimelineLen) return true;
+    return false;
+  });
+  if (!timeline) return [undefined, 0];
+  return [timeline, uptoTimelineLen - timeline.getEvents().length];
+};
+
+export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
+  absoluteIndex - timelineBaseIndex;
+
+export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
+  timeline.getEvents()[index];
+
+export const getEventIdAbsoluteIndex = (
+  timelines: EventTimeline[],
+  eventTimeline: EventTimeline,
+  eventId: string
+): number | undefined => {
+  const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
+  if (timelineIndex === -1) return undefined;
+  const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
+  if (eventIndex === -1) return undefined;
+  const baseIndex = timelines
+    .slice(0, timelineIndex)
+    .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
+  return baseIndex + eventIndex;
+};
+
+export const factoryGetFileSrcUrl =
+  (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
+    if (encFile) {
+      if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+      const encRes = await fetch(httpUrl, { method: 'GET' });
+      const encData = await encRes.arrayBuffer();
+      const decryptedBlob = await decryptFile(encData, mimeType, encFile);
+      return URL.createObjectURL(decryptedBlob);
+    }
+    return httpUrl;
+  };
+
+type RoomTimelineProps = {
+  room: Room;
+  eventId?: string;
+  roomInputRef: RefObject<HTMLElement>;
+  editor: Editor;
+};
+
+const PAGINATION_LIMIT = 80;
+
+type Timeline = {
+  linkedTimelines: EventTimeline[];
+  range: ItemRange;
+};
+
+const useEventTimelineLoader = (
+  mx: MatrixClient,
+  room: Room,
+  onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
+  onError: (err: Error | null) => void
+) => {
+  const loadEventTimeline = useCallback(
+    async (eventId: string) => {
+      const [err, replyEvtTimeline] = await to(
+        mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
+      );
+      if (!replyEvtTimeline) {
+        onError(err ?? null);
+        return;
+      }
+      const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
+      const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
+
+      if (absIndex === undefined) {
+        onError(err ?? null);
+        return;
+      }
+
+      onLoad(eventId, linkedTimelines, absIndex);
+    },
+    [mx, room, onLoad, onError]
+  );
+
+  return loadEventTimeline;
+};
+
+const useTimelinePagination = (
+  mx: MatrixClient,
+  timeline: Timeline,
+  setTimeline: Dispatch<SetStateAction<Timeline>>,
+  limit: number
+) => {
+  const timelineRef = useRef(timeline);
+  timelineRef.current = timeline;
+  const alive = useAlive();
+
+  const handleTimelinePagination = useMemo(() => {
+    let fetching = false;
+
+    const recalibratePagination = (
+      linkedTimelines: EventTimeline[],
+      timelinesEventsCount: number[],
+      backwards: boolean
+    ) => {
+      const topTimeline = linkedTimelines[0];
+      const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
+
+      const newLTimelines = getLinkedTimelines(topTimeline);
+      const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
+      const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
+
+      const topTmAddedEvt =
+        timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
+      const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
+
+      setTimeline((currentTimeline) => ({
+        linkedTimelines: newLTimelines,
+        range:
+          offsetRange > 0
+            ? {
+                start: currentTimeline.range.start + offsetRange,
+                end: currentTimeline.range.end + offsetRange,
+              }
+            : { ...currentTimeline.range },
+      }));
+    };
+
+    return async (backwards: boolean) => {
+      if (fetching) return;
+      const { linkedTimelines: lTimelines } = timelineRef.current;
+      const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
+
+      const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
+      if (!timelineToPaginate) return;
+
+      const paginationToken = timelineToPaginate.getPaginationToken(
+        backwards ? Direction.Backward : Direction.Forward
+      );
+      if (
+        !paginationToken &&
+        getTimelinesEventsCount(lTimelines) !==
+          getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
+      ) {
+        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+        return;
+      }
+
+      fetching = true;
+      const [err] = await to(
+        mx.paginateEventTimeline(timelineToPaginate, {
+          backwards,
+          limit,
+        })
+      );
+      if (err) {
+        // TODO: handle pagination error.
+        return;
+      }
+      const fetchedTimeline =
+        timelineToPaginate.getNeighbouringTimeline(
+          backwards ? Direction.Backward : Direction.Forward
+        ) ?? timelineToPaginate;
+      // Decrypt all event ahead of render cycle
+      if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
+        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
+      }
+
+      fetching = false;
+      if (alive()) {
+        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+      }
+    };
+  }, [mx, alive, setTimeline, limit]);
+  return handleTimelinePagination;
+};
+
+const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
+  useEffect(() => {
+    const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
+      mEvent,
+      eventRoom,
+      toStartOfTimeline,
+      removed,
+      data
+    ) => {
+      if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
+      onArrive(mEvent);
+    };
+    const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
+      if (eventRoom?.roomId !== room.roomId) return;
+      onArrive(mEvent);
+    };
+
+    room.on(RoomEvent.Timeline, handleTimelineEvent);
+    room.on(RoomEvent.Redaction, handleRedaction);
+    return () => {
+      room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+      room.removeListener(RoomEvent.Redaction, handleRedaction);
+    };
+  }, [room, onArrive]);
+};
+
+const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
+  useEffect(() => {
+    const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
+      if (r.roomId !== room.roomId) return;
+      onRefresh();
+    };
+
+    room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+    return () => {
+      room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+    };
+  }, [room, onRefresh]);
+};
+
+const getInitialTimeline = (room: Room) => {
+  const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+  const evLength = getTimelinesEventsCount(linkedTimelines);
+  return {
+    linkedTimelines,
+    range: {
+      start: Math.max(evLength - PAGINATION_LIMIT, 0),
+      end: evLength,
+    },
+  };
+};
+
+const getEmptyTimeline = () => ({
+  range: { start: 0, end: 0 },
+  linkedTimelines: [],
+});
+
+const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
+  const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
+  if (!readUptoEventId) return undefined;
+  const evtTimeline = getEventTimeline(room, readUptoEventId);
+  const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+  return {
+    readUptoEventId,
+    inLiveTimeline: latestTimeline === room.getLiveTimeline(),
+    scrollTo,
+  };
+};
+
+export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
+  const mx = useMatrixClient();
+  const encryptedRoom = mx.isRoomEncrypted(room.roomId);
+  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
+  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
+  const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
+  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
+  const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
+  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
+  const powerLevels = usePowerLevelsContext();
+  const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
+  const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
+  const canRedact = canDoAction('redact', myPowerLevel);
+  const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
+  const [editId, setEditId] = useState<string>();
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+  const imagePackRooms: Room[] = useMemo(() => {
+    const allParentSpaces = [
+      room.roomId,
+      ...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
+    ];
+    return allParentSpaces.reduce<Room[]>((list, rId) => {
+      const r = mx.getRoom(rId);
+      if (r) list.push(r);
+      return list;
+    }, []);
+  }, [mx, room]);
+
+  const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
+  const readUptoEventIdRef = useRef<string>();
+  if (unreadInfo) {
+    readUptoEventIdRef.current = unreadInfo.readUptoEventId;
+  }
+
+  const atBottomAnchorRef = useRef<HTMLElement>(null);
+  const [atBottom, setAtBottom] = useState<boolean>(true);
+  const atBottomRef = useRef(atBottom);
+  atBottomRef.current = atBottom;
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const scrollToBottomRef = useRef({
+    count: 0,
+    smooth: true,
+  });
+
+  const [focusItem, setFocusItem] = useState<
+    | {
+        index: number;
+        scrollTo: boolean;
+        highlight: boolean;
+      }
+    | undefined
+  >();
+  const alive = useAlive();
+
+  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+    () =>
+      getReactCustomHtmlParser(mx, room, {
+        handleSpoilerClick: (evt) => {
+          const target = evt.currentTarget;
+          if (target.getAttribute('aria-pressed') === 'true') {
+            evt.stopPropagation();
+            target.setAttribute('aria-pressed', 'false');
+            target.style.cursor = 'initial';
+          }
+        },
+        handleMentionClick: (evt) => {
+          const target = evt.currentTarget;
+          const mentionId = target.getAttribute('data-mention-id');
+          if (typeof mentionId !== 'string') return;
+          if (isUserId(mentionId)) {
+            openProfileViewer(mentionId, room.roomId);
+            return;
+          }
+          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+            if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
+            else navigateRoom(mentionId);
+            return;
+          }
+          openJoinAlias(mentionId);
+        },
+      }),
+    [mx, room, navigateRoom, navigateSpace]
+  );
+  const parseMemberEvent = useMemberEventParser();
+
+  const [timeline, setTimeline] = useState<Timeline>(() =>
+    eventId ? getEmptyTimeline() : getInitialTimeline(room)
+  );
+  const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
+  const liveTimelineLinked =
+    timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
+  const canPaginateBack =
+    typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
+  const rangeAtStart = timeline.range.start === 0;
+  const rangeAtEnd = timeline.range.end === eventsLength;
+  const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
+  atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
+
+  const handleTimelinePagination = useTimelinePagination(
+    mx,
+    timeline,
+    setTimeline,
+    PAGINATION_LIMIT
+  );
+
+  const getScrollElement = useCallback(() => scrollRef.current, []);
+
+  const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
+    useVirtualPaginator({
+      count: eventsLength,
+      limit: PAGINATION_LIMIT,
+      range: timeline.range,
+      onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+      getScrollElement,
+      getItemElement: useCallback(
+        (index: number) =>
+          (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+          undefined,
+        []
+      ),
+      onEnd: handleTimelinePagination,
+    });
+
+  const loadEventTimeline = useEventTimelineLoader(
+    mx,
+    room,
+    useCallback(
+      (evtId, lTimelines, evtAbsIndex) => {
+        if (!alive()) return;
+        const evLength = getTimelinesEventsCount(lTimelines);
+
+        setFocusItem({
+          index: evtAbsIndex,
+          scrollTo: true,
+          highlight: evtId !== readUptoEventIdRef.current,
+        });
+        setTimeline({
+          linkedTimelines: lTimelines,
+          range: {
+            start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
+            end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
+          },
+        });
+      },
+      [alive]
+    ),
+    useCallback(() => {
+      if (!alive()) return;
+      setTimeline(getInitialTimeline(room));
+      scrollToBottomRef.current.count += 1;
+      scrollToBottomRef.current.smooth = false;
+    }, [alive, room])
+  );
+
+  useLiveEventArrive(
+    room,
+    useCallback(
+      (mEvt: MatrixEvent) => {
+        // if user is at bottom of timeline
+        // keep paginating timeline and conditionally mark as read
+        // otherwise we update timeline without paginating
+        // so timeline can be updated with evt like: edits, reactions etc
+        if (atBottomRef.current) {
+          if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
+            requestAnimationFrame(() => markAsRead(mEvt.getRoomId()));
+          }
+
+          if (document.hasFocus()) {
+            scrollToBottomRef.current.count += 1;
+            scrollToBottomRef.current.smooth = true;
+          } else if (!unreadInfo) {
+            setUnreadInfo(getRoomUnreadInfo(room));
+          }
+          setTimeline((ct) => ({
+            ...ct,
+            range: {
+              start: ct.range.start + 1,
+              end: ct.range.end + 1,
+            },
+          }));
+          return;
+        }
+        setTimeline((ct) => ({ ...ct }));
+        if (!unreadInfo) {
+          setUnreadInfo(getRoomUnreadInfo(room));
+        }
+      },
+      [mx, room, unreadInfo]
+    )
+  );
+
+  useLiveTimelineRefresh(
+    room,
+    useCallback(() => {
+      if (liveTimelineLinked) {
+        setTimeline(getInitialTimeline(room));
+      }
+    }, [room, liveTimelineLinked])
+  );
+
+  // Stay at bottom when room editor resize
+  useResizeObserver(
+    useMemo(() => {
+      let mounted = false;
+      return (entries) => {
+        if (!mounted) {
+          // skip initial mounting call
+          mounted = true;
+          return;
+        }
+        if (!roomInputRef.current) return;
+        const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
+        const scrollElement = getScrollElement();
+        if (!editorBaseEntry || !scrollElement) return;
+
+        if (atBottomRef.current) {
+          scrollToBottom(scrollElement);
+        }
+      };
+    }, [getScrollElement, roomInputRef]),
+    useCallback(() => roomInputRef.current, [roomInputRef])
+  );
+
+  const tryAutoMarkAsRead = useCallback(() => {
+    if (!unreadInfo) {
+      requestAnimationFrame(() => markAsRead(room.roomId));
+      return;
+    }
+    const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
+    const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+    if (latestTimeline === room.getLiveTimeline()) {
+      requestAnimationFrame(() => markAsRead(room.roomId));
+    }
+  }, [room, unreadInfo]);
+
+  const debounceSetAtBottom = useDebounce(
+    useCallback((entry: IntersectionObserverEntry) => {
+      if (!entry.isIntersecting) setAtBottom(false);
+    }, []),
+    { wait: 1000 }
+  );
+  useIntersectionObserver(
+    useCallback(
+      (entries) => {
+        const target = atBottomAnchorRef.current;
+        if (!target) return;
+        const targetEntry = getIntersectionObserverEntry(target, entries);
+        if (targetEntry) debounceSetAtBottom(targetEntry);
+        if (targetEntry?.isIntersecting && atLiveEndRef.current) {
+          setAtBottom(true);
+          tryAutoMarkAsRead();
+        }
+      },
+      [debounceSetAtBottom, tryAutoMarkAsRead]
+    ),
+    useCallback(
+      () => ({
+        root: getScrollElement(),
+        rootMargin: '100px',
+      }),
+      [getScrollElement]
+    ),
+    useCallback(() => atBottomAnchorRef.current, [])
+  );
+
+  useDocumentFocusChange(
+    useCallback(
+      (inFocus) => {
+        if (inFocus && atBottomRef.current) {
+          tryAutoMarkAsRead();
+        }
+      },
+      [tryAutoMarkAsRead]
+    )
+  );
+
+  // Handle up arrow edit
+  useKeyDown(
+    window,
+    useCallback(
+      (evt) => {
+        if (
+          isKeyHotkey('arrowup', evt) &&
+          editableActiveElement() &&
+          document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
+          isEmptyEditor(editor)
+        ) {
+          const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
+            canEditEvent(mx, mEvt)
+          );
+          const editableEvtId = editableEvt?.getId();
+          if (!editableEvtId) return;
+          setEditId(editableEvtId);
+        }
+      },
+      [mx, room, editor]
+    )
+  );
+
+  useEffect(() => {
+    if (eventId) {
+      setTimeline(getEmptyTimeline());
+      loadEventTimeline(eventId);
+    }
+  }, [eventId, loadEventTimeline]);
+
+  // Scroll to bottom on initial timeline load
+  useLayoutEffect(() => {
+    const scrollEl = scrollRef.current;
+    if (scrollEl) {
+      scrollToBottom(scrollEl);
+    }
+  }, []);
+
+  // if live timeline is linked and unreadInfo change
+  // Scroll to last read message
+  useLayoutEffect(() => {
+    const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
+    if (readUptoEventId && inLiveTimeline && scrollTo) {
+      const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+      const evtTimeline = getEventTimeline(room, readUptoEventId);
+      const absoluteIndex =
+        evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
+      if (absoluteIndex) {
+        scrollToItem(absoluteIndex, {
+          behavior: 'instant',
+          align: 'start',
+          stopInView: true,
+        });
+      }
+    }
+  }, [room, unreadInfo, scrollToItem]);
+
+  // scroll to focused message
+  useLayoutEffect(() => {
+    if (focusItem && focusItem.scrollTo) {
+      scrollToItem(focusItem.index, {
+        behavior: 'instant',
+        align: 'center',
+        stopInView: true,
+      });
+    }
+
+    setTimeout(() => {
+      if (!alive()) return;
+      setFocusItem((currentItem) => {
+        if (currentItem === focusItem) return undefined;
+        return currentItem;
+      });
+    }, 2000);
+  }, [alive, focusItem, scrollToItem]);
+
+  // scroll to bottom of timeline
+  const scrollToBottomCount = scrollToBottomRef.current.count;
+  useLayoutEffect(() => {
+    if (scrollToBottomCount > 0) {
+      const scrollEl = scrollRef.current;
+      if (scrollEl)
+        scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
+    }
+  }, [scrollToBottomCount]);
+
+  // Remove unreadInfo on mark as read
+  useEffect(() => {
+    const handleFullRead = (rId: string) => {
+      if (rId !== room.roomId) return;
+      setUnreadInfo(undefined);
+    };
+    initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
+    return () => {
+      initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
+    };
+  }, [room]);
+
+  // scroll out of view msg editor in view.
+  useEffect(() => {
+    if (editId) {
+      const editMsgElement =
+        (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
+        undefined;
+      if (editMsgElement) {
+        scrollToElement(editMsgElement, {
+          align: 'center',
+          behavior: 'smooth',
+          stopInView: true,
+        });
+      }
+    }
+  }, [scrollToElement, editId]);
+
+  const handleJumpToLatest = () => {
+    setTimeline(getInitialTimeline(room));
+    scrollToBottomRef.current.count += 1;
+    scrollToBottomRef.current.smooth = false;
+  };
+
+  const handleJumpToUnread = () => {
+    if (unreadInfo?.readUptoEventId) {
+      setTimeline(getEmptyTimeline());
+      loadEventTimeline(unreadInfo.readUptoEventId);
+    }
+  };
+
+  const handleMarkAsRead = () => {
+    markAsRead(room.roomId);
+  };
+
+  const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
+    async (evt) => {
+      const replyId = evt.currentTarget.getAttribute('data-reply-id');
+      if (typeof replyId !== 'string') return;
+      const replyTimeline = getEventTimeline(room, replyId);
+      const absoluteIndex =
+        replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
+
+      if (typeof absoluteIndex === 'number') {
+        scrollToItem(absoluteIndex, {
+          behavior: 'smooth',
+          align: 'center',
+          stopInView: true,
+        });
+        setFocusItem({
+          index: absoluteIndex,
+          scrollTo: false,
+          highlight: true,
+        });
+      } else {
+        setTimeline(getEmptyTimeline());
+        loadEventTimeline(replyId);
+      }
+    },
+    [room, timeline, scrollToItem, loadEventTimeline]
+  );
+
+  const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+    (evt) => {
+      evt.preventDefault();
+      evt.stopPropagation();
+      const userId = evt.currentTarget.getAttribute('data-user-id');
+      if (!userId) {
+        console.warn('Button should have "data-user-id" attribute!');
+        return;
+      }
+      openProfileViewer(userId, room.roomId);
+    },
+    [room]
+  );
+  const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+    (evt) => {
+      evt.preventDefault();
+      const userId = evt.currentTarget.getAttribute('data-user-id');
+      if (!userId) {
+        console.warn('Button should have "data-user-id" attribute!');
+        return;
+      }
+      const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
+      editor.insertNode(
+        createMentionElement(
+          userId,
+          name.startsWith('@') ? name : `@${name}`,
+          userId === mx.getUserId()
+        )
+      );
+      ReactEditor.focus(editor);
+      moveCursor(editor);
+    },
+    [mx, room, editor]
+  );
+
+  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+    (evt) => {
+      const replyId = evt.currentTarget.getAttribute('data-event-id');
+      if (!replyId) {
+        console.warn('Button should have "data-event-id" attribute!');
+        return;
+      }
+      const replyEvt = room.findEventById(replyId);
+      if (!replyEvt) return;
+      const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
+      const { body, formatted_body: formattedBody }: Record<string, string> =
+        editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
+      const senderId = replyEvt.getSender();
+      if (senderId && typeof body === 'string') {
+        setReplyDraft({
+          userId: senderId,
+          eventId: replyId,
+          body,
+          formattedBody,
+        });
+        setTimeout(() => ReactEditor.focus(editor), 100);
+      }
+    },
+    [room, setReplyDraft, editor]
+  );
+
+  const handleReactionToggle = useCallback(
+    (targetEventId: string, key: string, shortcode?: string) => {
+      const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
+      const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
+      const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
+      const reactions = reactionsSet ? Array.from(reactionsSet) : [];
+      const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
+
+      if (myReaction && !!myReaction?.isRelation()) {
+        mx.redactEvent(room.roomId, myReaction.getId()!);
+        return;
+      }
+      const rShortcode =
+        shortcode ||
+        (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
+      mx.sendEvent(
+        room.roomId,
+        MessageEvent.Reaction,
+        getReactionContent(targetEventId, key, rShortcode)
+      );
+    },
+    [mx, room]
+  );
+  const handleEdit = useCallback(
+    (editEvtId?: string) => {
+      if (editEvtId) {
+        setEditId(editEvtId);
+        return;
+      }
+      setEditId(undefined);
+      ReactEditor.focus(editor);
+    },
+    [editor]
+  );
+
+  const renderMatrixEvent = useMatrixEventRenderer<
+    [string, MatrixEvent, number, EventTimelineSet, boolean]
+  >(
+    {
+      [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
+        const reactionRelations = getEventReactions(timelineSet, mEventId);
+        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+        const hasReactions = reactions && reactions.length > 0;
+        const { replyEventId } = mEvent;
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+
+        const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+        const getContent = (() =>
+          editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
+
+        const senderId = mEvent.getSender() ?? '';
+        const senderDisplayName =
+          getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+
+        return (
+          <Message
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            messageSpacing={messageSpacing}
+            messageLayout={messageLayout}
+            collapse={collapse}
+            highlight={highlighted}
+            edit={editId === mEventId}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+            canSendReaction={canSendReaction}
+            imagePackRooms={imagePackRooms}
+            relations={hasReactions ? reactionRelations : undefined}
+            onUserClick={handleUserClick}
+            onUsernameClick={handleUsernameClick}
+            onReplyClick={handleReplyClick}
+            onReactionToggle={handleReactionToggle}
+            onEditId={handleEdit}
+            reply={
+              replyEventId && (
+                <Reply
+                  as="button"
+                  mx={mx}
+                  room={room}
+                  timelineSet={timelineSet}
+                  eventId={replyEventId}
+                  data-reply-id={replyEventId}
+                  onClick={handleOpenReply}
+                />
+              )
+            }
+            reactions={
+              reactionRelations && (
+                <Reactions
+                  style={{ marginTop: config.space.S200 }}
+                  room={room}
+                  relations={reactionRelations}
+                  mEventId={mEventId}
+                  canSendReaction={canSendReaction}
+                  onReactionToggle={handleReactionToggle}
+                />
+              )
+            }
+          >
+            {mEvent.isRedacted() ? (
+              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
+            ) : (
+              <RenderMessageContent
+                displayName={senderDisplayName}
+                msgType={mEvent.getContent().msgtype ?? ''}
+                ts={mEvent.getTs()}
+                edited={!!editedEvent}
+                getContent={getContent}
+                mediaAutoLoad={mediaAutoLoad}
+                urlPreview={showUrlPreview}
+                htmlReactParserOptions={htmlReactParserOptions}
+                outlineAttachment={messageLayout === 2}
+              />
+            )}
+          </Message>
+        );
+      },
+      [MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => {
+        const reactionRelations = getEventReactions(timelineSet, mEventId);
+        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+        const hasReactions = reactions && reactions.length > 0;
+        const { replyEventId } = mEvent;
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+
+        return (
+          <Message
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            messageSpacing={messageSpacing}
+            messageLayout={messageLayout}
+            collapse={collapse}
+            highlight={highlighted}
+            edit={editId === mEventId}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+            canSendReaction={canSendReaction}
+            imagePackRooms={imagePackRooms}
+            relations={hasReactions ? reactionRelations : undefined}
+            onUserClick={handleUserClick}
+            onUsernameClick={handleUsernameClick}
+            onReplyClick={handleReplyClick}
+            onReactionToggle={handleReactionToggle}
+            onEditId={handleEdit}
+            reply={
+              replyEventId && (
+                <Reply
+                  as="button"
+                  mx={mx}
+                  room={room}
+                  timelineSet={timelineSet}
+                  eventId={replyEventId}
+                  data-reply-id={replyEventId}
+                  onClick={handleOpenReply}
+                />
+              )
+            }
+            reactions={
+              reactionRelations && (
+                <Reactions
+                  style={{ marginTop: config.space.S200 }}
+                  room={room}
+                  relations={reactionRelations}
+                  mEventId={mEventId}
+                  canSendReaction={canSendReaction}
+                  onReactionToggle={handleReactionToggle}
+                />
+              )
+            }
+          >
+            <EncryptedContent mEvent={mEvent}>
+              {() => {
+                if (mEvent.isRedacted()) return <RedactedContent />;
+                if (mEvent.getType() === MessageEvent.Sticker)
+                  return (
+                    <MSticker
+                      content={mEvent.getContent()}
+                      renderImageContent={(props) => (
+                        <ImageContent
+                          {...props}
+                          autoPlay={mediaAutoLoad}
+                          renderImage={(p) => <Image {...p} loading="lazy" />}
+                          renderViewer={(p) => <ImageViewer {...p} />}
+                        />
+                      )}
+                    />
+                  );
+                if (mEvent.getType() === MessageEvent.RoomMessage) {
+                  const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+                  const getContent = (() =>
+                    editedEvent?.getContent()['m.new_content'] ??
+                    mEvent.getContent()) as GetContentCallback;
+
+                  const senderId = mEvent.getSender() ?? '';
+                  const senderDisplayName =
+                    getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+                  return (
+                    <RenderMessageContent
+                      displayName={senderDisplayName}
+                      msgType={mEvent.getContent().msgtype ?? ''}
+                      ts={mEvent.getTs()}
+                      edited={!!editedEvent}
+                      getContent={getContent}
+                      mediaAutoLoad={mediaAutoLoad}
+                      urlPreview={showUrlPreview}
+                      htmlReactParserOptions={htmlReactParserOptions}
+                      outlineAttachment={messageLayout === 2}
+                    />
+                  );
+                }
+                if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+                  return (
+                    <Text>
+                      <MessageNotDecryptedContent />
+                    </Text>
+                  );
+                return (
+                  <Text>
+                    <MessageUnsupportedContent />
+                  </Text>
+                );
+              }}
+            </EncryptedContent>
+          </Message>
+        );
+      },
+      [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {
+        const reactionRelations = getEventReactions(timelineSet, mEventId);
+        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+        const hasReactions = reactions && reactions.length > 0;
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+
+        return (
+          <Message
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            messageSpacing={messageSpacing}
+            messageLayout={messageLayout}
+            collapse={collapse}
+            highlight={highlighted}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+            canSendReaction={canSendReaction}
+            imagePackRooms={imagePackRooms}
+            relations={hasReactions ? reactionRelations : undefined}
+            onUserClick={handleUserClick}
+            onUsernameClick={handleUsernameClick}
+            onReplyClick={handleReplyClick}
+            onReactionToggle={handleReactionToggle}
+            reactions={
+              reactionRelations && (
+                <Reactions
+                  style={{ marginTop: config.space.S200 }}
+                  room={room}
+                  relations={reactionRelations}
+                  mEventId={mEventId}
+                  canSendReaction={canSendReaction}
+                  onReactionToggle={handleReactionToggle}
+                />
+              )
+            }
+          >
+            {mEvent.isRedacted() ? (
+              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
+            ) : (
+              <MSticker
+                content={mEvent.getContent()}
+                renderImageContent={(props) => (
+                  <ImageContent
+                    {...props}
+                    autoPlay={mediaAutoLoad}
+                    renderImage={(p) => <Image {...p} loading="lazy" />}
+                    renderViewer={(p) => <ImageViewer {...p} />}
+                  />
+                )}
+              />
+            )}
+          </Message>
+        );
+      },
+      [StateEvent.RoomMember]: (mEventId, mEvent, item) => {
+        const membershipChanged = isMembershipChanged(mEvent);
+        if (membershipChanged && hideMembershipEvents) return null;
+        if (!membershipChanged && hideNickAvatarEvents) return null;
+
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+        const parsed = parseMemberEvent(mEvent);
+
+        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+        return (
+          <Event
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            highlight={highlighted}
+            messageSpacing={messageSpacing}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          >
+            <EventContent
+              messageLayout={messageLayout}
+              time={timeJSX}
+              iconSrc={parsed.icon}
+              content={
+                <Box grow="Yes" direction="Column">
+                  <Text size="T300" priority="300">
+                    {parsed.body}
+                  </Text>
+                </Box>
+              }
+            />
+          </Event>
+        );
+      },
+      [StateEvent.RoomName]: (mEventId, mEvent, item) => {
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+        const senderId = mEvent.getSender() ?? '';
+        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+        return (
+          <Event
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            highlight={highlighted}
+            messageSpacing={messageSpacing}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          >
+            <EventContent
+              messageLayout={messageLayout}
+              time={timeJSX}
+              iconSrc={Icons.Hash}
+              content={
+                <Box grow="Yes" direction="Column">
+                  <Text size="T300" priority="300">
+                    <b>{senderName}</b>
+                    {' changed room name'}
+                  </Text>
+                </Box>
+              }
+            />
+          </Event>
+        );
+      },
+      [StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+        const senderId = mEvent.getSender() ?? '';
+        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+        return (
+          <Event
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            highlight={highlighted}
+            messageSpacing={messageSpacing}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          >
+            <EventContent
+              messageLayout={messageLayout}
+              time={timeJSX}
+              iconSrc={Icons.Hash}
+              content={
+                <Box grow="Yes" direction="Column">
+                  <Text size="T300" priority="300">
+                    <b>{senderName}</b>
+                    {' changed room topic'}
+                  </Text>
+                </Box>
+              }
+            />
+          </Event>
+        );
+      },
+      [StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
+        const highlighted = focusItem?.index === item && focusItem.highlight;
+        const senderId = mEvent.getSender() ?? '';
+        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+        return (
+          <Event
+            key={mEvent.getId()}
+            data-message-item={item}
+            data-message-id={mEventId}
+            room={room}
+            mEvent={mEvent}
+            highlight={highlighted}
+            messageSpacing={messageSpacing}
+            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          >
+            <EventContent
+              messageLayout={messageLayout}
+              time={timeJSX}
+              iconSrc={Icons.Hash}
+              content={
+                <Box grow="Yes" direction="Column">
+                  <Text size="T300" priority="300">
+                    <b>{senderName}</b>
+                    {' changed room avatar'}
+                  </Text>
+                </Box>
+              }
+            />
+          </Event>
+        );
+      },
+    },
+    (mEventId, mEvent, item) => {
+      if (!showHiddenEvents) return null;
+      const highlighted = focusItem?.index === item && focusItem.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          data-message-id={mEventId}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Code}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' sent '}
+                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+                  {' state event'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+    (mEventId, mEvent, item) => {
+      if (!showHiddenEvents) return null;
+      if (Object.keys(mEvent.getContent()).length === 0) return null;
+      if (mEvent.getRelation()) return null;
+      if (mEvent.isRedaction()) return null;
+
+      const highlighted = focusItem?.index === item && focusItem.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          data-message-id={mEventId}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Code}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' sent '}
+                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+                  {' event'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    }
+  );
+
+  let prevEvent: MatrixEvent | undefined;
+  let isPrevRendered = false;
+  let newDivider = false;
+  let dayDivider = false;
+  const eventRenderer = (item: number) => {
+    const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
+    if (!eventTimeline) return null;
+    const timelineSet = eventTimeline?.getTimelineSet();
+    const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
+    const mEventId = mEvent?.getId();
+
+    if (!mEvent || !mEventId) return null;
+
+    if (!newDivider && readUptoEventIdRef.current) {
+      newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
+    }
+    if (!dayDivider) {
+      dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
+    }
+
+    const collapsed =
+      isPrevRendered &&
+      !dayDivider &&
+      (!newDivider || mEvent.getSender() === mx.getUserId()) &&
+      prevEvent !== undefined &&
+      prevEvent.getSender() === mEvent.getSender() &&
+      prevEvent.getType() === mEvent.getType() &&
+      minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
+
+    const eventJSX = reactionOrEditEvent(mEvent)
+      ? null
+      : renderMatrixEvent(
+          mEvent.getType(),
+          typeof mEvent.getStateKey() === 'string',
+          mEventId,
+          mEvent,
+          item,
+          timelineSet,
+          collapsed
+        );
+    prevEvent = mEvent;
+    isPrevRendered = !!eventJSX;
+
+    const newDividerJSX =
+      newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
+        <MessageBase space={messageSpacing}>
+          <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
+            <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
+              <Text size="L400">New Messages</Text>
+            </Badge>
+          </TimelineDivider>
+        </MessageBase>
+      ) : null;
+
+    const dayDividerJSX =
+      dayDivider && eventJSX ? (
+        <MessageBase space={messageSpacing}>
+          <TimelineDivider variant="Surface">
+            <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
+              <Text size="L400">
+                {(() => {
+                  if (today(mEvent.getTs())) return 'Today';
+                  if (yesterday(mEvent.getTs())) return 'Yesterday';
+                  return timeDayMonthYear(mEvent.getTs());
+                })()}
+              </Text>
+            </Badge>
+          </TimelineDivider>
+        </MessageBase>
+      ) : null;
+
+    if (eventJSX && (newDividerJSX || dayDividerJSX)) {
+      if (newDividerJSX) newDivider = false;
+      if (dayDividerJSX) dayDivider = false;
+
+      return (
+        <React.Fragment key={mEventId}>
+          {newDividerJSX}
+          {dayDividerJSX}
+          {eventJSX}
+        </React.Fragment>
+      );
+    }
+
+    return eventJSX;
+  };
+
+  return (
+    <Box grow="Yes" style={{ position: 'relative' }}>
+      {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
+        <TimelineFloat position="Top">
+          <Chip
+            variant="Primary"
+            radii="Pill"
+            outlined
+            before={<Icon size="50" src={Icons.MessageUnread} />}
+            onClick={handleJumpToUnread}
+          >
+            <Text size="L400">Jump to Unread</Text>
+          </Chip>
+
+          <Chip
+            variant="SurfaceVariant"
+            radii="Pill"
+            outlined
+            before={<Icon size="50" src={Icons.CheckTwice} />}
+            onClick={handleMarkAsRead}
+          >
+            <Text size="L400">Mark as Read</Text>
+          </Chip>
+        </TimelineFloat>
+      )}
+      <Scroll ref={scrollRef} visibility="Hover">
+        <Box
+          direction="Column"
+          justifyContent="End"
+          style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
+        >
+          {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
+            <div
+              style={{
+                padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
+                  messageLayout === 1 ? config.space.S400 : toRem(64)
+                }`,
+              }}
+            >
+              <RoomIntro room={room} />
+            </div>
+          )}
+          {(canPaginateBack || !rangeAtStart) &&
+            (messageLayout === 1 ? (
+              <>
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder ref={observeBackAnchor} />
+              </>
+            ) : (
+              <>
+                <DefaultPlaceholder />
+                <DefaultPlaceholder />
+                <DefaultPlaceholder ref={observeBackAnchor} />
+              </>
+            ))}
+
+          {getItems().map(eventRenderer)}
+
+          {(!liveTimelineLinked || !rangeAtEnd) &&
+            (messageLayout === 1 ? (
+              <>
+                <CompactPlaceholder ref={observeFrontAnchor} />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+              </>
+            ) : (
+              <>
+                <DefaultPlaceholder ref={observeFrontAnchor} />
+                <DefaultPlaceholder />
+                <DefaultPlaceholder />
+              </>
+            ))}
+          <span ref={atBottomAnchorRef} />
+        </Box>
+      </Scroll>
+      {!atBottom && (
+        <TimelineFloat position="Bottom">
+          <Chip
+            variant="SurfaceVariant"
+            radii="Pill"
+            outlined
+            before={<Icon size="50" src={Icons.ArrowBottom} />}
+            onClick={handleJumpToLatest}
+          >
+            <Text size="L400">Jump to Latest</Text>
+          </Chip>
+        </TimelineFloat>
+      )}
+    </Box>
+  );
+}
diff --git a/src/app/features/room/RoomTombstone.css.ts b/src/app/features/room/RoomTombstone.css.ts
new file mode 100644 (file)
index 0000000..c4c0461
--- /dev/null
@@ -0,0 +1,7 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const RoomTombstone = style({
+  padding: config.space.S200,
+  paddingLeft: config.space.S400,
+});
diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx
new file mode 100644 (file)
index 0000000..bd8afda
--- /dev/null
@@ -0,0 +1,67 @@
+import React, { useCallback } from 'react';
+import { Box, Button, Spinner, Text, color } from 'folds';
+
+import * as css from './RoomTombstone.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { genRoomVia } from '../../../util/matrixUtil';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { Membership } from '../../../types/matrix/room';
+import { RoomInputPlaceholder } from './RoomInputPlaceholder';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+
+type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
+export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
+  const mx = useMatrixClient();
+  const { navigateRoom } = useRoomNavigate();
+
+  const [joinState, handleJoin] = useAsyncCallback(
+    useCallback(() => {
+      const currentRoom = mx.getRoom(roomId);
+      const via = currentRoom ? genRoomVia(currentRoom) : [];
+      return mx.joinRoom(replacementRoomId, {
+        viaServers: via,
+      });
+    }, [mx, roomId, replacementRoomId])
+  );
+  const replacementRoom = mx.getRoom(replacementRoomId);
+
+  const handleOpen = () => {
+    if (replacementRoom) navigateRoom(replacementRoom.roomId);
+    if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
+  };
+
+  return (
+    <RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
+      <Box direction="Column" grow="Yes">
+        <Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
+        {joinState.status === AsyncStatus.Error && (
+          <Text style={{ color: color.Critical.Main }} size="T200">
+            {(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
+          </Text>
+        )}
+      </Box>
+      {replacementRoom?.getMyMembership() === Membership.Join ||
+      joinState.status === AsyncStatus.Success ? (
+        <Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
+          <Text size="B300">Open New Room</Text>
+        </Button>
+      ) : (
+        <Button
+          onClick={handleJoin}
+          size="300"
+          variant="Primary"
+          fill="Solid"
+          radii="300"
+          before={
+            joinState.status === AsyncStatus.Loading && (
+              <Spinner size="100" variant="Primary" fill="Solid" />
+            )
+          }
+          disabled={joinState.status === AsyncStatus.Loading}
+        >
+          <Text size="B300">Join New Room</Text>
+        </Button>
+      )}
+    </RoomInputPlaceholder>
+  );
+}
diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx
new file mode 100644 (file)
index 0000000..fe145b3
--- /dev/null
@@ -0,0 +1,84 @@
+import React, { useRef } from 'react';
+import { Box, Text, config } from 'folds';
+import { EventType, Room } from 'matrix-js-sdk';
+
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { StateEvent } from '../../../types/matrix/room';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useEditor } from '../../components/editor';
+import { RoomInputPlaceholder } from './RoomInputPlaceholder';
+import { RoomTimeline } from './RoomTimeline';
+import { RoomViewTyping } from './RoomViewTyping';
+import { RoomTombstone } from './RoomTombstone';
+import { RoomInput } from './RoomInput';
+import { RoomViewFollowing } from './RoomViewFollowing';
+import { Page } from '../../components/page';
+import { RoomViewHeader } from './RoomViewHeader';
+
+export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
+  const roomInputRef = useRef(null);
+  const roomViewRef = useRef(null);
+
+  const { roomId } = room;
+  const editor = useEditor();
+
+  const mx = useMatrixClient();
+
+  const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
+  const powerLevels = usePowerLevelsContext();
+  const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels);
+  const myUserId = mx.getUserId();
+  const canMessage = myUserId
+    ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
+    : false;
+
+  return (
+    <Page ref={roomViewRef}>
+      <RoomViewHeader />
+      <Box grow="Yes" direction="Column">
+        <RoomTimeline
+          key={roomId}
+          room={room}
+          eventId={eventId}
+          roomInputRef={roomInputRef}
+          editor={editor}
+        />
+        <RoomViewTyping room={room} />
+      </Box>
+      <Box shrink="No" direction="Column">
+        <div style={{ padding: `0 ${config.space.S400}` }}>
+          {tombstoneEvent ? (
+            <RoomTombstone
+              roomId={roomId}
+              body={tombstoneEvent.getContent().body}
+              replacementRoomId={tombstoneEvent.getContent().replacement_room}
+            />
+          ) : (
+            <>
+              {canMessage && (
+                <RoomInput
+                  room={room}
+                  editor={editor}
+                  roomId={roomId}
+                  fileDropContainerRef={roomViewRef}
+                  ref={roomInputRef}
+                />
+              )}
+              {!canMessage && (
+                <RoomInputPlaceholder
+                  style={{ padding: config.space.S200 }}
+                  alignItems="Center"
+                  justifyContent="Center"
+                >
+                  <Text align="Center">You do not have permission to post in this room</Text>
+                </RoomInputPlaceholder>
+              )}
+            </>
+          )}
+        </div>
+        <RoomViewFollowing room={room} />
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts
new file mode 100644 (file)
index 0000000..0a0358e
--- /dev/null
@@ -0,0 +1,31 @@
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const RoomViewFollowing = recipe({
+  base: [
+    DefaultReset,
+    {
+      minHeight: toRem(28),
+      padding: `0 ${config.space.S400}`,
+      width: '100%',
+      backgroundColor: color.Surface.Container,
+      color: color.Surface.OnContainer,
+      outline: 'none',
+    },
+  ],
+  variants: {
+    clickable: {
+      true: {
+        cursor: 'pointer',
+        selectors: {
+          '&:hover, &:focus-visible': {
+            color: color.Primary.Main,
+          },
+          '&:active': {
+            color: color.Primary.Main,
+          },
+        },
+      },
+    },
+  },
+});
diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx
new file mode 100644 (file)
index 0000000..2f7a583
--- /dev/null
@@ -0,0 +1,139 @@
+import React, { useState } from 'react';
+import {
+  Box,
+  Icon,
+  Icons,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Text,
+  as,
+  config,
+} from 'folds';
+import { Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewFollowing.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
+import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
+import { EventReaders } from '../../components/event-readers';
+
+export type RoomViewFollowingProps = {
+  room: Room;
+};
+export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
+  ({ className, room, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [open, setOpen] = useState(false);
+    const latestEvent = useRoomLatestRenderedEvent(room);
+    const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
+    const names = latestEventReaders
+      .filter((readerId) => readerId !== mx.getUserId())
+      .map(
+        (readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId
+      );
+
+    const eventId = latestEvent?.getId();
+
+    return (
+      <>
+        {eventId && (
+          <Overlay open={open} backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setOpen(false),
+                  clickOutsideDeactivates: true,
+                }}
+              >
+                <Modal variant="Surface" size="300">
+                  <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
+                </Modal>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+        <Box
+          as={names.length > 0 ? 'button' : 'div'}
+          onClick={names.length > 0 ? () => setOpen(true) : undefined}
+          className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
+          alignItems="Center"
+          justifyContent="End"
+          gap="200"
+          {...props}
+          ref={ref}
+        >
+          {names.length > 0 && (
+            <>
+              <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
+              <Text size="T300" truncate>
+                {names.length === 1 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' is following the conversation.'}
+                    </Text>
+                  </>
+                )}
+                {names.length === 2 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' and '}
+                    </Text>
+                    <b>{names[1]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' are following the conversation.'}
+                    </Text>
+                  </>
+                )}
+                {names.length === 3 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {', '}
+                    </Text>
+                    <b>{names[1]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' and '}
+                    </Text>
+                    <b>{names[2]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' are following the conversation.'}
+                    </Text>
+                  </>
+                )}
+                {names.length > 3 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {', '}
+                    </Text>
+                    <b>{names[1]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {', '}
+                    </Text>
+                    <b>{names[2]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' and '}
+                    </Text>
+                    <b>{names.length - 3} others</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' are following the conversation.'}
+                    </Text>
+                  </>
+                )}
+              </Text>
+            </>
+          )}
+        </Box>
+      </>
+    );
+  }
+);
diff --git a/src/app/features/room/RoomViewHeader.css.ts b/src/app/features/room/RoomViewHeader.css.ts
new file mode 100644 (file)
index 0000000..19e1afe
--- /dev/null
@@ -0,0 +1,10 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const HeaderTopic = style({
+  ':hover': {
+    cursor: 'pointer',
+    opacity: config.opacity.P500,
+    textDecoration: 'underline',
+  },
+});
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
new file mode 100644 (file)
index 0000000..61c730f
--- /dev/null
@@ -0,0 +1,348 @@
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+  Box,
+  Avatar,
+  Text,
+  Overlay,
+  OverlayCenter,
+  OverlayBackdrop,
+  IconButton,
+  Icon,
+  Icons,
+  Tooltip,
+  TooltipProvider,
+  Menu,
+  MenuItem,
+  toRem,
+  config,
+  Line,
+  PopOut,
+  RectCords,
+} from 'folds';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { JoinRule, Room } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
+
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { PageHeader } from '../../components/page';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { UseStateProvider } from '../../components/UseStateProvider';
+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 { settingsAtom } from '../../state/settings';
+import { useSpaceOptionally } from '../../hooks/useSpace';
+import {
+  getHomeSearchPath,
+  getOriginBaseUrl,
+  getSpaceSearchPath,
+  joinPathComponent,
+  withOriginBaseUrl,
+  withSearchParam,
+} from '../../pages/pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { _SearchPathSearchParams } from '../../pages/paths';
+import * as css from './RoomViewHeader.css';
+import { useRoomUnread } from '../../state/hooks/unread';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { markAsRead } from '../../../client/action/notifications';
+import { roomToUnreadAtom } from '../../state/room/roomToUnread';
+import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { copyToClipboard } from '../../utils/dom';
+import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
+import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
+import { useClientConfig } from '../../hooks/useClientConfig';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+
+type RoomMenuProps = {
+  room: Room;
+  linkPath: string;
+  requestClose: () => void;
+};
+const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
+  ({ room, linkPath, requestClose }, ref) => {
+    const mx = useMatrixClient();
+    const { hashRouter } = useClientConfig();
+    const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+    const powerLevels = usePowerLevelsContext();
+    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+    const handleMarkAsRead = () => {
+      markAsRead(room.roomId);
+      requestClose();
+    };
+
+    const handleInvite = () => {
+      openInviteUser(room.roomId);
+      requestClose();
+    };
+
+    const handleCopyLink = () => {
+      copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
+      requestClose();
+    };
+
+    const handleRoomSettings = () => {
+      toggleRoomSettings(room.roomId);
+      requestClose();
+    };
+
+    return (
+      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleMarkAsRead}
+            size="300"
+            after={<Icon size="100" src={Icons.CheckTwice} />}
+            radii="300"
+            disabled={!unread}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Mark as Read
+            </Text>
+          </MenuItem>
+        </Box>
+        <Line variant="Surface" size="300" />
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleInvite}
+            variant="Primary"
+            fill="None"
+            size="300"
+            after={<Icon size="100" src={Icons.UserPlus} />}
+            radii="300"
+            disabled={!canInvite}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Invite
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleCopyLink}
+            size="300"
+            after={<Icon size="100" src={Icons.Link} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Copy Link
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleRoomSettings}
+            size="300"
+            after={<Icon size="100" src={Icons.Setting} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Room Settings
+            </Text>
+          </MenuItem>
+        </Box>
+        <Line variant="Surface" size="300" />
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <UseStateProvider initial={false}>
+            {(promptLeave, setPromptLeave) => (
+              <>
+                <MenuItem
+                  onClick={() => setPromptLeave(true)}
+                  variant="Critical"
+                  fill="None"
+                  size="300"
+                  after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+                  radii="300"
+                  aria-pressed={promptLeave}
+                >
+                  <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                    Leave Room
+                  </Text>
+                </MenuItem>
+                {promptLeave && (
+                  <LeaveRoomPrompt
+                    roomId={room.roomId}
+                    onDone={requestClose}
+                    onCancel={() => setPromptLeave(false)}
+                  />
+                )}
+              </>
+            )}
+          </UseStateProvider>
+        </Box>
+      </Menu>
+    );
+  }
+);
+
+export function RoomViewHeader() {
+  const navigate = useNavigate();
+  const mx = useMatrixClient();
+  const screenSize = useScreenSizeContext();
+  const room = useRoom();
+  const space = useSpaceOptionally();
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+  const mDirects = useAtomValue(mDirectAtom);
+
+  const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
+  const ecryptedRoom = !!encryptionEvent;
+  const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
+  const name = useRoomName(room);
+  const topic = useRoomTopic(room);
+  const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
+
+  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+  const location = useLocation();
+  const currentPath = joinPathComponent(location);
+
+  const handleSearchClick = () => {
+    const searchParams: _SearchPathSearchParams = {
+      rooms: room.roomId,
+    };
+    const path = space
+      ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
+      : getHomeSearchPath();
+    navigate(withSearchParam(path, searchParams));
+  };
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PageHeader>
+      <Box grow="Yes" gap="300">
+        <Box grow="Yes" alignItems="Center" gap="300">
+          <Avatar size="300">
+            <RoomAvatar
+              roomId={room.roomId}
+              src={avatarUrl}
+              alt={name}
+              renderFallback={() => (
+                <RoomIcon size="200" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
+              )}
+            />
+          </Avatar>
+          <Box direction="Column">
+            <Text size={topic ? 'H5' : 'H3'} truncate>
+              {name}
+            </Text>
+            {topic && (
+              <UseStateProvider initial={false}>
+                {(viewTopic, setViewTopic) => (
+                  <>
+                    <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+                      <OverlayCenter>
+                        <FocusTrap
+                          focusTrapOptions={{
+                            initialFocus: false,
+                            clickOutsideDeactivates: true,
+                            onDeactivate: () => setViewTopic(false),
+                          }}
+                        >
+                          <RoomTopicViewer
+                            name={name}
+                            topic={topic}
+                            requestClose={() => setViewTopic(false)}
+                          />
+                        </FocusTrap>
+                      </OverlayCenter>
+                    </Overlay>
+                    <Text
+                      as="button"
+                      type="button"
+                      onClick={() => setViewTopic(true)}
+                      className={css.HeaderTopic}
+                      size="T200"
+                      priority="300"
+                      truncate
+                    >
+                      {topic}
+                    </Text>
+                  </>
+                )}
+              </UseStateProvider>
+            )}
+          </Box>
+        </Box>
+        <Box shrink="No">
+          {!ecryptedRoom && (
+            <TooltipProvider
+              position="Bottom"
+              offset={4}
+              tooltip={
+                <Tooltip>
+                  <Text>Search</Text>
+                </Tooltip>
+              }
+            >
+              {(triggerRef) => (
+                <IconButton ref={triggerRef} onClick={handleSearchClick}>
+                  <Icon size="400" src={Icons.Search} />
+                </IconButton>
+              )}
+            </TooltipProvider>
+          )}
+          {screenSize === ScreenSize.Desktop && (
+            <TooltipProvider
+              position="Bottom"
+              offset={4}
+              tooltip={
+                <Tooltip>
+                  <Text>Members</Text>
+                </Tooltip>
+              }
+            >
+              {(triggerRef) => (
+                <IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
+                  <Icon size="400" src={Icons.User} />
+                </IconButton>
+              )}
+            </TooltipProvider>
+          )}
+          <TooltipProvider
+            position="Bottom"
+            align="End"
+            offset={4}
+            tooltip={
+              <Tooltip>
+                <Text>More Options</Text>
+              </Tooltip>
+            }
+          >
+            {(triggerRef) => (
+              <IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
+                <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
+              </IconButton>
+            )}
+          </TooltipProvider>
+          <PopOut
+            anchor={menuAnchor}
+            position="Bottom"
+            align="End"
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setMenuAnchor(undefined),
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                }}
+              >
+                <RoomMenu
+                  room={room}
+                  linkPath={currentPath}
+                  requestClose={() => setMenuAnchor(undefined)}
+                />
+              </FocusTrap>
+            }
+          />
+        </Box>
+      </Box>
+    </PageHeader>
+  );
+}
diff --git a/src/app/features/room/RoomViewTyping.css.ts b/src/app/features/room/RoomViewTyping.css.ts
new file mode 100644 (file)
index 0000000..5c90a17
--- /dev/null
@@ -0,0 +1,27 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+const SlideUpAnime = keyframes({
+  from: {
+    transform: 'translateY(100%)',
+  },
+  to: {
+    transform: 'translateY(0)',
+  },
+});
+
+export const RoomViewTyping = style([
+  DefaultReset,
+  {
+    padding: `0 ${config.space.S500}`,
+    width: '100%',
+    backgroundColor: color.Surface.Container,
+    color: color.Surface.OnContainer,
+    position: 'absolute',
+    bottom: 0,
+    animation: `${SlideUpAnime} 100ms ease-in-out`,
+  },
+]);
+export const TypingText = style({
+  flexGrow: 1,
+});
diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx
new file mode 100644 (file)
index 0000000..1142a3a
--- /dev/null
@@ -0,0 +1,121 @@
+import React from 'react';
+import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import { useSetAtom } from 'jotai';
+import { roomIdToTypingMembersAtom } from '../../state/typingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewTyping.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+
+export type RoomViewTypingProps = {
+  room: Room;
+};
+export const RoomViewTyping = as<'div', RoomViewTypingProps>(
+  ({ className, room, ...props }, ref) => {
+    const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
+    const mx = useMatrixClient();
+    const typingMembers = useRoomTypingMember(room.roomId);
+
+    const typingNames = typingMembers
+      .filter((receipt) => receipt.userId !== mx.getUserId())
+      .map(
+        (receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
+      )
+      .reverse();
+
+    if (typingNames.length === 0) {
+      return null;
+    }
+
+    const handleDropAll = () => {
+      // some homeserver does not timeout typing status
+      // we have given option so user can drop their typing status
+      typingMembers.forEach((receipt) =>
+        setTypingMembers({
+          type: 'DELETE',
+          roomId: room.roomId,
+          userId: receipt.userId,
+        })
+      );
+    };
+
+    return (
+      <div style={{ position: 'relative' }}>
+        <Box
+          className={classNames(css.RoomViewTyping, className)}
+          alignItems="Center"
+          gap="400"
+          {...props}
+          ref={ref}
+        >
+          <TypingIndicator />
+          <Text className={css.TypingText} size="T300" truncate>
+            {typingNames.length === 1 && (
+              <>
+                <b>{typingNames[0]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' is typing...'}
+                </Text>
+              </>
+            )}
+            {typingNames.length === 2 && (
+              <>
+                <b>{typingNames[0]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' and '}
+                </Text>
+                <b>{typingNames[1]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' are typing...'}
+                </Text>
+              </>
+            )}
+            {typingNames.length === 3 && (
+              <>
+                <b>{typingNames[0]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {', '}
+                </Text>
+                <b>{typingNames[1]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' and '}
+                </Text>
+                <b>{typingNames[2]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' are typing...'}
+                </Text>
+              </>
+            )}
+            {typingNames.length > 3 && (
+              <>
+                <b>{typingNames[0]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {', '}
+                </Text>
+                <b>{typingNames[1]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {', '}
+                </Text>
+                <b>{typingNames[2]}</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' and '}
+                </Text>
+                <b>{typingNames.length - 3} others</b>
+                <Text as="span" size="Inherit" priority="300">
+                  {' are typing...'}
+                </Text>
+              </>
+            )}
+          </Text>
+          <IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
+            <Icon size="50" src={Icons.Cross} />
+          </IconButton>
+        </Box>
+      </div>
+    );
+  }
+);
diff --git a/src/app/features/room/index.ts b/src/app/features/room/index.ts
new file mode 100644 (file)
index 0000000..50dbc7a
--- /dev/null
@@ -0,0 +1 @@
+export * from './Room';
diff --git a/src/app/features/room/message/EncryptedContent.tsx b/src/app/features/room/message/EncryptedContent.tsx
new file mode 100644 (file)
index 0000000..bf0fd19
--- /dev/null
@@ -0,0 +1,23 @@
+import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
+import React, { ReactNode, useEffect, useState } from 'react';
+
+type EncryptedContentProps = {
+  mEvent: MatrixEvent;
+  children: () => ReactNode;
+};
+
+export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
+  const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted());
+
+  useEffect(() => {
+    const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => {
+      toggleDecrypted((s) => !s);
+    };
+    mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
+    return () => {
+      mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
+    };
+  }, [mEvent]);
+
+  return <>{children()}</>;
+}
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
new file mode 100644 (file)
index 0000000..70d5c55
--- /dev/null
@@ -0,0 +1,1148 @@
+import {
+  Avatar,
+  Box,
+  Button,
+  Dialog,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Line,
+  Menu,
+  MenuItem,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  PopOut,
+  RectCords,
+  Spinner,
+  Text,
+  as,
+  color,
+  config,
+} from 'folds';
+import React, {
+  FormEventHandler,
+  MouseEventHandler,
+  ReactNode,
+  useCallback,
+  useState,
+} from 'react';
+import FocusTrap from 'focus-trap-react';
+import { useHover, useFocusWithin } from 'react-aria';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import classNames from 'classnames';
+import {
+  AvatarBase,
+  BubbleLayout,
+  CompactLayout,
+  MessageBase,
+  ModernLayout,
+  Time,
+  Username,
+} from '../../../components/message';
+import colorMXID from '../../../../util/colorMXID';
+import {
+  canEditEvent,
+  getEventEdits,
+  getMemberAvatarMxc,
+  getMemberDisplayName,
+} from '../../../utils/room';
+import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../../utils/matrix';
+import { MessageLayout, MessageSpacing } from '../../../state/settings';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
+import * as css from './styles.css';
+import { EventReaders } from '../../../components/event-readers';
+import { TextViewer } from '../../../components/text-viewer';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { ReactionViewer } from '../reaction-viewer';
+import { MessageEditor } from './MessageEditor';
+import { UserAvatar } from '../../../components/user-avatar';
+import { useSpaceOptionally } from '../../../hooks/useSpace';
+import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
+import {
+  getDirectRoomPath,
+  getHomeRoomPath,
+  getOriginBaseUrl,
+  getSpaceRoomPath,
+  withOriginBaseUrl,
+} from '../../../pages/pathUtils';
+import { copyToClipboard } from '../../../utils/dom';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+
+export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
+
+type MessageQuickReactionsProps = {
+  onReaction: ReactionHandler;
+};
+export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
+  ({ onReaction, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const recentEmojis = useRecentEmoji(mx, 4);
+
+    if (recentEmojis.length === 0) return <span />;
+    return (
+      <>
+        <Box
+          style={{ padding: config.space.S200 }}
+          alignItems="Center"
+          justifyContent="Center"
+          gap="200"
+          {...props}
+          ref={ref}
+        >
+          {recentEmojis.map((emoji) => (
+            <IconButton
+              key={emoji.unicode}
+              className={css.MessageQuickReaction}
+              size="300"
+              variant="SurfaceVariant"
+              radii="Pill"
+              title={emoji.shortcode}
+              aria-label={emoji.shortcode}
+              onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
+            >
+              <Text size="T500">{emoji.unicode}</Text>
+            </IconButton>
+          ))}
+        </Box>
+        <Line size="300" />
+      </>
+    );
+  }
+);
+
+export const MessageAllReactionItem = as<
+  'button',
+  {
+    room: Room;
+    relations: Relations;
+    onClose?: () => void;
+  }
+>(({ room, relations, onClose, ...props }, ref) => {
+  const [open, setOpen] = useState(false);
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay
+        onContextMenu={(evt: any) => {
+          evt.stopPropagation();
+        }}
+        open={open}
+        backdrop={<OverlayBackdrop />}
+      >
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              returnFocusOnDeactivate: false,
+              onDeactivate: () => handleClose(),
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Modal variant="Surface" size="300">
+              <ReactionViewer
+                room={room}
+                relations={relations}
+                requestClose={() => setOpen(false)}
+              />
+            </Modal>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <MenuItem
+        size="300"
+        after={<Icon size="100" src={Icons.Smile} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        {...props}
+        ref={ref}
+        aria-pressed={open}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          View Reactions
+        </Text>
+      </MenuItem>
+    </>
+  );
+});
+
+export const MessageReadReceiptItem = as<
+  'button',
+  {
+    room: Room;
+    eventId: string;
+    onClose?: () => void;
+  }
+>(({ room, eventId, onClose, ...props }, ref) => {
+  const [open, setOpen] = useState(false);
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Modal variant="Surface" size="300">
+              <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
+            </Modal>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <MenuItem
+        size="300"
+        after={<Icon size="100" src={Icons.CheckTwice} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        {...props}
+        ref={ref}
+        aria-pressed={open}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          Read Receipts
+        </Text>
+      </MenuItem>
+    </>
+  );
+});
+
+export const MessageSourceCodeItem = as<
+  'button',
+  {
+    room: Room;
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+  const [open, setOpen] = useState(false);
+
+  const getContent = (evt: MatrixEvent) =>
+    evt.isEncrypted()
+      ? {
+          [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
+          [`<== ORIGINAL_EVENT ==>`]: evt.event,
+        }
+      : evt.event;
+
+  const getText = (): string => {
+    const evtId = mEvent.getId()!;
+    const evtTimeline = room.getTimelineForEvent(evtId);
+    const edits =
+      evtTimeline &&
+      getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
+
+    if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
+
+    const content: Record<string, unknown> = {
+      '<== MAIN_EVENT ==>': getContent(mEvent),
+    };
+
+    edits.forEach((editEvt, index) => {
+      content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
+    });
+
+    return JSON.stringify(content, null, 2);
+  };
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Modal variant="Surface" size="500">
+              <TextViewer
+                name="Source Code"
+                langName="json"
+                text={getText()}
+                requestClose={handleClose}
+              />
+            </Modal>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <MenuItem
+        size="300"
+        after={<Icon size="100" src={Icons.BlockCode} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        {...props}
+        ref={ref}
+        aria-pressed={open}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          View Source
+        </Text>
+      </MenuItem>
+    </>
+  );
+});
+
+export const MessageCopyLinkItem = as<
+  'button',
+  {
+    room: Room;
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+  const mx = useMatrixClient();
+  const { hashRouter } = useClientConfig();
+  const space = useSpaceOptionally();
+  const directSelected = useDirectSelected();
+
+  const handleCopy = () => {
+    const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
+    let eventPath = getHomeRoomPath(roomIdOrAlias, mEvent.getId());
+    if (space) {
+      eventPath = getSpaceRoomPath(
+        getCanonicalAliasOrRoomId(mx, space.roomId),
+        roomIdOrAlias,
+        mEvent.getId()
+      );
+    } else if (directSelected) {
+      eventPath = getDirectRoomPath(roomIdOrAlias, mEvent.getId());
+    }
+    copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), eventPath));
+    onClose?.();
+  };
+
+  return (
+    <MenuItem
+      size="300"
+      after={<Icon size="100" src={Icons.Link} />}
+      radii="300"
+      onClick={handleCopy}
+      {...props}
+      ref={ref}
+    >
+      <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+        Copy Link
+      </Text>
+    </MenuItem>
+  );
+});
+
+export const MessageDeleteItem = as<
+  'button',
+  {
+    room: Room;
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+  const mx = useMatrixClient();
+  const [open, setOpen] = useState(false);
+
+  const [deleteState, deleteMessage] = useAsyncCallback(
+    useCallback(
+      (eventId: string, reason?: string) =>
+        mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
+      [mx, room]
+    )
+  );
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const eventId = mEvent.getId();
+    if (
+      !eventId ||
+      deleteState.status === AsyncStatus.Loading ||
+      deleteState.status === AsyncStatus.Success
+    )
+      return;
+    const target = evt.target as HTMLFormElement | undefined;
+    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+    const reason = reasonInput && reasonInput.value.trim();
+    deleteMessage(eventId, reason);
+  };
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Dialog variant="Surface">
+              <Header
+                style={{
+                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                  borderBottomWidth: config.borderWidth.B300,
+                }}
+                variant="Surface"
+                size="500"
+              >
+                <Box grow="Yes">
+                  <Text size="H4">Delete Message</Text>
+                </Box>
+                <IconButton size="300" onClick={handleClose} radii="300">
+                  <Icon src={Icons.Cross} />
+                </IconButton>
+              </Header>
+              <Box
+                as="form"
+                onSubmit={handleSubmit}
+                style={{ padding: config.space.S400 }}
+                direction="Column"
+                gap="400"
+              >
+                <Text priority="400">
+                  This action is irreversible! Are you sure that you want to delete this message?
+                </Text>
+                <Box direction="Column" gap="100">
+                  <Text size="L400">
+                    Reason{' '}
+                    <Text as="span" size="T200">
+                      (optional)
+                    </Text>
+                  </Text>
+                  <Input name="reasonInput" variant="Background" />
+                  {deleteState.status === AsyncStatus.Error && (
+                    <Text style={{ color: color.Critical.Main }} size="T300">
+                      Failed to delete message! Please try again.
+                    </Text>
+                  )}
+                </Box>
+                <Button
+                  type="submit"
+                  variant="Critical"
+                  before={
+                    deleteState.status === AsyncStatus.Loading ? (
+                      <Spinner fill="Solid" variant="Critical" size="200" />
+                    ) : undefined
+                  }
+                  aria-disabled={deleteState.status === AsyncStatus.Loading}
+                >
+                  <Text size="B400">
+                    {deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
+                  </Text>
+                </Button>
+              </Box>
+            </Dialog>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <Button
+        variant="Critical"
+        fill="None"
+        size="300"
+        after={<Icon size="100" src={Icons.Delete} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        aria-pressed={open}
+        {...props}
+        ref={ref}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          Delete
+        </Text>
+      </Button>
+    </>
+  );
+});
+
+export const MessageReportItem = as<
+  'button',
+  {
+    room: Room;
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+  const mx = useMatrixClient();
+  const [open, setOpen] = useState(false);
+
+  const [reportState, reportMessage] = useAsyncCallback(
+    useCallback(
+      (eventId: string, score: number, reason: string) =>
+        mx.reportEvent(room.roomId, eventId, score, reason),
+      [mx, room]
+    )
+  );
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const eventId = mEvent.getId();
+    if (
+      !eventId ||
+      reportState.status === AsyncStatus.Loading ||
+      reportState.status === AsyncStatus.Success
+    )
+      return;
+    const target = evt.target as HTMLFormElement | undefined;
+    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+    const reason = reasonInput && reasonInput.value.trim();
+    if (reasonInput) reasonInput.value = '';
+    reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
+  };
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Dialog variant="Surface">
+              <Header
+                style={{
+                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                  borderBottomWidth: config.borderWidth.B300,
+                }}
+                variant="Surface"
+                size="500"
+              >
+                <Box grow="Yes">
+                  <Text size="H4">Report Message</Text>
+                </Box>
+                <IconButton size="300" onClick={handleClose} radii="300">
+                  <Icon src={Icons.Cross} />
+                </IconButton>
+              </Header>
+              <Box
+                as="form"
+                onSubmit={handleSubmit}
+                style={{ padding: config.space.S400 }}
+                direction="Column"
+                gap="400"
+              >
+                <Text priority="400">
+                  Report this message to server, which may then notify the appropriate people to
+                  take action.
+                </Text>
+                <Box direction="Column" gap="100">
+                  <Text size="L400">Reason</Text>
+                  <Input name="reasonInput" variant="Background" required />
+                  {reportState.status === AsyncStatus.Error && (
+                    <Text style={{ color: color.Critical.Main }} size="T300">
+                      Failed to report message! Please try again.
+                    </Text>
+                  )}
+                  {reportState.status === AsyncStatus.Success && (
+                    <Text style={{ color: color.Success.Main }} size="T300">
+                      Message has been reported to server.
+                    </Text>
+                  )}
+                </Box>
+                <Button
+                  type="submit"
+                  variant="Critical"
+                  before={
+                    reportState.status === AsyncStatus.Loading ? (
+                      <Spinner fill="Solid" variant="Critical" size="200" />
+                    ) : undefined
+                  }
+                  aria-disabled={
+                    reportState.status === AsyncStatus.Loading ||
+                    reportState.status === AsyncStatus.Success
+                  }
+                >
+                  <Text size="B400">
+                    {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
+                  </Text>
+                </Button>
+              </Box>
+            </Dialog>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <Button
+        variant="Critical"
+        fill="None"
+        size="300"
+        after={<Icon size="100" src={Icons.Warning} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        aria-pressed={open}
+        {...props}
+        ref={ref}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          Report
+        </Text>
+      </Button>
+    </>
+  );
+});
+
+export type MessageProps = {
+  room: Room;
+  mEvent: MatrixEvent;
+  collapse: boolean;
+  highlight: boolean;
+  edit?: boolean;
+  canDelete?: boolean;
+  canSendReaction?: boolean;
+  imagePackRooms?: Room[];
+  relations?: Relations;
+  messageLayout: MessageLayout;
+  messageSpacing: MessageSpacing;
+  onUserClick: MouseEventHandler<HTMLButtonElement>;
+  onUsernameClick: MouseEventHandler<HTMLButtonElement>;
+  onReplyClick: MouseEventHandler<HTMLButtonElement>;
+  onEditId?: (eventId?: string) => void;
+  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+  reply?: ReactNode;
+  reactions?: ReactNode;
+};
+export const Message = as<'div', MessageProps>(
+  (
+    {
+      className,
+      room,
+      mEvent,
+      collapse,
+      highlight,
+      edit,
+      canDelete,
+      canSendReaction,
+      imagePackRooms,
+      relations,
+      messageLayout,
+      messageSpacing,
+      onUserClick,
+      onUsernameClick,
+      onReplyClick,
+      onReactionToggle,
+      onEditId,
+      reply,
+      reactions,
+      children,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const senderId = mEvent.getSender() ?? '';
+    const [hover, setHover] = useState(false);
+    const { hoverProps } = useHover({ onHoverChange: setHover });
+    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+    const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+    const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
+
+    const senderDisplayName =
+      getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+    const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
+
+    const headerJSX = !collapse && (
+      <Box
+        gap="300"
+        direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
+        justifyContent="SpaceBetween"
+        alignItems="Baseline"
+        grow="Yes"
+      >
+        <Username
+          as="button"
+          style={{ color: colorMXID(senderId) }}
+          data-user-id={senderId}
+          onContextMenu={onUserClick}
+          onClick={onUsernameClick}
+        >
+          <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
+            <b>{senderDisplayName}</b>
+          </Text>
+        </Username>
+        <Box shrink="No" gap="100">
+          {messageLayout === 0 && hover && (
+            <>
+              <Text as="span" size="T200" priority="300">
+                {senderId}
+              </Text>
+              <Text as="span" size="T200" priority="300">
+                |
+              </Text>
+            </>
+          )}
+          <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
+        </Box>
+      </Box>
+    );
+
+    const avatarJSX = !collapse && messageLayout !== 1 && (
+      <AvatarBase>
+        <Avatar
+          className={css.MessageAvatar}
+          as="button"
+          size="300"
+          data-user-id={senderId}
+          onClick={onUserClick}
+        >
+          <UserAvatar
+            userId={senderId}
+            src={
+              senderAvatarMxc
+                ? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
+                : undefined
+            }
+            alt={senderDisplayName}
+            renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+          />
+        </Avatar>
+      </AvatarBase>
+    );
+
+    const msgContentJSX = (
+      <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
+        {reply}
+        {edit && onEditId ? (
+          <MessageEditor
+            style={{
+              maxWidth: '100%',
+              width: '100vw',
+            }}
+            roomId={room.roomId}
+            room={room}
+            mEvent={mEvent}
+            imagePackRooms={imagePackRooms}
+            onCancel={() => onEditId()}
+          />
+        ) : (
+          children
+        )}
+        {reactions}
+      </Box>
+    );
+
+    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+      if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
+      const tag = (evt.target as any).tagName;
+      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+      evt.preventDefault();
+      setMenuAnchor({
+        x: evt.clientX,
+        y: evt.clientY,
+        width: 0,
+        height: 0,
+      });
+    };
+
+    const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+      setMenuAnchor(target.getBoundingClientRect());
+    };
+
+    const closeMenu = () => {
+      setMenuAnchor(undefined);
+    };
+
+    const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
+      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+      setEmojiBoardAnchor(target.getBoundingClientRect());
+    };
+    const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
+      const rect = menuAnchor;
+      closeMenu();
+      // open it with timeout because closeMenu
+      // FocusTrap will return focus from emojiBoard
+
+      setTimeout(() => {
+        setEmojiBoardAnchor(rect);
+      }, 100);
+    };
+
+    return (
+      <MessageBase
+        className={classNames(css.MessageBase, className)}
+        tabIndex={0}
+        space={messageSpacing}
+        collapse={collapse}
+        highlight={highlight}
+        selected={!!menuAnchor || !!emojiBoardAnchor}
+        {...props}
+        {...hoverProps}
+        {...focusWithinProps}
+        ref={ref}
+      >
+        {!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
+          <div className={css.MessageOptionsBase}>
+            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+              <Box gap="100">
+                {canSendReaction && (
+                  <PopOut
+                    position="Bottom"
+                    align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
+                    offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
+                    anchor={emojiBoardAnchor}
+                    content={
+                      <EmojiBoard
+                        imagePackRooms={imagePackRooms ?? []}
+                        returnFocusOnDeactivate={false}
+                        allowTextCustomEmoji
+                        onEmojiSelect={(key) => {
+                          onReactionToggle(mEvent.getId()!, key);
+                          setEmojiBoardAnchor(undefined);
+                        }}
+                        onCustomEmojiSelect={(mxc, shortcode) => {
+                          onReactionToggle(mEvent.getId()!, mxc, shortcode);
+                          setEmojiBoardAnchor(undefined);
+                        }}
+                        requestClose={() => {
+                          setEmojiBoardAnchor(undefined);
+                        }}
+                      />
+                    }
+                  >
+                    <IconButton
+                      onClick={handleOpenEmojiBoard}
+                      variant="SurfaceVariant"
+                      size="300"
+                      radii="300"
+                      aria-pressed={!!emojiBoardAnchor}
+                    >
+                      <Icon src={Icons.SmilePlus} size="100" />
+                    </IconButton>
+                  </PopOut>
+                )}
+                <IconButton
+                  onClick={onReplyClick}
+                  data-event-id={mEvent.getId()}
+                  variant="SurfaceVariant"
+                  size="300"
+                  radii="300"
+                >
+                  <Icon src={Icons.ReplyArrow} size="100" />
+                </IconButton>
+                {canEditEvent(mx, mEvent) && onEditId && (
+                  <IconButton
+                    onClick={() => onEditId(mEvent.getId())}
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                  >
+                    <Icon src={Icons.Pencil} size="100" />
+                  </IconButton>
+                )}
+                <PopOut
+                  anchor={menuAnchor}
+                  position="Bottom"
+                  align={menuAnchor?.width === 0 ? 'Start' : 'End'}
+                  offset={menuAnchor?.width === 0 ? 0 : undefined}
+                  content={
+                    <FocusTrap
+                      focusTrapOptions={{
+                        initialFocus: false,
+                        onDeactivate: () => setMenuAnchor(undefined),
+                        clickOutsideDeactivates: true,
+                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                      }}
+                    >
+                      <Menu>
+                        {canSendReaction && (
+                          <MessageQuickReactions
+                            onReaction={(key, shortcode) => {
+                              onReactionToggle(mEvent.getId()!, key, shortcode);
+                              closeMenu();
+                            }}
+                          />
+                        )}
+                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                          {canSendReaction && (
+                            <MenuItem
+                              size="300"
+                              after={<Icon size="100" src={Icons.SmilePlus} />}
+                              radii="300"
+                              onClick={handleAddReactions}
+                            >
+                              <Text
+                                className={css.MessageMenuItemText}
+                                as="span"
+                                size="T300"
+                                truncate
+                              >
+                                Add Reaction
+                              </Text>
+                            </MenuItem>
+                          )}
+                          {relations && (
+                            <MessageAllReactionItem
+                              room={room}
+                              relations={relations}
+                              onClose={closeMenu}
+                            />
+                          )}
+                          <MenuItem
+                            size="300"
+                            after={<Icon size="100" src={Icons.ReplyArrow} />}
+                            radii="300"
+                            data-event-id={mEvent.getId()}
+                            onClick={(evt: any) => {
+                              onReplyClick(evt);
+                              closeMenu();
+                            }}
+                          >
+                            <Text
+                              className={css.MessageMenuItemText}
+                              as="span"
+                              size="T300"
+                              truncate
+                            >
+                              Reply
+                            </Text>
+                          </MenuItem>
+                          {canEditEvent(mx, mEvent) && onEditId && (
+                            <MenuItem
+                              size="300"
+                              after={<Icon size="100" src={Icons.Pencil} />}
+                              radii="300"
+                              data-event-id={mEvent.getId()}
+                              onClick={() => {
+                                onEditId(mEvent.getId());
+                                closeMenu();
+                              }}
+                            >
+                              <Text
+                                className={css.MessageMenuItemText}
+                                as="span"
+                                size="T300"
+                                truncate
+                              >
+                                Edit Message
+                              </Text>
+                            </MenuItem>
+                          )}
+                          <MessageReadReceiptItem
+                            room={room}
+                            eventId={mEvent.getId() ?? ''}
+                            onClose={closeMenu}
+                          />
+                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
+                          <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
+                        </Box>
+                        {((!mEvent.isRedacted() && canDelete) ||
+                          mEvent.getSender() !== mx.getUserId()) && (
+                          <>
+                            <Line size="300" />
+                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                              {!mEvent.isRedacted() && canDelete && (
+                                <MessageDeleteItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                              {mEvent.getSender() !== mx.getUserId() && (
+                                <MessageReportItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                            </Box>
+                          </>
+                        )}
+                      </Menu>
+                    </FocusTrap>
+                  }
+                >
+                  <IconButton
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                    onClick={handleOpenMenu}
+                    aria-pressed={!!menuAnchor}
+                  >
+                    <Icon src={Icons.VerticalDots} size="100" />
+                  </IconButton>
+                </PopOut>
+              </Box>
+            </Menu>
+          </div>
+        )}
+        {messageLayout === 1 && (
+          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
+            {msgContentJSX}
+          </CompactLayout>
+        )}
+        {messageLayout === 2 && (
+          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+            {headerJSX}
+            {msgContentJSX}
+          </BubbleLayout>
+        )}
+        {messageLayout !== 1 && messageLayout !== 2 && (
+          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+            {headerJSX}
+            {msgContentJSX}
+          </ModernLayout>
+        )}
+      </MessageBase>
+    );
+  }
+);
+
+export type EventProps = {
+  room: Room;
+  mEvent: MatrixEvent;
+  highlight: boolean;
+  canDelete?: boolean;
+  messageSpacing: MessageSpacing;
+};
+export const Event = as<'div', EventProps>(
+  ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [hover, setHover] = useState(false);
+    const { hoverProps } = useHover({ onHoverChange: setHover });
+    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+    const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+    const stateEvent = typeof mEvent.getStateKey() === 'string';
+
+    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+      if (evt.altKey || !window.getSelection()?.isCollapsed) return;
+      const tag = (evt.target as any).tagName;
+      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+      evt.preventDefault();
+      setMenuAnchor({
+        x: evt.clientX,
+        y: evt.clientY,
+        width: 0,
+        height: 0,
+      });
+    };
+
+    const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+      setMenuAnchor(target.getBoundingClientRect());
+    };
+
+    const closeMenu = () => {
+      setMenuAnchor(undefined);
+    };
+
+    return (
+      <MessageBase
+        className={classNames(css.MessageBase, className)}
+        tabIndex={0}
+        space={messageSpacing}
+        autoCollapse
+        highlight={highlight}
+        selected={!!menuAnchor}
+        {...props}
+        {...hoverProps}
+        {...focusWithinProps}
+        ref={ref}
+      >
+        {(hover || !!menuAnchor) && (
+          <div className={css.MessageOptionsBase}>
+            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+              <Box gap="100">
+                <PopOut
+                  anchor={menuAnchor}
+                  position="Bottom"
+                  align={menuAnchor?.width === 0 ? 'Start' : 'End'}
+                  offset={menuAnchor?.width === 0 ? 0 : undefined}
+                  content={
+                    <FocusTrap
+                      focusTrapOptions={{
+                        initialFocus: false,
+                        onDeactivate: () => setMenuAnchor(undefined),
+                        clickOutsideDeactivates: true,
+                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                      }}
+                    >
+                      <Menu {...props} ref={ref}>
+                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                          <MessageReadReceiptItem
+                            room={room}
+                            eventId={mEvent.getId() ?? ''}
+                            onClose={closeMenu}
+                          />
+                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
+                          <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
+                        </Box>
+                        {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
+                          (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
+                          <>
+                            <Line size="300" />
+                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                              {!mEvent.isRedacted() && canDelete && (
+                                <MessageDeleteItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                              {mEvent.getSender() !== mx.getUserId() && (
+                                <MessageReportItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                            </Box>
+                          </>
+                        )}
+                      </Menu>
+                    </FocusTrap>
+                  }
+                >
+                  <IconButton
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                    onClick={handleOpenMenu}
+                    aria-pressed={!!menuAnchor}
+                  >
+                    <Icon src={Icons.VerticalDots} size="100" />
+                  </IconButton>
+                </PopOut>
+              </Box>
+            </Menu>
+          </div>
+        )}
+        <div onContextMenu={handleContextMenu}>{children}</div>
+      </MessageBase>
+    );
+  }
+);
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
new file mode 100644 (file)
index 0000000..0c99503
--- /dev/null
@@ -0,0 +1,331 @@
+import React, {
+  KeyboardEventHandler,
+  MouseEventHandler,
+  useCallback,
+  useEffect,
+  useState,
+} from 'react';
+import {
+  Box,
+  Chip,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  PopOut,
+  RectCords,
+  Spinner,
+  Text,
+  as,
+  config,
+} from 'folds';
+import { Editor, Transforms } from 'slate';
+import { ReactEditor } from 'slate-react';
+import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
+import { isKeyHotkey } from 'is-hotkey';
+import {
+  AUTOCOMPLETE_PREFIXES,
+  AutocompletePrefix,
+  AutocompleteQuery,
+  CustomEditor,
+  EmoticonAutocomplete,
+  RoomMentionAutocomplete,
+  Toolbar,
+  UserMentionAutocomplete,
+  createEmoticonElement,
+  customHtmlEqualsPlainText,
+  getAutocompleteQuery,
+  getPrevWorldRange,
+  htmlToEditorInput,
+  moveCursor,
+  plainToEditorInput,
+  toMatrixCustomHTML,
+  toPlainText,
+  trimCustomHtml,
+  useEditor,
+} from '../../../components/editor';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
+import { mobileOrTablet } from '../../../utils/user-agent';
+
+type MessageEditorProps = {
+  roomId: string;
+  room: Room;
+  mEvent: MatrixEvent;
+  imagePackRooms?: Room[];
+  onCancel: () => void;
+};
+export const MessageEditor = as<'div', MessageEditorProps>(
+  ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const editor = useEditor();
+    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
+    const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
+    const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+    const [toolbar, setToolbar] = useState(globalToolbar);
+
+    const [autocompleteQuery, setAutocompleteQuery] =
+      useState<AutocompleteQuery<AutocompletePrefix>>();
+
+    const getPrevBodyAndFormattedBody = useCallback((): [
+      string | undefined,
+      string | undefined
+    ] => {
+      const evtId = mEvent.getId()!;
+      const evtTimeline = room.getTimelineForEvent(evtId);
+      const editedEvent =
+        evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
+
+      const { body, formatted_body: customHtml }: Record<string, unknown> =
+        editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
+
+      return [
+        typeof body === 'string' ? body : undefined,
+        typeof customHtml === 'string' ? customHtml : undefined,
+      ];
+    }, [room, mEvent]);
+
+    const [saveState, save] = useAsyncCallback(
+      useCallback(async () => {
+        const plainText = toPlainText(editor.children).trim();
+        const customHtml = trimCustomHtml(
+          toMatrixCustomHTML(editor.children, {
+            allowTextFormatting: true,
+            allowBlockMarkdown: isMarkdown,
+            allowInlineMarkdown: isMarkdown,
+          })
+        );
+
+        const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
+
+        if (plainText === '') return undefined;
+        if (prevBody) {
+          if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
+            return undefined;
+          }
+          if (
+            !prevCustomHtml &&
+            prevBody === plainText &&
+            customHtmlEqualsPlainText(customHtml, plainText)
+          ) {
+            return undefined;
+          }
+        }
+
+        const newContent: IContent = {
+          msgtype: mEvent.getContent().msgtype,
+          body: plainText,
+        };
+
+        if (!customHtmlEqualsPlainText(customHtml, plainText)) {
+          newContent.format = 'org.matrix.custom.html';
+          newContent.formatted_body = customHtml;
+        }
+
+        const content: IContent = {
+          ...newContent,
+          body: `* ${plainText}`,
+          'm.new_content': newContent,
+          'm.relates_to': {
+            event_id: mEvent.getId(),
+            rel_type: RelationType.Replace,
+          },
+        };
+
+        return mx.sendMessage(roomId, content);
+      }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
+    );
+
+    const handleSave = useCallback(() => {
+      if (saveState.status !== AsyncStatus.Loading) {
+        save();
+      }
+    }, [saveState, save]);
+
+    const handleKeyDown: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
+          evt.preventDefault();
+          handleSave();
+        }
+        if (isKeyHotkey('escape', evt)) {
+          evt.preventDefault();
+          onCancel();
+        }
+      },
+      [onCancel, handleSave, enterForNewline]
+    );
+
+    const handleKeyUp: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isKeyHotkey('escape', evt)) {
+          evt.preventDefault();
+          return;
+        }
+
+        const prevWordRange = getPrevWorldRange(editor);
+        const query = prevWordRange
+          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+          : undefined;
+        setAutocompleteQuery(query);
+      },
+      [editor]
+    );
+
+    const handleCloseAutocomplete = useCallback(() => {
+      ReactEditor.focus(editor);
+      setAutocompleteQuery(undefined);
+    }, [editor]);
+
+    const handleEmoticonSelect = (key: string, shortcode: string) => {
+      editor.insertNode(createEmoticonElement(key, shortcode));
+      moveCursor(editor);
+    };
+
+    useEffect(() => {
+      const [body, customHtml] = getPrevBodyAndFormattedBody();
+
+      const initialValue =
+        typeof customHtml === 'string'
+          ? htmlToEditorInput(customHtml)
+          : plainToEditorInput(typeof body === 'string' ? body : '');
+
+      Transforms.select(editor, {
+        anchor: Editor.start(editor, []),
+        focus: Editor.end(editor, []),
+      });
+
+      editor.insertFragment(initialValue);
+      if (!mobileOrTablet()) ReactEditor.focus(editor);
+    }, [editor, getPrevBodyAndFormattedBody]);
+
+    useEffect(() => {
+      if (saveState.status === AsyncStatus.Success) {
+        onCancel();
+      }
+    }, [saveState, onCancel]);
+
+    return (
+      <div {...props} ref={ref}>
+        {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+          <RoomMentionAutocomplete
+            roomId={roomId}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+          <UserMentionAutocomplete
+            room={room}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+          <EmoticonAutocomplete
+            imagePackRooms={imagePackRooms || []}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        <CustomEditor
+          editor={editor}
+          placeholder="Edit message..."
+          onKeyDown={handleKeyDown}
+          onKeyUp={handleKeyUp}
+          bottom={
+            <>
+              <Box
+                style={{ padding: config.space.S200, paddingTop: 0 }}
+                alignItems="End"
+                justifyContent="SpaceBetween"
+                gap="100"
+              >
+                <Box gap="Inherit">
+                  <Chip
+                    onClick={handleSave}
+                    variant="Primary"
+                    radii="Pill"
+                    disabled={saveState.status === AsyncStatus.Loading}
+                    outlined
+                    before={
+                      saveState.status === AsyncStatus.Loading ? (
+                        <Spinner variant="Primary" fill="Soft" size="100" />
+                      ) : undefined
+                    }
+                  >
+                    <Text size="B300">Save</Text>
+                  </Chip>
+                  <Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
+                    <Text size="B300">Cancel</Text>
+                  </Chip>
+                </Box>
+                <Box gap="Inherit">
+                  <IconButton
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                    onClick={() => setToolbar(!toolbar)}
+                  >
+                    <Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+                  </IconButton>
+                  <UseStateProvider initial={undefined}>
+                    {(anchor: RectCords | undefined, setAnchor) => (
+                      <PopOut
+                        anchor={anchor}
+                        alignOffset={-8}
+                        position="Top"
+                        align="End"
+                        content={
+                          <EmojiBoard
+                            imagePackRooms={imagePackRooms ?? []}
+                            returnFocusOnDeactivate={false}
+                            onEmojiSelect={handleEmoticonSelect}
+                            onCustomEmojiSelect={handleEmoticonSelect}
+                            requestClose={() => {
+                              setAnchor(undefined);
+                              if (!mobileOrTablet()) ReactEditor.focus(editor);
+                            }}
+                          />
+                        }
+                      >
+                        <IconButton
+                          aria-pressed={anchor !== undefined}
+                          onClick={
+                            ((evt) =>
+                              setAnchor(
+                                evt.currentTarget.getBoundingClientRect()
+                              )) as MouseEventHandler<HTMLButtonElement>
+                          }
+                          variant="SurfaceVariant"
+                          size="300"
+                          radii="300"
+                        >
+                          <Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
+                        </IconButton>
+                      </PopOut>
+                    )}
+                  </UseStateProvider>
+                </Box>
+              </Box>
+              {toolbar && (
+                <div>
+                  <Line variant="SurfaceVariant" size="300" />
+                  <Toolbar />
+                </div>
+              )}
+            </>
+          }
+        />
+      </div>
+    );
+  }
+);
diff --git a/src/app/features/room/message/Reactions.tsx b/src/app/features/room/message/Reactions.tsx
new file mode 100644 (file)
index 0000000..728cf81
--- /dev/null
@@ -0,0 +1,125 @@
+import React, { MouseEventHandler, useCallback, useState } from 'react';
+import {
+  Box,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+  toRem,
+} from 'folds';
+import classNames from 'classnames';
+import { Room } from 'matrix-js-sdk';
+import { type Relations } from 'matrix-js-sdk/lib/models/relations';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { factoryEventSentBy } from '../../../utils/matrix';
+import { Reaction, ReactionTooltipMsg } from '../../../components/message';
+import { useRelations } from '../../../hooks/useRelations';
+import * as css from './styles.css';
+import { ReactionViewer } from '../reaction-viewer';
+
+export type ReactionsProps = {
+  room: Room;
+  mEventId: string;
+  canSendReaction?: boolean;
+  relations: Relations;
+  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+};
+export const Reactions = as<'div', ReactionsProps>(
+  ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [viewer, setViewer] = useState<boolean | string>(false);
+    const myUserId = mx.getUserId();
+    const reactions = useRelations(
+      relations,
+      useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+    );
+
+    const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
+      evt.stopPropagation();
+      evt.preventDefault();
+      const key = evt.currentTarget.getAttribute('data-reaction-key');
+      if (!key) setViewer(true);
+      else setViewer(key);
+    };
+
+    return (
+      <Box
+        className={classNames(css.ReactionsContainer, className)}
+        gap="200"
+        wrap="Wrap"
+        {...props}
+        ref={ref}
+      >
+        {reactions.map(([key, events]) => {
+          const rEvents = Array.from(events);
+          if (rEvents.length === 0 || typeof key !== 'string') return null;
+          const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
+          const isPressed = !!myREvent?.getRelation();
+
+          return (
+            <TooltipProvider
+              key={key}
+              position="Top"
+              tooltip={
+                <Tooltip style={{ maxWidth: toRem(200) }}>
+                  <Text className={css.ReactionsTooltipText} size="T300">
+                    <ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
+                  </Text>
+                </Tooltip>
+              }
+            >
+              {(targetRef) => (
+                <Reaction
+                  ref={targetRef}
+                  data-reaction-key={key}
+                  aria-pressed={isPressed}
+                  key={key}
+                  mx={mx}
+                  reaction={key}
+                  count={events.size}
+                  onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
+                  onContextMenu={handleViewReaction}
+                  aria-disabled={!canSendReaction}
+                />
+              )}
+            </TooltipProvider>
+          );
+        })}
+        {reactions.length > 0 && (
+          <Overlay
+            onContextMenu={(evt: any) => {
+              evt.stopPropagation();
+            }}
+            open={!!viewer}
+            backdrop={<OverlayBackdrop />}
+          >
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setViewer(false),
+                  clickOutsideDeactivates: true,
+                }}
+              >
+                <Modal variant="Surface" size="300">
+                  <ReactionViewer
+                    room={room}
+                    initialKey={typeof viewer === 'string' ? viewer : undefined}
+                    relations={relations}
+                    requestClose={() => setViewer(false)}
+                  />
+                </Modal>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/features/room/message/index.ts b/src/app/features/room/message/index.ts
new file mode 100644 (file)
index 0000000..d79e2aa
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './Reactions';
+export * from './Message';
+export * from './EncryptedContent';
diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts
new file mode 100644 (file)
index 0000000..b87cb50
--- /dev/null
@@ -0,0 +1,50 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config, toRem } from 'folds';
+
+export const MessageBase = style({
+  position: 'relative',
+});
+
+export const MessageOptionsBase = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    top: toRem(-30),
+    right: 0,
+    zIndex: 1,
+  },
+]);
+export const MessageOptionsBar = style([
+  DefaultReset,
+  {
+    padding: config.space.S100,
+  },
+]);
+
+export const MessageAvatar = style({
+  cursor: 'pointer',
+});
+
+export const MessageQuickReaction = style({
+  minWidth: toRem(32),
+});
+
+export const MessageMenuGroup = style({
+  padding: config.space.S100,
+});
+
+export const MessageMenuItemText = style({
+  flexGrow: 1,
+});
+
+export const ReactionsContainer = style({
+  selectors: {
+    '&:empty': {
+      display: 'none',
+    },
+  },
+});
+
+export const ReactionsTooltipText = style({
+  wordBreak: 'break-word',
+});
diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts
new file mode 100644 (file)
index 0000000..103e8dc
--- /dev/null
@@ -0,0 +1,162 @@
+import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
+import to from 'await-to-js';
+import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
+import {
+  getImageFileUrl,
+  getThumbnail,
+  getThumbnailDimensions,
+  getVideoFileUrl,
+  loadImageElement,
+  loadVideoElement,
+} from '../../utils/dom';
+import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
+import { TUploadItem } from '../../state/room/roomInputDrafts';
+import { encodeBlurHash } from '../../utils/blurHash';
+import { scaleYDimension } from '../../utils/common';
+
+const generateThumbnailContent = async (
+  mx: MatrixClient,
+  img: HTMLImageElement | HTMLVideoElement,
+  dimensions: [number, number],
+  encrypt: boolean
+): Promise<IThumbnailContent> => {
+  const thumbnail = await getThumbnail(img, ...dimensions);
+  if (!thumbnail) throw new Error('Can not create thumbnail!');
+  const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
+  const thumbnailFile = encThumbData?.file ?? thumbnail;
+  if (!thumbnailFile) throw new Error('Can not create thumbnail!');
+
+  const data = await mx.uploadContent(thumbnailFile);
+  const thumbMxc = data?.content_uri;
+  if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
+  const thumbnailContent = getThumbnailContent({
+    thumbnail: thumbnailFile,
+    encInfo: encThumbData?.encInfo,
+    mxc: thumbMxc,
+    width: dimensions[0],
+    height: dimensions[1],
+  });
+  return thumbnailContent;
+};
+
+export const getImageMsgContent = async (
+  mx: MatrixClient,
+  item: TUploadItem,
+  mxc: string
+): Promise<IContent> => {
+  const { file, originalFile, encInfo } = item;
+  const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
+  if (imgError) console.warn(imgError);
+
+  const content: IContent = {
+    msgtype: MsgType.Image,
+    body: file.name,
+  };
+  if (imgEl) {
+    const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
+
+    content.info = {
+      ...getImageInfo(imgEl, file),
+      [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
+    };
+  }
+  if (encInfo) {
+    content.file = {
+      ...encInfo,
+      url: mxc,
+    };
+  } else {
+    content.url = mxc;
+  }
+  return content;
+};
+
+export const getVideoMsgContent = async (
+  mx: MatrixClient,
+  item: TUploadItem,
+  mxc: string
+): Promise<IContent> => {
+  const { file, originalFile, encInfo } = item;
+
+  const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
+  if (videoError) console.warn(videoError);
+
+  const content: IContent = {
+    msgtype: MsgType.Video,
+    body: file.name,
+  };
+  if (videoEl) {
+    const [thumbError, thumbContent] = await to(
+      generateThumbnailContent(
+        mx,
+        videoEl,
+        getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
+        !!encInfo
+      )
+    );
+    if (thumbContent && thumbContent.thumbnail_info) {
+      thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(
+        videoEl,
+        512,
+        scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
+      );
+    }
+    if (thumbError) console.warn(thumbError);
+    content.info = {
+      ...getVideoInfo(videoEl, file),
+      ...thumbContent,
+    };
+  }
+  if (encInfo) {
+    content.file = {
+      ...encInfo,
+      url: mxc,
+    };
+  } else {
+    content.url = mxc;
+  }
+  return content;
+};
+
+export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
+  const { file, encInfo } = item;
+  const content: IContent = {
+    msgtype: MsgType.Audio,
+    body: file.name,
+    info: {
+      mimetype: file.type,
+      size: file.size,
+    },
+  };
+  if (encInfo) {
+    content.file = {
+      ...encInfo,
+      url: mxc,
+    };
+  } else {
+    content.url = mxc;
+  }
+  return content;
+};
+
+export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
+  const { file, encInfo } = item;
+  const content: IContent = {
+    msgtype: MsgType.File,
+    body: file.name,
+    filename: file.name,
+    info: {
+      mimetype: file.type,
+      size: file.size,
+    },
+  };
+  if (encInfo) {
+    content.file = {
+      ...encInfo,
+      url: mxc,
+    };
+  } else {
+    content.url = mxc;
+  }
+  return content;
+};
diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.css.ts b/src/app/features/room/reaction-viewer/ReactionViewer.css.ts
new file mode 100644 (file)
index 0000000..a8a85b0
--- /dev/null
@@ -0,0 +1,31 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const ReactionViewer = style([
+  DefaultReset,
+  {
+    height: '100%',
+  },
+]);
+
+export const Sidebar = style({
+  backgroundColor: color.Background.Container,
+  color: color.Background.OnContainer,
+});
+export const SidebarContent = style({
+  padding: config.space.S200,
+  paddingRight: 0,
+});
+
+export const Header = style({
+  paddingLeft: config.space.S400,
+  paddingRight: config.space.S300,
+
+  flexShrink: 0,
+  gap: config.space.S200,
+});
+
+export const Content = style({
+  paddingLeft: config.space.S200,
+  paddingBottom: config.space.S400,
+});
diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx
new file mode 100644 (file)
index 0000000..1c1b79a
--- /dev/null
@@ -0,0 +1,154 @@
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import {
+  Avatar,
+  Box,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  MenuItem,
+  Scroll,
+  Text,
+  as,
+  config,
+} from 'folds';
+import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import { getMemberDisplayName } from '../../../utils/room';
+import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
+import * as css from './ReactionViewer.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { openProfileViewer } from '../../../../client/action/navigation';
+import { useRelations } from '../../../hooks/useRelations';
+import { Reaction } from '../../../components/message';
+import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
+import { UserAvatar } from '../../../components/user-avatar';
+
+export type ReactionViewerProps = {
+  room: Room;
+  initialKey?: string;
+  relations: Relations;
+  requestClose: () => void;
+};
+export const ReactionViewer = as<'div', ReactionViewerProps>(
+  ({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const reactions = useRelations(
+      relations,
+      useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+    );
+
+    const [selectedKey, setSelectedKey] = useState<string>(() => {
+      if (initialKey) return initialKey;
+      const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
+      return defaultReaction ? defaultReaction[0] : '';
+    });
+
+    const getName = (member: RoomMember) =>
+      getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+    const getReactionsForKey = (key: string): MatrixEvent[] => {
+      const reactSet = reactions.find(([k]) => k === key)?.[1];
+      if (!reactSet) return [];
+      return Array.from(reactSet);
+    };
+
+    const selectedReactions = getReactionsForKey(selectedKey);
+    const selectedShortcode =
+      selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
+      getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
+      selectedKey;
+
+    return (
+      <Box
+        className={classNames(css.ReactionViewer, className)}
+        direction="Row"
+        {...props}
+        ref={ref}
+      >
+        <Box shrink="No" className={css.Sidebar}>
+          <Scroll visibility="Hover" hideTrack size="300">
+            <Box className={css.SidebarContent} direction="Column" gap="200">
+              {reactions.map(([key, evts]) => {
+                if (typeof key !== 'string') return null;
+                return (
+                  <Reaction
+                    key={key}
+                    mx={mx}
+                    reaction={key}
+                    count={evts.size}
+                    aria-selected={key === selectedKey}
+                    onClick={() => setSelectedKey(key)}
+                  />
+                );
+              })}
+            </Box>
+          </Scroll>
+        </Box>
+        <Line variant="Surface" direction="Vertical" size="300" />
+        <Box grow="Yes" direction="Column">
+          <Header className={css.Header} variant="Surface" size="600">
+            <Box grow="Yes">
+              <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
+            </Box>
+            <IconButton size="300" onClick={requestClose}>
+              <Icon src={Icons.Cross} />
+            </IconButton>
+          </Header>
+
+          <Box grow="Yes">
+            <Scroll visibility="Hover" hideTrack size="300">
+              <Box className={css.Content} direction="Column">
+                {selectedReactions.map((mEvent) => {
+                  const senderId = mEvent.getSender();
+                  if (!senderId) return null;
+                  const member = room.getMember(senderId);
+                  const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
+
+                  const avatarUrl = member?.getAvatarUrl(
+                    mx.baseUrl,
+                    100,
+                    100,
+                    'crop',
+                    undefined,
+                    false
+                  );
+
+                  return (
+                    <MenuItem
+                      key={senderId}
+                      style={{ padding: `0 ${config.space.S200}` }}
+                      radii="400"
+                      onClick={() => {
+                        requestClose();
+                        openProfileViewer(senderId, room.roomId);
+                      }}
+                      before={
+                        <Avatar size="200">
+                          <UserAvatar
+                            userId={senderId}
+                            src={avatarUrl ?? undefined}
+                            alt={name}
+                            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
+                          />
+                        </Avatar>
+                      }
+                    >
+                      <Box grow="Yes">
+                        <Text size="T400" truncate>
+                          {name}
+                        </Text>
+                      </Box>
+                    </MenuItem>
+                  );
+                })}
+              </Box>
+            </Scroll>
+          </Box>
+        </Box>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/features/room/reaction-viewer/index.ts b/src/app/features/room/reaction-viewer/index.ts
new file mode 100644 (file)
index 0000000..172e6f3
--- /dev/null
@@ -0,0 +1 @@
+export * from './ReactionViewer';
diff --git a/src/app/hooks/router/useDirectSelected.ts b/src/app/hooks/router/useDirectSelected.ts
new file mode 100644 (file)
index 0000000..adb2851
--- /dev/null
@@ -0,0 +1,22 @@
+import { useMatch } from 'react-router-dom';
+import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
+
+export const useDirectSelected = (): boolean => {
+  const directMatch = useMatch({
+    path: getDirectPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!directMatch;
+};
+
+export const useDirectCreateSelected = (): boolean => {
+  const match = useMatch({
+    path: getDirectCreatePath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
diff --git a/src/app/hooks/router/useExploreSelected.ts b/src/app/hooks/router/useExploreSelected.ts
new file mode 100644 (file)
index 0000000..f0ffdc8
--- /dev/null
@@ -0,0 +1,28 @@
+import { useMatch, useParams } from 'react-router-dom';
+import { getExploreFeaturedPath, getExplorePath } from '../../pages/pathUtils';
+
+export const useExploreSelected = (): boolean => {
+  const match = useMatch({
+    path: getExplorePath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useExploreFeaturedSelected = (): boolean => {
+  const match = useMatch({
+    path: getExploreFeaturedPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useExploreServer = (): string | undefined => {
+  const { server } = useParams();
+
+  return server;
+};
diff --git a/src/app/hooks/router/useHomeSelected.ts b/src/app/hooks/router/useHomeSelected.ts
new file mode 100644 (file)
index 0000000..7580260
--- /dev/null
@@ -0,0 +1,47 @@
+import { useMatch } from 'react-router-dom';
+import {
+  getHomeCreatePath,
+  getHomeJoinPath,
+  getHomePath,
+  getHomeSearchPath,
+} from '../../pages/pathUtils';
+
+export const useHomeSelected = (): boolean => {
+  const homeMatch = useMatch({
+    path: getHomePath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!homeMatch;
+};
+
+export const useHomeCreateSelected = (): boolean => {
+  const match = useMatch({
+    path: getHomeCreatePath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useHomeJoinSelected = (): boolean => {
+  const match = useMatch({
+    path: getHomeJoinPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useHomeSearchSelected = (): boolean => {
+  const match = useMatch({
+    path: getHomeSearchPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts
new file mode 100644 (file)
index 0000000..de8d3d6
--- /dev/null
@@ -0,0 +1,36 @@
+import { useMatch } from 'react-router-dom';
+import {
+  getInboxInvitesPath,
+  getInboxNotificationsPath,
+  getInboxPath,
+} from '../../pages/pathUtils';
+
+export const useInboxSelected = (): boolean => {
+  const match = useMatch({
+    path: getInboxPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useInboxNotificationsSelected = (): boolean => {
+  const match = useMatch({
+    path: getInboxNotificationsPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useInboxInvitesSelected = (): boolean => {
+  const match = useMatch({
+    path: getInboxInvitesPath(),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
diff --git a/src/app/hooks/router/useSelectedRoom.ts b/src/app/hooks/router/useSelectedRoom.ts
new file mode 100644 (file)
index 0000000..6d0ee4f
--- /dev/null
@@ -0,0 +1,15 @@
+import { useParams } from 'react-router-dom';
+import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
+import { useMatrixClient } from '../useMatrixClient';
+
+export const useSelectedRoom = (): string | undefined => {
+  const mx = useMatrixClient();
+
+  const { roomIdOrAlias } = useParams();
+  const roomId =
+    roomIdOrAlias && isRoomAlias(roomIdOrAlias)
+      ? getCanonicalAliasRoomId(mx, roomIdOrAlias)
+      : roomIdOrAlias;
+
+  return roomId;
+};
diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts
new file mode 100644 (file)
index 0000000..2e8e73a
--- /dev/null
@@ -0,0 +1,37 @@
+import { useMatch, useParams } from 'react-router-dom';
+import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
+import { useMatrixClient } from '../useMatrixClient';
+import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
+
+export const useSelectedSpace = (): string | undefined => {
+  const mx = useMatrixClient();
+
+  const { spaceIdOrAlias } = useParams();
+
+  const spaceId =
+    spaceIdOrAlias && isRoomAlias(spaceIdOrAlias)
+      ? getCanonicalAliasRoomId(mx, spaceIdOrAlias)
+      : spaceIdOrAlias;
+
+  return spaceId;
+};
+
+export const useSpaceLobbySelected = (spaceIdOrAlias: string): boolean => {
+  const match = useMatch({
+    path: getSpaceLobbyPath(spaceIdOrAlias),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
+
+export const useSpaceSearchSelected = (spaceIdOrAlias: string): boolean => {
+  const match = useMatch({
+    path: getSpaceSearchPath(spaceIdOrAlias),
+    caseSensitive: true,
+    end: false,
+  });
+
+  return !!match;
+};
diff --git a/src/app/hooks/useAccountDataCallback.ts b/src/app/hooks/useAccountDataCallback.ts
new file mode 100644 (file)
index 0000000..e12f1c4
--- /dev/null
@@ -0,0 +1,14 @@
+import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+
+export const useAccountDataCallback = (
+  mx: MatrixClient,
+  onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]
+) => {
+  useEffect(() => {
+    mx.on(ClientEvent.AccountData, onAccountData);
+    return () => {
+      mx.removeListener(ClientEvent.AccountData, onAccountData);
+    };
+  }, [mx, onAccountData]);
+};
index fc7dca63fe90fa30bb062a299eb506bd4462c908..431f0b32f0d485f03fa2d499fd5804efc66027f7 100644 (file)
@@ -68,9 +68,11 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
           throw new Error('AsyncCallbackHook: Request replaced!');
         }
         if (alive()) {
-          setState({
-            status: AsyncStatus.Success,
-            data,
+          queueMicrotask(() => {
+            setState({
+              status: AsyncStatus.Success,
+              data,
+            });
           });
         }
         return data;
@@ -78,10 +80,13 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
         if (currentReqNumber !== reqNumberRef.current) {
           throw new Error('AsyncCallbackHook: Request replaced!');
         }
+
         if (alive()) {
-          setState({
-            status: AsyncStatus.Error,
-            error: e as TError,
+          queueMicrotask(() => {
+            setState({
+              status: AsyncStatus.Error,
+              error: e as TError,
+            });
           });
         }
         throw e;
diff --git a/src/app/hooks/useCapabilities.ts b/src/app/hooks/useCapabilities.ts
new file mode 100644 (file)
index 0000000..b2fbd64
--- /dev/null
@@ -0,0 +1,12 @@
+import { Capabilities } from 'matrix-js-sdk';
+import { createContext, useContext } from 'react';
+
+const CapabilitiesContext = createContext<Capabilities | null>(null);
+
+export const CapabilitiesProvider = CapabilitiesContext.Provider;
+
+export function useCapabilities(): Capabilities {
+  const capabilities = useContext(CapabilitiesContext);
+  if (!capabilities) throw new Error('Capabilities are not provided!');
+  return capabilities;
+}
diff --git a/src/app/hooks/useCategoryHandler.ts b/src/app/hooks/useCategoryHandler.ts
new file mode 100644 (file)
index 0000000..086902d
--- /dev/null
@@ -0,0 +1,27 @@
+import { MouseEventHandler } from 'react';
+
+type CategoryAction =
+  | {
+      type: 'PUT';
+      categoryId: string;
+    }
+  | {
+      type: 'DELETE';
+      categoryId: string;
+    };
+export const useCategoryHandler = (
+  setAtom: (action: CategoryAction) => void,
+  closed: (categoryId: string) => boolean
+) => {
+  const handleCategoryClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const categoryId = evt.currentTarget.getAttribute('data-category-id');
+    if (!categoryId) return;
+    if (closed(categoryId)) {
+      setAtom({ type: 'DELETE', categoryId });
+      return;
+    }
+    setAtom({ type: 'PUT', categoryId });
+  };
+
+  return handleCategoryClick;
+};
index 8406668dedbe3ce5b6f627f6bef466a5ece60989..e5fc6cc617193b2f19d64c1d3dbf24536e7a5b21 100644 (file)
@@ -1,14 +1,23 @@
 import { createContext, useContext } from 'react';
 
+export type HashRouterConfig = {
+  enabled?: boolean;
+  basename?: string;
+};
+
 export type ClientConfig = {
   defaultHomeserver?: number;
   homeserverList?: string[];
   allowCustomHomeservers?: boolean;
 
-  hashRouter?: {
-    enabled?: boolean;
-    basename?: string;
+  featuredCommunities?: {
+    openAsDefault?: boolean;
+    spaces?: string[];
+    rooms?: string[];
+    servers?: string[];
   };
+
+  hashRouter?: HashRouterConfig;
 };
 
 const ClientConfigContext = createContext<ClientConfig | null>(null);
index ad464c63d66a3b7a3645e45f8918049287635477..aadbf5348ba5998897fd521ac51be6a98a80a093 100644 (file)
@@ -1,9 +1,9 @@
 import { MatrixClient, Room } from 'matrix-js-sdk';
 import { useMemo } from 'react';
-import { hasDMWith, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
-import { selectRoom } from '../../client/action/navigation';
+import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
 import { hasDevices } from '../../util/matrixUtil';
 import * as roomActions from '../../client/action/room';
+import { useRoomNavigate } from './useRoomNavigate';
 
 export const SHRUG = '¯\\_(ツ)_/¯';
 
@@ -59,6 +59,8 @@ export type CommandContent = {
 export type CommandRecord = Record<Command, CommandContent>;
 
 export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
+  const { navigateRoom } = useRoomNavigate();
+
   const commands: CommandRecord = useMemo(
     () => ({
       [Command.Me]: {
@@ -84,16 +86,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
           const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
           if (userIds.length === 0) return;
           if (userIds.length === 1) {
-            const dmRoomId = hasDMWith(mx, userIds[0]);
+            const dmRoomId = getDMRoomFor(mx, userIds[0])?.roomId;
             if (dmRoomId) {
-              selectRoom(dmRoomId);
+              navigateRoom(dmRoomId);
               return;
             }
           }
           const devices = await Promise.all(userIds.map(hasDevices));
           const isEncrypt = devices.every((hasDevice) => hasDevice);
           const result = await roomActions.createDM(userIds, isEncrypt);
-          selectRoom(result.room_id);
+          navigateRoom(result.room_id);
         },
       },
       [Command.Join]: {
@@ -212,7 +214,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
         },
       },
     }),
-    [mx, room]
+    [mx, room, navigateRoom]
   );
 
   return commands;
diff --git a/src/app/hooks/useElementSizeObserver.ts b/src/app/hooks/useElementSizeObserver.ts
new file mode 100644 (file)
index 0000000..da563c1
--- /dev/null
@@ -0,0 +1,23 @@
+import { useCallback } from 'react';
+import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
+
+export const useElementSizeObserver = <T extends Element>(
+  element: () => T | null,
+  onResize: (width: number, height: number, element: T) => void
+) => {
+  useResizeObserver(
+    useCallback(
+      (entries) => {
+        const target = element();
+        if (!target) return;
+        const targetEntry = getResizeObserverEntry(target, entries);
+        if (targetEntry) {
+          const { clientWidth, clientHeight } = targetEntry.target;
+          onResize(clientWidth, clientHeight, target);
+        }
+      },
+      [element, onResize]
+    ),
+    element
+  );
+};
diff --git a/src/app/hooks/useInterval.ts b/src/app/hooks/useInterval.ts
new file mode 100644 (file)
index 0000000..161af39
--- /dev/null
@@ -0,0 +1,24 @@
+import { useEffect, useMemo } from 'react';
+
+export type IntervalCallback = () => void;
+
+/**
+ * @param callback interval callback.
+ * @param ms interval time in milliseconds. negative value will stop the interval.
+ * @returns interval id or undefined if not running.
+ */
+export const useInterval = (callback: IntervalCallback, ms: number): number | undefined => {
+  const id = useMemo(() => {
+    if (ms < 0) return undefined;
+    return window.setInterval(callback, ms);
+  }, [callback, ms]);
+
+  useEffect(
+    () => () => {
+      window.clearInterval(id);
+    },
+    [id]
+  );
+
+  return id;
+};
diff --git a/src/app/hooks/useJoinedRoomId.ts b/src/app/hooks/useJoinedRoomId.ts
new file mode 100644 (file)
index 0000000..857defa
--- /dev/null
@@ -0,0 +1,19 @@
+import { useMemo } from 'react';
+import { useMatrixClient } from './useMatrixClient';
+
+import { getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix';
+
+export const useJoinedRoomId = (allRooms: string[], roomIdOrAlias: string): string | undefined => {
+  const mx = useMatrixClient();
+
+  const joinedRoomId = useMemo(() => {
+    const roomId = isRoomAlias(roomIdOrAlias)
+      ? getCanonicalAliasRoomId(mx, roomIdOrAlias)
+      : roomIdOrAlias;
+
+    if (roomId && allRooms.includes(roomId)) return roomId;
+    return undefined;
+  }, [mx, allRooms, roomIdOrAlias]);
+
+  return joinedRoomId;
+};
diff --git a/src/app/hooks/useLocalRoomSummary.ts b/src/app/hooks/useLocalRoomSummary.ts
new file mode 100644 (file)
index 0000000..46f8156
--- /dev/null
@@ -0,0 +1,44 @@
+import { GuestAccess, HistoryVisibility, JoinRule, Room } from 'matrix-js-sdk';
+import { getStateEvent } from '../utils/room';
+import { StateEvent } from '../../types/matrix/room';
+
+export type LocalRoomSummary = {
+  roomId: string;
+  name: string;
+  topic?: string;
+  avatarUrl?: string;
+  canonicalAlias?: string;
+  worldReadable?: boolean;
+  guestCanJoin?: boolean;
+  memberCount?: number;
+  roomType?: string;
+  joinRule?: JoinRule;
+};
+export const useLocalRoomSummary = (room: Room): LocalRoomSummary => {
+  const topicEvent = getStateEvent(room, StateEvent.RoomTopic);
+  const topicContent = topicEvent?.getContent();
+  const topic =
+    topicContent && typeof topicContent.topic === 'string' ? topicContent.topic : undefined;
+
+  const historyEvent = getStateEvent(room, StateEvent.RoomHistoryVisibility);
+  const historyContent = historyEvent?.getContent();
+  const worldReadable =
+    historyContent && typeof historyContent.history_visibility === 'string'
+      ? historyContent.history_visibility === HistoryVisibility.WorldReadable
+      : undefined;
+
+  const guestCanJoin = room.getGuestAccess() === GuestAccess.CanJoin;
+
+  return {
+    roomId: room.roomId,
+    name: room.name,
+    topic,
+    avatarUrl: room.getMxcAvatarUrl() ?? undefined,
+    canonicalAlias: room.getCanonicalAlias() ?? undefined,
+    worldReadable,
+    guestCanJoin,
+    memberCount: room.getJoinedMemberCount(),
+    roomType: room.getType(),
+    joinRule: room.getJoinRule(),
+  };
+};
index 9ace6b66a1aad80a9f9fdc3b26abe07ba88d6a8a..57d56cc1c5c0044ab1c9e94b50207f4894bbe394 100644 (file)
@@ -1,80 +1,31 @@
 import { ReactNode } from 'react';
-import { MatrixEvent } from 'matrix-js-sdk';
-import { MessageEvent, StateEvent } from '../../types/matrix/room';
 
-export type EventRenderer<T extends unknown[]> = (
-  eventId: string,
-  mEvent: MatrixEvent,
-  ...args: T
-) => ReactNode;
+export type EventRenderer<T extends unknown[]> = (...args: T) => ReactNode;
 
-export type EventRendererOpts<T extends unknown[]> = {
-  renderRoomMessage?: EventRenderer<T>;
-  renderRoomEncrypted?: EventRenderer<T>;
-  renderSticker?: EventRenderer<T>;
-  renderRoomMember?: EventRenderer<T>;
-  renderRoomName?: EventRenderer<T>;
-  renderRoomTopic?: EventRenderer<T>;
-  renderRoomAvatar?: EventRenderer<T>;
-  renderStateEvent?: EventRenderer<T>;
-  renderEvent?: EventRenderer<T>;
-};
+export type EventRendererOpts<T extends unknown[]> = Record<string, EventRenderer<T>>;
 
 export type RenderMatrixEvent<T extends unknown[]> = (
-  eventId: string,
-  mEvent: MatrixEvent,
+  eventType: string,
+  isStateEvent: boolean,
   ...args: T
 ) => ReactNode;
 
 export const useMatrixEventRenderer =
-  <T extends unknown[]>({
-    renderRoomMessage,
-    renderRoomEncrypted,
-    renderSticker,
-    renderRoomMember,
-    renderRoomName,
-    renderRoomTopic,
-    renderRoomAvatar,
-    renderStateEvent,
-    renderEvent,
-  }: EventRendererOpts<T>): RenderMatrixEvent<T> =>
-  (eventId, mEvent, ...args) => {
-    const eventType = mEvent.getWireType();
-
-    if (eventType === MessageEvent.RoomMessage && renderRoomMessage) {
-      return renderRoomMessage(eventId, mEvent, ...args);
-    }
-
-    if (eventType === MessageEvent.RoomMessageEncrypted && renderRoomEncrypted) {
-      return renderRoomEncrypted(eventId, mEvent, ...args);
-    }
-
-    if (eventType === MessageEvent.Sticker && renderSticker) {
-      return renderSticker(eventId, mEvent, ...args);
-    }
-
-    if (eventType === StateEvent.RoomMember && renderRoomMember) {
-      return renderRoomMember(eventId, mEvent, ...args);
-    }
-
-    if (eventType === StateEvent.RoomName && renderRoomName) {
-      return renderRoomName(eventId, mEvent, ...args);
-    }
-
-    if (eventType === StateEvent.RoomTopic && renderRoomTopic) {
-      return renderRoomTopic(eventId, mEvent, ...args);
-    }
-
-    if (eventType === StateEvent.RoomAvatar && renderRoomAvatar) {
-      return renderRoomAvatar(eventId, mEvent, ...args);
-    }
+  <T extends unknown[]>(
+    typeToRenderer: EventRendererOpts<T>,
+    renderStateEvent?: EventRenderer<T>,
+    renderEvent?: EventRenderer<T>
+  ): RenderMatrixEvent<T> =>
+  (eventType, isStateEvent, ...args) => {
+    const renderer = typeToRenderer[eventType];
+    if (typeToRenderer[eventType]) return renderer(...args);
 
-    if (typeof mEvent.getStateKey() === 'string' && renderStateEvent) {
-      return renderStateEvent(eventId, mEvent, ...args);
+    if (isStateEvent && renderStateEvent) {
+      return renderStateEvent(...args);
     }
 
-    if (typeof mEvent.getStateKey() !== 'string' && renderEvent) {
-      return renderEvent(eventId, mEvent, ...args);
+    if (!isStateEvent && renderEvent) {
+      return renderEvent(...args);
     }
     return null;
   };
diff --git a/src/app/hooks/useMediaConfig.ts b/src/app/hooks/useMediaConfig.ts
new file mode 100644 (file)
index 0000000..929ebd0
--- /dev/null
@@ -0,0 +1,16 @@
+import { createContext, useContext } from 'react';
+
+export interface MediaConfig {
+  [key: string]: unknown;
+  'm.upload.size'?: number;
+}
+
+const MediaConfigContext = createContext<MediaConfig | null>(null);
+
+export const MediaConfigProvider = MediaConfigContext.Provider;
+
+export function useMediaConfig(): MediaConfig {
+  const mediaConfig = useContext(MediaConfigContext);
+  if (!mediaConfig) throw new Error('Media configs are not provided!');
+  return mediaConfig;
+}
diff --git a/src/app/hooks/useNavToActivePathMapper.ts b/src/app/hooks/useNavToActivePathMapper.ts
new file mode 100644 (file)
index 0000000..d3cec08
--- /dev/null
@@ -0,0 +1,18 @@
+import { useEffect } from 'react';
+import { useSetAtom } from 'jotai';
+import { useLocation } from 'react-router-dom';
+import { useNavToActivePathAtom } from '../state/hooks/navToActivePath';
+
+export const useNavToActivePathMapper = (navId: string) => {
+  const location = useLocation();
+  const setNavToActivePath = useSetAtom(useNavToActivePathAtom());
+
+  useEffect(() => {
+    const { pathname, search, hash } = location;
+    setNavToActivePath({
+      type: 'PUT',
+      navId,
+      path: { pathname, search, hash },
+    });
+  }, [location, setNavToActivePath, navId]);
+};
index 98e3701edef386e6a86cf677b9ba3a5c208d3060..0fcbbd33052c89596e38df76a79b4317cfa38277 100644 (file)
@@ -1,7 +1,11 @@
 import { Room } from 'matrix-js-sdk';
-import { createContext, useCallback, useContext } from 'react';
+import { createContext, useCallback, useContext, useMemo } from 'react';
 import { useStateEvent } from './useStateEvent';
 import { StateEvent } from '../../types/matrix/room';
+import { useForceUpdate } from './useForceUpdate';
+import { useStateEventCallback } from './useStateEventCallback';
+import { useMatrixClient } from './useMatrixClient';
+import { getStateEvent } from '../utils/room';
 
 export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
 
@@ -16,7 +20,7 @@ enum DefaultPowerLevels {
   historical = 0,
 }
 
-interface IPowerLevels {
+export interface IPowerLevels {
   users_default?: number;
   state_default?: number;
   events_default?: number;
@@ -31,9 +35,75 @@ interface IPowerLevels {
   notifications?: Record<string, number>;
 }
 
-export type GetPowerLevel = (userId: string) => number;
-export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
-export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
+export function usePowerLevels(room: Room): IPowerLevels {
+  const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
+  const powerLevels: IPowerLevels =
+    powerLevelsEvent?.getContent<IPowerLevels>() ?? DefaultPowerLevels;
+
+  return powerLevels;
+}
+
+export const PowerLevelsContext = createContext<IPowerLevels | null>(null);
+
+export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
+
+export const usePowerLevelsContext = (): IPowerLevels => {
+  const pl = useContext(PowerLevelsContext);
+  if (!pl) throw new Error('PowerLevelContext is not initialized!');
+  return pl;
+};
+
+export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
+  const mx = useMatrixClient();
+  const [updateCount, forceUpdate] = useForceUpdate();
+
+  useStateEventCallback(
+    mx,
+    useCallback(
+      (event) => {
+        const roomId = event.getRoomId();
+        if (
+          roomId &&
+          event.getType() === StateEvent.RoomPowerLevels &&
+          event.getStateKey() === '' &&
+          rooms.find((r) => r.roomId === roomId)
+        ) {
+          forceUpdate();
+        }
+      },
+      [rooms, forceUpdate]
+    )
+  );
+
+  const roomToPowerLevels = useMemo(
+    () => {
+      const rToPl = new Map<string, IPowerLevels>();
+
+      rooms.forEach((room) => {
+        const pl = getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent<IPowerLevels>();
+        if (pl) rToPl.set(room.roomId, pl);
+      });
+
+      return rToPl;
+    },
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [rooms, updateCount]
+  );
+
+  return roomToPowerLevels;
+};
+
+export type GetPowerLevel = (powerLevels: IPowerLevels, userId: string | undefined) => number;
+export type CanSend = (
+  powerLevels: IPowerLevels,
+  eventType: string | undefined,
+  powerLevel: number
+) => boolean;
+export type CanDoAction = (
+  powerLevels: IPowerLevels,
+  action: PowerLevelActions,
+  powerLevel: number
+) => boolean;
 
 export type PowerLevelsAPI = {
   getPowerLevel: GetPowerLevel;
@@ -42,51 +112,58 @@ export type PowerLevelsAPI = {
   canDoAction: CanDoAction;
 };
 
-export function usePowerLevels(room: Room): PowerLevelsAPI {
-  const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
-  const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
-
-  const getPowerLevel: GetPowerLevel = useCallback(
-    (userId) => {
-      const { users_default: usersDefault, users } = powerLevels;
-      if (users && typeof users[userId] === 'number') {
-        return users[userId];
-      }
-      return usersDefault ?? DefaultPowerLevels.usersDefault;
-    },
+export const powerLevelAPI: PowerLevelsAPI = {
+  getPowerLevel: (powerLevels, userId) => {
+    const { users_default: usersDefault, users } = powerLevels;
+    if (userId && users && typeof users[userId] === 'number') {
+      return users[userId];
+    }
+    return usersDefault ?? DefaultPowerLevels.usersDefault;
+  },
+  canSendEvent: (powerLevels, eventType, powerLevel) => {
+    const { events, events_default: eventsDefault } = powerLevels;
+    if (events && eventType && typeof events[eventType] === 'number') {
+      return powerLevel >= events[eventType];
+    }
+    return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
+  },
+  canSendStateEvent: (powerLevels, eventType, powerLevel) => {
+    const { events, state_default: stateDefault } = powerLevels;
+    if (events && eventType && typeof events[eventType] === 'number') {
+      return powerLevel >= events[eventType];
+    }
+    return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
+  },
+  canDoAction: (powerLevels, action, powerLevel) => {
+    const requiredPL = powerLevels[action];
+    if (typeof requiredPL === 'number') {
+      return powerLevel >= requiredPL;
+    }
+    return powerLevel >= DefaultPowerLevels[action];
+  },
+};
+
+export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
+  const getPowerLevel = useCallback(
+    (userId: string | undefined) => powerLevelAPI.getPowerLevel(powerLevels, userId),
     [powerLevels]
   );
 
-  const canSendEvent: CanSend = useCallback(
-    (eventType, powerLevel) => {
-      const { events, events_default: eventsDefault } = powerLevels;
-      if (events && eventType && typeof events[eventType] === 'number') {
-        return powerLevel >= events[eventType];
-      }
-      return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
-    },
+  const canSendEvent = useCallback(
+    (eventType: string | undefined, powerLevel: number) =>
+      powerLevelAPI.canSendEvent(powerLevels, eventType, powerLevel),
     [powerLevels]
   );
 
-  const canSendStateEvent: CanSend = useCallback(
-    (eventType, powerLevel) => {
-      const { events, state_default: stateDefault } = powerLevels;
-      if (events && eventType && typeof events[eventType] === 'number') {
-        return powerLevel >= events[eventType];
-      }
-      return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
-    },
+  const canSendStateEvent = useCallback(
+    (eventType: string | undefined, powerLevel: number) =>
+      powerLevelAPI.canSendStateEvent(powerLevels, eventType, powerLevel),
     [powerLevels]
   );
 
-  const canDoAction: CanDoAction = useCallback(
-    (action, powerLevel) => {
-      const requiredPL = powerLevels[action];
-      if (typeof requiredPL === 'number') {
-        return powerLevel >= requiredPL;
-      }
-      return powerLevel >= DefaultPowerLevels[action];
-    },
+  const canDoAction = useCallback(
+    (action: PowerLevelActions, powerLevel: number) =>
+      powerLevelAPI.canDoAction(powerLevels, action, powerLevel),
     [powerLevels]
   );
 
@@ -96,14 +173,4 @@ export function usePowerLevels(room: Room): PowerLevelsAPI {
     canSendStateEvent,
     canDoAction,
   };
-}
-
-export const PowerLevelsContext = createContext<PowerLevelsAPI | null>(null);
-
-export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
-
-export const usePowerLevelsAPI = (): PowerLevelsAPI => {
-  const api = useContext(PowerLevelsContext);
-  if (!api) throw new Error('PowerLevelContext is not initialized!');
-  return api;
 };
diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts
new file mode 100644 (file)
index 0000000..3f802d4
--- /dev/null
@@ -0,0 +1,12 @@
+import { Room } from 'matrix-js-sdk';
+import { createContext, useContext } from 'react';
+
+const RoomContext = createContext<Room | null>(null);
+
+export const RoomProvider = RoomContext.Provider;
+
+export function useRoom(): Room {
+  const room = useContext(RoomContext);
+  if (!room) throw new Error('Room not provided!');
+  return room;
+}
diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts
new file mode 100644 (file)
index 0000000..5124883
--- /dev/null
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react';
+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { StateEvent } from '../../types/matrix/room';
+import { useStateEvent } from './useStateEvent';
+
+export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
+  const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
+
+  if (dm) {
+    return room.getAvatarFallbackMember()?.getMxcAvatarUrl();
+  }
+  const content = avatarEvent?.getContent();
+  const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined;
+
+  return avatarMxc;
+};
+
+export const useRoomName = (room: Room): string => {
+  const [name, setName] = useState(room.name);
+
+  useEffect(() => {
+    const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
+      setName(room.name);
+    };
+    room.on(RoomEvent.Name, handleRoomNameChange);
+    return () => {
+      room.removeListener(RoomEvent.Name, handleRoomNameChange);
+    };
+  }, [room]);
+
+  return name;
+};
+
+export const useRoomTopic = (room: Room): string | undefined => {
+  const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
+
+  const content = topicEvent?.getContent();
+  const topic = content && typeof content.topic === 'string' ? content.topic : undefined;
+
+  return topic;
+};
diff --git a/src/app/hooks/useRoomMsgContentRenderer.ts b/src/app/hooks/useRoomMsgContentRenderer.ts
deleted file mode 100644 (file)
index b014249..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-import { ReactNode } from 'react';
-import { MatrixEvent, MsgType } from 'matrix-js-sdk';
-
-export type MsgContentRenderer<T extends unknown[]> = (
-  eventId: string,
-  mEvent: MatrixEvent,
-  ...args: T
-) => ReactNode;
-
-export type RoomMsgContentRendererOpts<T extends unknown[]> = {
-  renderText?: MsgContentRenderer<T>;
-  renderEmote?: MsgContentRenderer<T>;
-  renderNotice?: MsgContentRenderer<T>;
-  renderImage?: MsgContentRenderer<T>;
-  renderVideo?: MsgContentRenderer<T>;
-  renderAudio?: MsgContentRenderer<T>;
-  renderFile?: MsgContentRenderer<T>;
-  renderLocation?: MsgContentRenderer<T>;
-  renderBadEncrypted?: MsgContentRenderer<T>;
-  renderUnsupported?: MsgContentRenderer<T>;
-  renderBrokenFallback?: MsgContentRenderer<T>;
-};
-
-export type RenderRoomMsgContent<T extends unknown[]> = (
-  eventId: string,
-  mEvent: MatrixEvent,
-  ...args: T
-) => ReactNode;
-
-export const useRoomMsgContentRenderer =
-  <T extends unknown[]>({
-    renderText,
-    renderEmote,
-    renderNotice,
-    renderImage,
-    renderVideo,
-    renderAudio,
-    renderFile,
-    renderLocation,
-    renderBadEncrypted,
-    renderUnsupported,
-    renderBrokenFallback,
-  }: RoomMsgContentRendererOpts<T>): RenderRoomMsgContent<T> =>
-  (eventId, mEvent, ...args) => {
-    const msgType = mEvent.getContent().msgtype;
-
-    let node: ReactNode = null;
-
-    if (msgType === MsgType.Text && renderText) node = renderText(eventId, mEvent, ...args);
-    else if (msgType === MsgType.Emote && renderEmote) node = renderEmote(eventId, mEvent, ...args);
-    else if (msgType === MsgType.Notice && renderNotice)
-      node = renderNotice(eventId, mEvent, ...args);
-    else if (msgType === MsgType.Image && renderImage) node = renderImage(eventId, mEvent, ...args);
-    else if (msgType === MsgType.Video && renderVideo) node = renderVideo(eventId, mEvent, ...args);
-    else if (msgType === MsgType.Audio && renderAudio) node = renderAudio(eventId, mEvent, ...args);
-    else if (msgType === MsgType.File && renderFile) node = renderFile(eventId, mEvent, ...args);
-    else if (msgType === MsgType.Location && renderLocation)
-      node = renderLocation(eventId, mEvent, ...args);
-    else if (msgType === 'm.bad.encrypted' && renderBadEncrypted)
-      node = renderBadEncrypted(eventId, mEvent, ...args);
-    else if (renderUnsupported) {
-      node = renderUnsupported(eventId, mEvent, ...args);
-    }
-
-    if (!node && renderBrokenFallback) node = renderBrokenFallback(eventId, mEvent, ...args);
-
-    return node;
-  };
diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts
new file mode 100644 (file)
index 0000000..55528e7
--- /dev/null
@@ -0,0 +1,55 @@
+import { useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { getCanonicalAliasOrRoomId } from '../utils/matrix';
+import {
+  getDirectRoomPath,
+  getHomeRoomPath,
+  getSpacePath,
+  getSpaceRoomPath,
+} from '../pages/pathUtils';
+import { useMatrixClient } from './useMatrixClient';
+import { getOrphanParents } from '../utils/room';
+import { roomToParentsAtom } from '../state/room/roomToParents';
+import { mDirectAtom } from '../state/mDirectList';
+
+export const useRoomNavigate = () => {
+  const navigate = useNavigate();
+  const mx = useMatrixClient();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const mDirects = useAtomValue(mDirectAtom);
+
+  const navigateSpace = useCallback(
+    (roomId: string) => {
+      const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
+      navigate(getSpacePath(roomIdOrAlias));
+    },
+    [mx, navigate]
+  );
+
+  const navigateRoom = useCallback(
+    (roomId: string, eventId?: string) => {
+      const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
+
+      const orphanParents = getOrphanParents(roomToParents, roomId);
+      if (orphanParents.length > 0) {
+        const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, orphanParents[0]);
+        navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId));
+        return;
+      }
+
+      if (mDirects.has(roomId)) {
+        navigate(getDirectRoomPath(roomIdOrAlias, eventId));
+        return;
+      }
+
+      navigate(getHomeRoomPath(roomIdOrAlias, eventId));
+    },
+    [mx, navigate, roomToParents, mDirects]
+  );
+
+  return {
+    navigateSpace,
+    navigateRoom,
+  };
+};
diff --git a/src/app/hooks/useRoomTypingMembers.ts b/src/app/hooks/useRoomTypingMembers.ts
new file mode 100644 (file)
index 0000000..5f24fb5
--- /dev/null
@@ -0,0 +1,10 @@
+import { useAtomValue } from 'jotai';
+import { useMemo } from 'react';
+import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../state/typingMembers';
+
+export const useRoomTypingMember = (roomId: string) => {
+  const typing = useAtomValue(
+    useMemo(() => selectRoomTypingMembersAtom(roomId, roomIdToTypingMembersAtom), [roomId])
+  );
+  return typing;
+};
index 4afe90832966bc45c42367ce0329e52b54005d69..3e64528d653dbdbafdfeafe67c2a5822b12d10a2 100644 (file)
@@ -1,5 +1,5 @@
-import { useCallback, useState } from 'react';
-import { getResizeObserverEntry, useResizeObserver } from './useResizeObserver';
+import { createContext, useCallback, useContext, useState } from 'react';
+import { useElementSizeObserver } from './useElementSizeObserver';
 
 export const TABLET_BREAKPOINT = 1124;
 export const MOBILE_BREAKPOINT = 750;
@@ -16,21 +16,24 @@ export const getScreenSize = (width: number): ScreenSize => {
   return ScreenSize.Mobile;
 };
 
-export const useScreenSize = (): [ScreenSize, number] => {
-  const [size, setSize] = useState<[ScreenSize, number]>([
-    getScreenSize(document.body.clientWidth),
-    document.body.clientWidth,
-  ]);
-  useResizeObserver(
-    useCallback((entries) => {
-      const bodyEntry = getResizeObserverEntry(document.body, entries);
-      if (bodyEntry) {
-        const bWidth = bodyEntry.contentRect.width;
-        setSize([getScreenSize(bWidth), bWidth]);
-      }
-    }, []),
-    document.body
+export const useScreenSize = (): ScreenSize => {
+  const [size, setSize] = useState<ScreenSize>(getScreenSize(document.body.clientWidth));
+
+  useElementSizeObserver(
+    useCallback(() => document.body, []),
+    useCallback((width) => setSize(getScreenSize(width)), [])
   );
 
   return size;
 };
+
+const ScreenSizeContext = createContext<ScreenSize | null>(null);
+export const ScreenSizeProvider = ScreenSizeContext.Provider;
+
+export const useScreenSizeContext = (): ScreenSize => {
+  const screenSize = useContext(ScreenSizeContext);
+  if (screenSize === null) {
+    throw new Error('Screen size not provided!');
+  }
+  return screenSize;
+};
diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts
new file mode 100644 (file)
index 0000000..16aba22
--- /dev/null
@@ -0,0 +1,138 @@
+import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+import { AccountDataEvent } from '../../types/matrix/accountData';
+import { useMatrixClient } from './useMatrixClient';
+import { getAccountData, isSpace } from '../utils/room';
+import { Membership } from '../../types/matrix/room';
+import { useAccountDataCallback } from './useAccountDataCallback';
+
+export type ISidebarFolder = {
+  name?: string;
+  id: string;
+  content: string[];
+};
+export type TSidebarItem = string | ISidebarFolder;
+export type SidebarItems = Array<TSidebarItem>;
+
+export type InCinnySpacesContent = {
+  shortcut?: string[];
+  sidebar?: SidebarItems;
+};
+
+export const parseSidebar = (
+  mx: MatrixClient,
+  orphanSpaces: string[],
+  content?: InCinnySpacesContent
+) => {
+  const sidebar = content?.sidebar ?? content?.shortcut ?? [];
+  const orphans = new Set(orphanSpaces);
+
+  const items: SidebarItems = [];
+
+  const safeToAdd = (spaceId: string): boolean => {
+    if (typeof spaceId !== 'string') return false;
+    const space = mx.getRoom(spaceId);
+    if (space?.getMyMembership() !== Membership.Join) return false;
+    return isSpace(space);
+  };
+
+  sidebar.forEach((item) => {
+    if (typeof item === 'string') {
+      if (safeToAdd(item) && !items.includes(item)) {
+        orphans.delete(item);
+        items.push(item);
+      }
+      return;
+    }
+    if (
+      typeof item === 'object' &&
+      typeof item.id === 'string' &&
+      Array.isArray(item.content) &&
+      !items.find((i) => (typeof i === 'string' ? false : i.id === item.id))
+    ) {
+      const safeContent = item.content.filter(safeToAdd);
+      safeContent.forEach((i) => orphans.delete(i));
+      items.push({
+        ...item,
+        content: Array.from(new Set(safeContent)),
+      });
+    }
+  });
+
+  orphans.forEach((spaceId) => items.push(spaceId));
+  return items;
+};
+
+export const useSidebarItems = (
+  orphanSpaces: string[]
+): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
+  const mx = useMatrixClient();
+
+  const [sidebarItems, setSidebarItems] = useState(() => {
+    const inCinnySpacesContent = getAccountData(
+      mx,
+      AccountDataEvent.CinnySpaces
+    )?.getContent<InCinnySpacesContent>();
+    return parseSidebar(mx, orphanSpaces, inCinnySpacesContent);
+  });
+
+  useEffect(() => {
+    const inCinnySpacesContent = getAccountData(
+      mx,
+      AccountDataEvent.CinnySpaces
+    )?.getContent<InCinnySpacesContent>();
+    setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent));
+  }, [mx, orphanSpaces]);
+
+  useAccountDataCallback(
+    mx,
+    useCallback(
+      (mEvent) => {
+        if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
+          const newContent = mEvent.getContent<InCinnySpacesContent>();
+          setSidebarItems(parseSidebar(mx, orphanSpaces, newContent));
+        }
+      },
+      [mx, orphanSpaces]
+    )
+  );
+
+  return [sidebarItems, setSidebarItems];
+};
+
+export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
+  const newItems: SidebarItems = items
+    .map((item) => {
+      if (typeof item === 'string') {
+        if (item === roomId) return null;
+        return item;
+      }
+      if (item.content.includes(roomId)) {
+        const newContent = item.content.filter((id) => id !== roomId);
+        if (newContent.length === 0) return null;
+        return {
+          ...item,
+          content: newContent,
+        };
+      }
+      return item;
+    })
+    .filter((item) => item !== null) as SidebarItems;
+
+  return newItems;
+};
+
+export const makeCinnySpacesContent = (
+  mx: MatrixClient,
+  items: SidebarItems
+): InCinnySpacesContent => {
+  const currentInSpaces =
+    getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
+
+  const newSpacesContent: InCinnySpacesContent = {
+    ...currentInSpaces,
+    sidebar: items,
+  };
+
+  return newSpacesContent;
+};
diff --git a/src/app/hooks/useSpace.ts b/src/app/hooks/useSpace.ts
new file mode 100644 (file)
index 0000000..8bec49e
--- /dev/null
@@ -0,0 +1,17 @@
+import { Room } from 'matrix-js-sdk';
+import { createContext, useContext } from 'react';
+
+const SpaceContext = createContext<Room | null>(null);
+
+export const SpaceProvider = SpaceContext.Provider;
+
+export function useSpace(): Room {
+  const space = useContext(SpaceContext);
+  if (!space) throw new Error('Space not provided!');
+  return space;
+}
+
+export function useSpaceOptionally(): Room | null {
+  const space = useContext(SpaceContext);
+  return space;
+}
diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts
new file mode 100644 (file)
index 0000000..c109cc2
--- /dev/null
@@ -0,0 +1,253 @@
+import { atom, useAtom, useAtomValue } from 'jotai';
+import { useCallback, useEffect, useState } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { useMatrixClient } from './useMatrixClient';
+import { roomToParentsAtom } from '../state/room/roomToParents';
+import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
+import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
+import { isRoomId } from '../utils/matrix';
+import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
+import { useStateEventCallback } from './useStateEventCallback';
+
+export type HierarchyItem =
+  | {
+      roomId: string;
+      content: MSpaceChildContent;
+      ts: number;
+      space: true;
+      parentId?: string;
+    }
+  | {
+      roomId: string;
+      content: MSpaceChildContent;
+      ts: number;
+      space?: false;
+      parentId: string;
+    };
+
+type GetRoomCallback = (roomId: string) => Room | undefined;
+
+const hierarchyItemTs: SortFunc<HierarchyItem> = (a, b) => byTsOldToNew(a.ts, b.ts);
+const hierarchyItemByOrder: SortFunc<HierarchyItem> = (a, b) =>
+  byOrderKey(a.content.order, b.content.order);
+
+const getHierarchySpaces = (
+  rootSpaceId: string,
+  getRoom: GetRoomCallback,
+  spaceRooms: Set<string>
+): HierarchyItem[] => {
+  const rootSpaceItem: HierarchyItem = {
+    roomId: rootSpaceId,
+    content: { via: [] },
+    ts: 0,
+    space: true,
+  };
+  let spaceItems: HierarchyItem[] = [];
+
+  const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => {
+    if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return;
+    const space = getRoom(spaceItem.roomId);
+    spaceItems.push(spaceItem);
+
+    if (!space) return;
+    const childEvents = getStateEvents(space, StateEvent.SpaceChild);
+
+    childEvents.forEach((childEvent) => {
+      if (!isValidChild(childEvent)) return;
+      const childId = childEvent.getStateKey();
+      if (!childId || !isRoomId(childId)) return;
+
+      // because we can not find if a childId is space without joining
+      // 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 = {
+          roomId: childId,
+          content: childEvent.getContent<MSpaceChildContent>(),
+          ts: childEvent.getTs(),
+          space: true,
+          parentId: spaceItem.roomId,
+        };
+        findAndCollectHierarchySpaces(childItem);
+      }
+    });
+  };
+  findAndCollectHierarchySpaces(rootSpaceItem);
+
+  spaceItems = [
+    rootSpaceItem,
+    ...spaceItems
+      .filter((item) => item.roomId !== rootSpaceId)
+      .sort(hierarchyItemTs)
+      .sort(hierarchyItemByOrder),
+  ];
+
+  return spaceItems;
+};
+
+const getSpaceHierarchy = (
+  rootSpaceId: string,
+  spaceRooms: Set<string>,
+  getRoom: (roomId: string) => Room | undefined,
+  closedCategory: (spaceId: string) => boolean
+): HierarchyItem[] => {
+  const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
+
+  const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
+    const space = getRoom(spaceItem.roomId);
+    if (!space || closedCategory(spaceItem.roomId)) {
+      return [spaceItem];
+    }
+    const childEvents = getStateEvents(space, StateEvent.SpaceChild);
+    const childItems: HierarchyItem[] = [];
+    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 = {
+        roomId: childId,
+        content: childEvent.getContent<MSpaceChildContent>(),
+        ts: childEvent.getTs(),
+        parentId: spaceItem.roomId,
+      };
+      childItems.push(childItem);
+    });
+    return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
+  });
+
+  return hierarchy;
+};
+
+export const useSpaceHierarchy = (
+  spaceId: string,
+  spaceRooms: Set<string>,
+  getRoom: (roomId: string) => Room | undefined,
+  closedCategory: (spaceId: string) => boolean
+): HierarchyItem[] => {
+  const mx = useMatrixClient();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+
+  const [hierarchyAtom] = useState(() =>
+    atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory))
+  );
+  const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
+
+  useEffect(() => {
+    setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory));
+  }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory]);
+
+  useStateEventCallback(
+    mx,
+    useCallback(
+      (mEvent) => {
+        if (mEvent.getType() !== StateEvent.SpaceChild) return;
+        const eventRoomId = mEvent.getRoomId();
+        if (!eventRoomId) return;
+
+        if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
+          setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory));
+        }
+      },
+      [spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory]
+    )
+  );
+
+  return hierarchy;
+};
+
+const getSpaceJoinedHierarchy = (
+  rootSpaceId: string,
+  getRoom: GetRoomCallback,
+  excludeRoom: (parentId: string, roomId: string) => boolean,
+  sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[]
+): HierarchyItem[] => {
+  const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
+
+  const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
+    const space = getRoom(spaceItem.roomId);
+    if (!space) {
+      return [];
+    }
+    const joinedRoomEvents = getStateEvents(space, StateEvent.SpaceChild).filter((childEvent) => {
+      if (!isValidChild(childEvent)) return false;
+      const childId = childEvent.getStateKey();
+      if (!childId || !isRoomId(childId)) return false;
+      const room = getRoom(childId);
+      if (!room || room.isSpaceRoom()) return false;
+
+      return true;
+    });
+
+    if (joinedRoomEvents.length === 0) return [];
+
+    const childItems: HierarchyItem[] = [];
+    joinedRoomEvents.forEach((childEvent) => {
+      const childId = childEvent.getStateKey();
+      if (!childId) return;
+
+      if (excludeRoom(space.roomId, childId)) return;
+
+      const childItem: HierarchyItem = {
+        roomId: childId,
+        content: childEvent.getContent<MSpaceChildContent>(),
+        ts: childEvent.getTs(),
+        parentId: spaceItem.roomId,
+      };
+      childItems.push(childItem);
+    });
+    return [spaceItem, ...sortRoomItems(spaceItem.roomId, childItems)];
+  });
+
+  return hierarchy;
+};
+
+export const useSpaceJoinedHierarchy = (
+  spaceId: string,
+  getRoom: GetRoomCallback,
+  excludeRoom: (parentId: string, roomId: string) => boolean,
+  sortByActivity: (spaceId: string) => boolean
+): HierarchyItem[] => {
+  const mx = useMatrixClient();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+
+  const sortRoomItems = useCallback(
+    (sId: string, items: HierarchyItem[]) => {
+      if (sortByActivity(sId)) {
+        items.sort((a, b) => factoryRoomIdByActivity(mx)(a.roomId, b.roomId));
+        return items;
+      }
+      items.sort(hierarchyItemTs).sort(hierarchyItemByOrder);
+      return items;
+    },
+    [mx, sortByActivity]
+  );
+
+  const [hierarchyAtom] = useState(() =>
+    atom(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems))
+  );
+  const [hierarchy, setHierarchy] = useAtom(hierarchyAtom);
+
+  useEffect(() => {
+    setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
+  }, [mx, spaceId, setHierarchy, getRoom, excludeRoom, sortRoomItems]);
+
+  useStateEventCallback(
+    mx,
+    useCallback(
+      (mEvent) => {
+        if (mEvent.getType() !== StateEvent.SpaceChild) return;
+        const eventRoomId = mEvent.getRoomId();
+        if (!eventRoomId) return;
+
+        if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) {
+          setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems));
+        }
+      },
+      [spaceId, roomToParents, setHierarchy, getRoom, excludeRoom, sortRoomItems]
+    )
+  );
+
+  return hierarchy;
+};
diff --git a/src/app/hooks/useSyncState.ts b/src/app/hooks/useSyncState.ts
new file mode 100644 (file)
index 0000000..4b2f416
--- /dev/null
@@ -0,0 +1,14 @@
+import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+
+export const useSyncState = (
+  mx: MatrixClient,
+  onChange: ClientEventHandlerMap[ClientEvent.Sync]
+): void => {
+  useEffect(() => {
+    mx.on(ClientEvent.Sync, onChange);
+    return () => {
+      mx.removeListener(ClientEvent.Sync, onChange);
+    };
+  }, [mx, onChange]);
+};
index af76eae7e5b8f2595ded10f83204061789b734c2..db8ceff1e57178d45018945099193a8679dc9536 100644 (file)
@@ -1,10 +1,9 @@
 import { MatrixClient } from 'matrix-js-sdk';
 import { useMemo, useRef } from 'react';
+import { TYPING_TIMEOUT_MS } from '../state/typingMembers';
 
 type TypingStatusUpdater = (typing: boolean) => void;
 
-const TYPING_TIMEOUT_MS = 5000; // 5 seconds
-
 export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
   const statusSentTsRef = useRef<number>(0);
 
index a9de7bdb91e4fbd555966d803d6df091432426b5..547c0af5b99fbbe657f63e53acd0bf8edfd11799 100644 (file)
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import './SpaceAddExisting.scss';
 
@@ -25,7 +25,7 @@ import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 
 import { useStore } from '../../hooks/useStore';
 
-function SpaceAddExistingContent({ roomId }) {
+function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
   const mountStore = useStore(roomId);
   const [debounce] = useState(new Debounce());
   const [process, setProcess] = useState(null);
@@ -33,16 +33,15 @@ function SpaceAddExistingContent({ roomId }) {
   const [selected, setSelected] = useState([]);
   const [searchIds, setSearchIds] = useState(null);
   const mx = initMatrix.matrixClient;
-  const {
-    spaces, rooms, directs, roomIdToParents,
-  } = initMatrix.roomList;
+  const { spaces, rooms, directs, roomIdToParents } = initMatrix.roomList;
 
   useEffect(() => {
-    const allIds = [...spaces, ...rooms, ...directs].filter((rId) => (
-      rId !== roomId && !roomIdToParents.get(rId)?.has(roomId)
-    ));
+    const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
+    const allIds = roomIds.filter(
+      (rId) => rId !== roomId && !roomIdToParents.get(rId)?.has(roomId)
+    );
     setAllRoomIds(allIds);
-  }, [roomId]);
+  }, [roomId, onlySpaces]);
 
   const toggleSelection = (rId) => {
     if (process !== null) return;
@@ -68,20 +67,26 @@ function SpaceAddExistingContent({ roomId }) {
         via.push(getIdServer(rId));
       }
 
-      return mx.sendStateEvent(roomId, 'm.space.child', {
-        auto_join: false,
-        suggested: false,
-        via,
-      }, rId);
+      return mx.sendStateEvent(
+        roomId,
+        'm.space.child',
+        {
+          auto_join: false,
+          suggested: false,
+          via,
+        },
+        rId
+      );
     });
 
     mountStore.setItem(true);
     await Promise.allSettled(promises);
     if (mountStore.getItem() !== true) return;
 
-    const allIds = [...spaces, ...rooms, ...directs].filter((rId) => (
-      rId !== roomId && !roomIdToParents.get(rId)?.has(roomId) && !selected.includes(rId)
-    ));
+    const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
+    const allIds = roomIds.filter(
+      (rId) => rId !== roomId && !roomIdToParents.get(rId)?.has(roomId) && !selected.includes(rId)
+    );
     setAllRoomIds(allIds);
     setProcess(null);
     setSelected([]);
@@ -98,9 +103,7 @@ function SpaceAddExistingContent({ roomId }) {
       const searchedIds = allRoomIds.filter((rId) => {
         let name = mx.getRoom(rId)?.name;
         if (!name) return false;
-        name = name.normalize('NFKC')
-          .toLocaleLowerCase()
-          .replace(/\s/g, '');
+        name = name.normalize('NFKC').toLocaleLowerCase().replace(/\s/g, '');
         return name.includes(term);
       });
       setSearchIds(searchedIds);
@@ -114,66 +117,64 @@ function SpaceAddExistingContent({ roomId }) {
 
   return (
     <>
-      <form onSubmit={(ev) => { ev.preventDefault(); }}>
+      <form
+        onSubmit={(ev) => {
+          ev.preventDefault();
+        }}
+      >
         <RawIcon size="small" src={SearchIC} />
-        <Input
-          name="searchInput"
-          onChange={handleSearch}
-          placeholder="Search room"
-          autoFocus
-        />
+        <Input name="searchInput" onChange={handleSearch} placeholder="Search room" autoFocus />
         <IconButton size="small" type="button" onClick={handleSearchClear} src={CrossIC} />
       </form>
       {searchIds?.length === 0 && <Text>No results found</Text>}
-      {
-        (searchIds || allRoomIds).map((rId) => {
-          const room = mx.getRoom(rId);
-          let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
-          if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
-
-          const parentSet = roomIdToParents.get(rId);
-          const parentNames = parentSet
-            ? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
-            : undefined;
-          const parents = parentNames ? parentNames.join(', ') : null;
-
-          const handleSelect = () => toggleSelection(rId);
-
-          return (
-            <RoomSelector
-              key={rId}
-              name={room.name}
-              parentName={parents}
-              roomId={rId}
-              imageSrc={directs.has(rId) ? imageSrc : null}
-              iconSrc={
-                directs.has(rId)
-                  ? null
-                  : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
-              }
-              isUnread={false}
-              notificationCount={0}
-              isAlert={false}
-              onClick={handleSelect}
-              options={(
-                <Checkbox
-                  isActive={selected.includes(rId)}
-                  variant="positive"
-                  onToggle={handleSelect}
-                  tabIndex={-1}
-                  disabled={process !== null}
-                />
-              )}
-            />
-          );
-        })
-      }
+      {(searchIds || allRoomIds).map((rId) => {
+        const room = mx.getRoom(rId);
+        let imageSrc =
+          room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+        if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+
+        const parentSet = roomIdToParents.get(rId);
+        const parentNames = parentSet
+          ? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
+          : undefined;
+        const parents = parentNames ? parentNames.join(', ') : null;
+
+        const handleSelect = () => toggleSelection(rId);
+
+        return (
+          <RoomSelector
+            key={rId}
+            name={room.name}
+            parentName={parents}
+            roomId={rId}
+            imageSrc={directs.has(rId) ? imageSrc : null}
+            iconSrc={
+              directs.has(rId) ? null : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
+            }
+            isUnread={false}
+            notificationCount={0}
+            isAlert={false}
+            onClick={handleSelect}
+            options={
+              <Checkbox
+                isActive={selected.includes(rId)}
+                variant="positive"
+                onToggle={handleSelect}
+                tabIndex={-1}
+                disabled={process !== null}
+              />
+            }
+          />
+        );
+      })}
       {selected.length !== 0 && (
         <div className="space-add-existing__footer">
           {process && <Spinner size="small" />}
           <Text weight="medium">{process || `${selected.length} item selected`}</Text>
-          { !process && (
-            <Button onClick={handleAdd} variant="primary">Add</Button>
+          {!process && (
+            <Button onClick={handleAdd} variant="primary">
+              Add
+            </Button>
           )}
         </div>
       )}
@@ -182,47 +183,51 @@ function SpaceAddExistingContent({ roomId }) {
 }
 SpaceAddExistingContent.propTypes = {
   roomId: PropTypes.string.isRequired,
+  spaces: PropTypes.bool.isRequired,
 };
 
 function useVisibilityToggle() {
-  const [roomId, setRoomId] = useState(null);
+  const [data, setData] = useState(null);
 
   useEffect(() => {
-    const handleOpen = (rId) => setRoomId(rId);
+    const handleOpen = (roomId, spaces) =>
+      setData({
+        roomId,
+        spaces,
+      });
     navigation.on(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
     return () => {
       navigation.removeListener(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
     };
   }, []);
 
-  const requestClose = () => setRoomId(null);
+  const requestClose = () => setData(null);
 
-  return [roomId, requestClose];
+  return [data, requestClose];
 }
 
 function SpaceAddExisting() {
-  const [roomId, requestClose] = useVisibilityToggle();
+  const [data, requestClose] = useVisibilityToggle();
   const mx = initMatrix.matrixClient;
-  const room = mx.getRoom(roomId);
+  const room = mx.getRoom(data?.roomId);
 
   return (
     <Dialog
-      isOpen={roomId !== null}
+      isOpen={!!room}
       className="space-add-existing"
-      title={(
+      title={
         <Text variant="s1" weight="medium" primary>
-          {roomId && twemojify(room.name)}
-          <span style={{ color: 'var(--tc-surface-low)' }}> â€” add existing rooms</span>
+          {room && twemojify(room.name)}
+          <span style={{ color: 'var(--tc-surface-low)' }}>
+            {' '}
+            â€” add existing {data?.spaces ? 'spaces' : 'rooms'}
+          </span>
         </Text>
-      )}
+      }
       contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
       onRequestClose={requestClose}
     >
-      {
-        roomId
-          ? <SpaceAddExistingContent roomId={roomId} />
-          : <div />
-      }
+      {room ? <SpaceAddExistingContent roomId={room.roomId} spaces={data.spaces} /> : <div />}
     </Dialog>
   );
 }
index fb75ee5b1999caa4f0560991e0bbb7c152a88c8e..0795e46925a419e1ad4c3225173a2c1512bd7766 100644 (file)
@@ -72,16 +72,16 @@ function Drawer() {
         <div className="rooms__wrapper">
           <ScrollView ref={scrollRef} autoHide>
             <div className="rooms-container">
-              {
-                selectedTab !== cons.tabs.DIRECTS
-                  ? <Home spaceId={spaceId} />
-                  : <Directs size={roomList.directs.size} />
-              }
+              {selectedTab !== cons.tabs.DIRECTS ? (
+                <Home spaceId={spaceId} />
+              ) : (
+                <Directs size={roomList.directs.size} />
+              )}
             </div>
           </ScrollView>
         </div>
       </div>
-      { systemState !== null && (
+      {systemState !== null && (
         <div className="drawer__state">
           <Text>{systemState.status}</Text>
         </div>
diff --git a/src/app/organisms/navigation/Sidebar1.tsx b/src/app/organisms/navigation/Sidebar1.tsx
deleted file mode 100644 (file)
index d9ee466..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import { Icon, Icons, Badge, AvatarFallback, Text } from 'folds';
-import { useAtom } from 'jotai';
-
-import {
-  Sidebar,
-  SidebarContent,
-  SidebarStackSeparator,
-  SidebarStack,
-  SidebarAvatar,
-} from '../../components/sidebar';
-import { selectedTabAtom, SidebarTab } from '../../state/selectedTab';
-
-export function Sidebar1() {
-  const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom);
-
-  return (
-    <Sidebar>
-      <SidebarContent
-        scrollable={
-          <>
-            <SidebarStack>
-              <SidebarAvatar
-                active={selectedTab === SidebarTab.Home}
-                outlined
-                tooltip="Home"
-                avatarChildren={<Icon src={Icons.Home} filled />}
-                onClick={() => setSelectedTab(SidebarTab.Home)}
-              />
-              <SidebarAvatar
-                active={selectedTab === SidebarTab.People}
-                outlined
-                tooltip="People"
-                avatarChildren={<Icon src={Icons.User} />}
-                onClick={() => setSelectedTab(SidebarTab.People)}
-              />
-            </SidebarStack>
-            <SidebarStackSeparator />
-            <SidebarStack>
-              <SidebarAvatar
-                tooltip="Space A"
-                notificationBadge={(badgeClassName) => (
-                  <Badge
-                    className={badgeClassName}
-                    size="200"
-                    variant="Secondary"
-                    fill="Solid"
-                    radii="Pill"
-                  />
-                )}
-                avatarChildren={
-                  <AvatarFallback
-                    style={{
-                      backgroundColor: 'red',
-                      color: 'white',
-                    }}
-                  >
-                    <Text size="T500">B</Text>
-                  </AvatarFallback>
-                }
-              />
-              <SidebarAvatar
-                tooltip="Space B"
-                hasCount
-                notificationBadge={(badgeClassName) => (
-                  <Badge className={badgeClassName} radii="Pill" fill="Solid" variant="Secondary">
-                    <Text size="L400">64</Text>
-                  </Badge>
-                )}
-                avatarChildren={
-                  <AvatarFallback
-                    style={{
-                      backgroundColor: 'green',
-                      color: 'white',
-                    }}
-                  >
-                    <Text size="T500">C</Text>
-                  </AvatarFallback>
-                }
-              />
-            </SidebarStack>
-            <SidebarStackSeparator />
-            <SidebarStack>
-              <SidebarAvatar
-                outlined
-                tooltip="Explore Community"
-                avatarChildren={<Icon src={Icons.Explore} />}
-              />
-              <SidebarAvatar
-                outlined
-                tooltip="Create Space"
-                avatarChildren={<Icon src={Icons.Plus} />}
-              />
-            </SidebarStack>
-          </>
-        }
-        sticky={
-          <>
-            <SidebarStackSeparator />
-            <SidebarStack>
-              <SidebarAvatar
-                outlined
-                tooltip="Search"
-                avatarChildren={<Icon src={Icons.Search} />}
-              />
-              <SidebarAvatar
-                tooltip="User Settings"
-                avatarChildren={
-                  <AvatarFallback
-                    style={{
-                      backgroundColor: 'blue',
-                      color: 'white',
-                    }}
-                  >
-                    <Text size="T500">A</Text>
-                  </AvatarFallback>
-                }
-              />
-            </SidebarStack>
-          </>
-        }
-      />
-    </Sidebar>
-  );
-}
index ba80f132dbba575ca0a24ae6906a708f6c45729a..835b70334c243b27aea57657e070d5b19fa26b41 100644 (file)
@@ -9,14 +9,18 @@ import InviteUser from '../invite-user/InviteUser';
 import Settings from '../settings/Settings';
 import SpaceSettings from '../space-settings/SpaceSettings';
 import SpaceManage from '../space-manage/SpaceManage';
+import RoomSettings from '../room/RoomSettings';
 
 function Windows() {
   const [isInviteList, changeInviteList] = useState(false);
   const [publicRooms, changePublicRooms] = useState({
-    isOpen: false, searchTerm: undefined,
+    isOpen: false,
+    searchTerm: undefined,
   });
   const [inviteUser, changeInviteUser] = useState({
-    isOpen: false, roomId: undefined, term: undefined,
+    isOpen: false,
+    roomId: undefined,
+    term: undefined,
   });
 
   function openInviteList() {
@@ -49,10 +53,7 @@ function Windows() {
 
   return (
     <>
-      <InviteList
-        isOpen={isInviteList}
-        onRequestClose={() => changeInviteList(false)}
-      />
+      <InviteList isOpen={isInviteList} onRequestClose={() => changeInviteList(false)} />
       <PublicRooms
         isOpen={publicRooms.isOpen}
         searchTerm={publicRooms.searchTerm}
@@ -66,6 +67,7 @@ function Windows() {
       />
       <Settings />
       <SpaceSettings />
+      <RoomSettings />
       <SpaceManage />
     </>
   );
diff --git a/src/app/organisms/room/CommandAutocomplete.tsx b/src/app/organisms/room/CommandAutocomplete.tsx
deleted file mode 100644 (file)
index 31903ac..0000000
+++ /dev/null
@@ -1,109 +0,0 @@
-import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
-import { Editor } from 'slate';
-import { Box, MenuItem, Text } from 'folds';
-import { Room } from 'matrix-js-sdk';
-import { Command, useCommands } from '../../hooks/useCommands';
-import {
-  AutocompleteMenu,
-  AutocompleteQuery,
-  createCommandElement,
-  moveCursor,
-  replaceWithElement,
-} from '../../components/editor';
-import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useKeyDown } from '../../hooks/useKeyDown';
-import { onTabPress } from '../../utils/keyboard';
-
-type CommandAutoCompleteHandler = (commandName: string) => void;
-
-type CommandAutocompleteProps = {
-  room: Room;
-  editor: Editor;
-  query: AutocompleteQuery<string>;
-  requestClose: () => void;
-};
-
-const SEARCH_OPTIONS: UseAsyncSearchOptions = {
-  matchOptions: {
-    contain: true,
-  },
-};
-
-export function CommandAutocomplete({
-  room,
-  editor,
-  query,
-  requestClose,
-}: CommandAutocompleteProps) {
-  const mx = useMatrixClient();
-  const commands = useCommands(mx, room);
-  const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
-
-  const [result, search, resetSearch] = useAsyncSearch(
-    commandNames,
-    useCallback((commandName: string) => commandName, []),
-    SEARCH_OPTIONS
-  );
-
-  const autoCompleteNames = result ? result.items : commandNames;
-
-  useEffect(() => {
-    if (query.text) search(query.text);
-    else resetSearch();
-  }, [query.text, search, resetSearch]);
-
-  const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
-    const cmdEl = createCommandElement(commandName);
-    replaceWithElement(editor, query.range, cmdEl);
-    moveCursor(editor, true);
-    requestClose();
-  };
-
-  useKeyDown(window, (evt: KeyboardEvent) => {
-    onTabPress(evt, () => {
-      if (autoCompleteNames.length === 0) {
-        return;
-      }
-      const cmdName = autoCompleteNames[0];
-      handleAutocomplete(cmdName);
-    });
-  });
-
-  return autoCompleteNames.length === 0 ? null : (
-    <AutocompleteMenu
-      headerContent={
-        <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
-          <Text size="L400">Commands</Text>
-          <Text size="T200" priority="300" truncate>
-            Begin your message with command
-          </Text>
-        </Box>
-      }
-      requestClose={requestClose}
-    >
-      {autoCompleteNames.map((commandName) => (
-        <MenuItem
-          key={commandName}
-          as="button"
-          radii="300"
-          onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
-            onTabPress(evt, () => handleAutocomplete(commandName))
-          }
-          onClick={() => handleAutocomplete(commandName)}
-        >
-          <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
-            <Box shrink="No">
-              <Text style={{ flexGrow: 1 }} size="B400" truncate>
-                {`/${commandName}`}
-              </Text>
-            </Box>
-            <Text truncate priority="300" size="T200">
-              {commands[commandName].description}
-            </Text>
-          </Box>
-        </MenuItem>
-      ))}
-    </AutocompleteMenu>
-  );
-}
diff --git a/src/app/organisms/room/MembersDrawer.css.ts b/src/app/organisms/room/MembersDrawer.css.ts
deleted file mode 100644 (file)
index a1f4153..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-import { keyframes, style } from '@vanilla-extract/css';
-import { color, config, toRem } from 'folds';
-
-export const MembersDrawer = style({
-  width: toRem(266),
-  backgroundColor: color.Background.Container,
-  color: color.Background.OnContainer,
-});
-
-export const MembersDrawerHeader = style({
-  flexShrink: 0,
-  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
-  borderBottomWidth: config.borderWidth.B300,
-});
-
-export const MemberDrawerContentBase = style({
-  position: 'relative',
-  overflow: 'hidden',
-});
-
-export const MemberDrawerContent = style({
-  padding: `${config.space.S200} 0`,
-});
-
-const ScrollBtnAnime = keyframes({
-  '0%': {
-    transform: `translate(-50%, -100%) scale(0)`,
-  },
-  '100%': {
-    transform: `translate(-50%, 0) scale(1)`,
-  },
-});
-
-export const DrawerScrollTop = style({
-  position: 'absolute',
-  top: config.space.S200,
-  left: '50%',
-  transform: 'translateX(-50%)',
-  zIndex: 1,
-  animation: `${ScrollBtnAnime} 100ms`,
-});
-
-export const DrawerGroup = style({
-  paddingLeft: config.space.S200,
-});
-
-export const MembersGroup = style({
-  paddingLeft: config.space.S200,
-});
-export const MembersGroupLabel = style({
-  padding: config.space.S200,
-  selectors: {
-    '&:not(:first-child)': {
-      paddingTop: config.space.S500,
-    },
-  },
-});
-
-export const DrawerVirtualItem = style({
-  position: 'absolute',
-  top: 0,
-  left: 0,
-  width: '100%',
-});
diff --git a/src/app/organisms/room/MembersDrawer.tsx b/src/app/organisms/room/MembersDrawer.tsx
deleted file mode 100644 (file)
index b4ba6b7..0000000
+++ /dev/null
@@ -1,566 +0,0 @@
-import React, {
-  ChangeEventHandler,
-  MouseEventHandler,
-  useCallback,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
-import {
-  Avatar,
-  AvatarFallback,
-  AvatarImage,
-  Badge,
-  Box,
-  Chip,
-  ContainerColor,
-  Header,
-  Icon,
-  IconButton,
-  Icons,
-  Input,
-  Menu,
-  MenuItem,
-  PopOut,
-  Scroll,
-  Spinner,
-  Text,
-  Tooltip,
-  TooltipProvider,
-  config,
-} from 'folds';
-import { Room, RoomMember } from 'matrix-js-sdk';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import FocusTrap from 'focus-trap-react';
-import millify from 'millify';
-import classNames from 'classnames';
-import { useAtomValue } from 'jotai';
-
-import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
-import * as css from './MembersDrawer.css';
-import { useRoomMembers } from '../../hooks/useRoomMembers';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import {
-  getIntersectionObserverEntry,
-  useIntersectionObserver,
-} from '../../hooks/useIntersectionObserver';
-import { Membership } from '../../../types/matrix/room';
-import { UseStateProvider } from '../../components/UseStateProvider';
-import {
-  SearchItemStrGetter,
-  UseAsyncSearchOptions,
-  useAsyncSearch,
-} from '../../hooks/useAsyncSearch';
-import { useDebounce } from '../../hooks/useDebounce';
-import colorMXID from '../../../util/colorMXID';
-import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
-import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
-import { TypingIndicator } from '../../components/typing-indicator';
-import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-
-export const MembershipFilters = {
-  filterJoined: (m: RoomMember) => m.membership === Membership.Join,
-  filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
-  filterLeaved: (m: RoomMember) =>
-    m.membership === Membership.Leave &&
-    m.events.member?.getStateKey() === m.events.member?.getSender(),
-  filterKicked: (m: RoomMember) =>
-    m.membership === Membership.Leave &&
-    m.events.member?.getStateKey() !== m.events.member?.getSender(),
-  filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
-};
-
-export type MembershipFilterFn = (m: RoomMember) => boolean;
-
-export type MembershipFilter = {
-  name: string;
-  filterFn: MembershipFilterFn;
-  color: ContainerColor;
-};
-
-const useMembershipFilterMenu = (): MembershipFilter[] =>
-  useMemo(
-    () => [
-      {
-        name: 'Joined',
-        filterFn: MembershipFilters.filterJoined,
-        color: 'Background',
-      },
-      {
-        name: 'Invited',
-        filterFn: MembershipFilters.filterInvited,
-        color: 'Success',
-      },
-      {
-        name: 'Left',
-        filterFn: MembershipFilters.filterLeaved,
-        color: 'Secondary',
-      },
-      {
-        name: 'Kicked',
-        filterFn: MembershipFilters.filterKicked,
-        color: 'Warning',
-      },
-      {
-        name: 'Banned',
-        filterFn: MembershipFilters.filterBanned,
-        color: 'Critical',
-      },
-    ],
-    []
-  );
-
-export const SortFilters = {
-  filterAscending: (a: RoomMember, b: RoomMember) =>
-    a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
-  filterDescending: (a: RoomMember, b: RoomMember) =>
-    a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
-  filterNewestFirst: (a: RoomMember, b: RoomMember) =>
-    (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
-  filterOldest: (a: RoomMember, b: RoomMember) =>
-    (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
-};
-
-export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
-
-export type SortFilter = {
-  name: string;
-  filterFn: SortFilterFn;
-};
-
-const useSortFilterMenu = (): SortFilter[] =>
-  useMemo(
-    () => [
-      {
-        name: 'A to Z',
-        filterFn: SortFilters.filterAscending,
-      },
-      {
-        name: 'Z to A',
-        filterFn: SortFilters.filterDescending,
-      },
-      {
-        name: 'Newest',
-        filterFn: SortFilters.filterNewestFirst,
-      },
-      {
-        name: 'Oldest',
-        filterFn: SortFilters.filterOldest,
-      },
-    ],
-    []
-  );
-
-export type MembersFilterOptions = {
-  membershipFilter: MembershipFilter;
-  sortFilter: SortFilter;
-};
-
-const SEARCH_OPTIONS: UseAsyncSearchOptions = {
-  limit: 100,
-  matchOptions: {
-    contain: true,
-  },
-};
-
-const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
-const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
-  getMemberSearchStr(m, query, mxIdToName);
-
-type MembersDrawerProps = {
-  room: Room;
-};
-export function MembersDrawer({ room }: MembersDrawerProps) {
-  const mx = useMatrixClient();
-  const scrollRef = useRef<HTMLDivElement>(null);
-  const searchInputRef = useRef<HTMLInputElement>(null);
-  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
-  const members = useRoomMembers(mx, room.roomId);
-  const getPowerLevelTag = usePowerLevelTags();
-  const fetchingMembers = members.length < room.getJoinedMemberCount();
-
-  const membershipFilterMenu = useMembershipFilterMenu();
-  const sortFilterMenu = useSortFilterMenu();
-  const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
-  const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
-
-  const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
-  const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
-
-  const [onTop, setOnTop] = useState(true);
-
-  const typingMembers = useAtomValue(
-    useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
-  );
-
-  const filteredMembers = useMemo(
-    () =>
-      members
-        .filter(membershipFilter.filterFn)
-        .sort(sortFilter.filterFn)
-        .sort((a, b) => b.powerLevel - a.powerLevel),
-    [members, membershipFilter, sortFilter]
-  );
-
-  const [result, search, resetSearch] = useAsyncSearch(
-    filteredMembers,
-    getRoomMemberStr,
-    SEARCH_OPTIONS
-  );
-  if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
-
-  const processMembers = result ? result.items : filteredMembers;
-
-  const PLTagOrRoomMember = useMemo(() => {
-    let prevTag: PowerLevelTag | undefined;
-    const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
-    processMembers.forEach((m) => {
-      const plTag = getPowerLevelTag(m.powerLevel);
-      if (plTag !== prevTag) {
-        prevTag = plTag;
-        tagOrMember.push(plTag);
-      }
-      tagOrMember.push(m);
-    });
-    return tagOrMember;
-  }, [processMembers, getPowerLevelTag]);
-
-  const virtualizer = useVirtualizer({
-    count: PLTagOrRoomMember.length,
-    getScrollElement: () => scrollRef.current,
-    estimateSize: () => 40,
-    overscan: 10,
-  });
-
-  useIntersectionObserver(
-    useCallback((intersectionEntries) => {
-      if (!scrollTopAnchorRef.current) return;
-      const entry = getIntersectionObserverEntry(scrollTopAnchorRef.current, intersectionEntries);
-      if (entry) setOnTop(entry.isIntersecting);
-    }, []),
-    useCallback(() => ({ root: scrollRef.current }), []),
-    useCallback(() => scrollTopAnchorRef.current, [])
-  );
-
-  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
-    useCallback(
-      (evt) => {
-        if (evt.target.value) search(evt.target.value);
-        else resetSearch();
-      },
-      [search, resetSearch]
-    ),
-    { wait: 200 }
-  );
-
-  const getName = (member: RoomMember) =>
-    getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
-
-  const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
-    const btn = evt.currentTarget as HTMLButtonElement;
-    const userId = btn.getAttribute('data-user-id');
-    openProfileViewer(userId, room.roomId);
-  };
-
-  return (
-    <Box className={css.MembersDrawer} direction="Column">
-      <Header className={css.MembersDrawerHeader} variant="Background" size="600">
-        <Box grow="Yes" alignItems="Center" gap="200">
-          <Box grow="Yes" alignItems="Center" gap="200">
-            <Text size="H5" truncate>
-              {`${millify(room.getJoinedMemberCount(), { precision: 1, locales: [] })} Members`}
-            </Text>
-          </Box>
-          <Box shrink="No" alignItems="Center">
-            <TooltipProvider
-              position="Bottom"
-              align="End"
-              offset={4}
-              tooltip={
-                <Tooltip>
-                  <Text>Invite Member</Text>
-                </Tooltip>
-              }
-            >
-              {(triggerRef) => (
-                <IconButton
-                  ref={triggerRef}
-                  variant="Background"
-                  onClick={() => openInviteUser(room.roomId)}
-                >
-                  <Icon src={Icons.UserPlus} />
-                </IconButton>
-              )}
-            </TooltipProvider>
-          </Box>
-        </Box>
-      </Header>
-      <Box className={css.MemberDrawerContentBase} grow="Yes">
-        <Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover">
-          <Box className={css.MemberDrawerContent} direction="Column" gap="200">
-            <Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
-              <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
-                <UseStateProvider initial={false}>
-                  {(open, setOpen) => (
-                    <PopOut
-                      open={open}
-                      position="Bottom"
-                      align="Start"
-                      offset={4}
-                      content={
-                        <FocusTrap
-                          focusTrapOptions={{
-                            initialFocus: false,
-                            onDeactivate: () => setOpen(false),
-                            clickOutsideDeactivates: true,
-                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                          }}
-                        >
-                          <Menu style={{ padding: config.space.S100 }}>
-                            {membershipFilterMenu.map((menuItem, index) => (
-                              <MenuItem
-                                key={menuItem.name}
-                                variant={
-                                  menuItem.name === membershipFilter.name
-                                    ? menuItem.color
-                                    : 'Surface'
-                                }
-                                aria-pressed={menuItem.name === membershipFilter.name}
-                                radii="300"
-                                onClick={() => {
-                                  setMembershipFilterIndex(index);
-                                  setOpen(false);
-                                }}
-                              >
-                                <Text>{menuItem.name}</Text>
-                              </MenuItem>
-                            ))}
-                          </Menu>
-                        </FocusTrap>
-                      }
-                    >
-                      {(anchorRef) => (
-                        <Chip
-                          ref={anchorRef}
-                          onClick={() => setOpen(!open)}
-                          variant={membershipFilter.color}
-                          size="400"
-                          radii="300"
-                          before={<Icon src={Icons.Filter} size="50" />}
-                        >
-                          <Text size="T200">{membershipFilter.name}</Text>
-                        </Chip>
-                      )}
-                    </PopOut>
-                  )}
-                </UseStateProvider>
-                <UseStateProvider initial={false}>
-                  {(open, setOpen) => (
-                    <PopOut
-                      open={open}
-                      position="Bottom"
-                      align="End"
-                      offset={4}
-                      content={
-                        <FocusTrap
-                          focusTrapOptions={{
-                            initialFocus: false,
-                            onDeactivate: () => setOpen(false),
-                            clickOutsideDeactivates: true,
-                            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                          }}
-                        >
-                          <Menu style={{ padding: config.space.S100 }}>
-                            {sortFilterMenu.map((menuItem, index) => (
-                              <MenuItem
-                                key={menuItem.name}
-                                variant="Surface"
-                                aria-pressed={menuItem.name === sortFilter.name}
-                                radii="300"
-                                onClick={() => {
-                                  setSortFilterIndex(index);
-                                  setOpen(false);
-                                }}
-                              >
-                                <Text>{menuItem.name}</Text>
-                              </MenuItem>
-                            ))}
-                          </Menu>
-                        </FocusTrap>
-                      }
-                    >
-                      {(anchorRef) => (
-                        <Chip
-                          ref={anchorRef}
-                          onClick={() => setOpen(!open)}
-                          variant="Background"
-                          size="400"
-                          radii="300"
-                          after={<Icon src={Icons.Sort} size="50" />}
-                        >
-                          <Text size="T200">{sortFilter.name}</Text>
-                        </Chip>
-                      )}
-                    </PopOut>
-                  )}
-                </UseStateProvider>
-              </Box>
-              <Box direction="Column" gap="100">
-                <Input
-                  ref={searchInputRef}
-                  onChange={handleSearchChange}
-                  style={{ paddingRight: config.space.S200 }}
-                  placeholder="Type name..."
-                  variant="Surface"
-                  size="400"
-                  radii="400"
-                  before={<Icon size="50" src={Icons.Search} />}
-                  after={
-                    result && (
-                      <Chip
-                        variant={result.items.length > 0 ? 'Success' : 'Critical'}
-                        size="400"
-                        radii="Pill"
-                        aria-pressed
-                        onClick={() => {
-                          if (searchInputRef.current) {
-                            searchInputRef.current.value = '';
-                            searchInputRef.current.focus();
-                          }
-                          resetSearch();
-                        }}
-                        after={<Icon size="50" src={Icons.Cross} />}
-                      >
-                        <Text size="B300">{`${result.items.length || 'No'} ${
-                          result.items.length === 1 ? 'Result' : 'Results'
-                        }`}</Text>
-                      </Chip>
-                    )
-                  }
-                />
-              </Box>
-            </Box>
-
-            {!onTop && (
-              <Box className={css.DrawerScrollTop}>
-                <IconButton
-                  onClick={() => virtualizer.scrollToOffset(0)}
-                  variant="Surface"
-                  radii="Pill"
-                  outlined
-                  size="300"
-                  aria-label="Scroll to Top"
-                >
-                  <Icon src={Icons.ChevronTop} size="300" />
-                </IconButton>
-              </Box>
-            )}
-
-            {!fetchingMembers && !result && processMembers.length === 0 && (
-              <Text style={{ padding: config.space.S300 }} align="Center">
-                {`No "${membershipFilter.name}" Members`}
-              </Text>
-            )}
-
-            <Box className={css.MembersGroup} direction="Column" gap="100">
-              <div
-                style={{
-                  position: 'relative',
-                  height: virtualizer.getTotalSize(),
-                }}
-              >
-                {virtualizer.getVirtualItems().map((vItem) => {
-                  const tagOrMember = PLTagOrRoomMember[vItem.index];
-                  if (!('userId' in tagOrMember)) {
-                    return (
-                      <Text
-                        style={{
-                          transform: `translateY(${vItem.start}px)`,
-                        }}
-                        data-index={vItem.index}
-                        ref={virtualizer.measureElement}
-                        key={`${room.roomId}-${vItem.index}`}
-                        className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
-                        size="L400"
-                      >
-                        {tagOrMember.name}
-                      </Text>
-                    );
-                  }
-
-                  const member = tagOrMember;
-                  const name = getName(member);
-                  const avatarUrl = member.getAvatarUrl(
-                    mx.baseUrl,
-                    100,
-                    100,
-                    'crop',
-                    undefined,
-                    false
-                  );
-
-                  return (
-                    <MenuItem
-                      style={{
-                        padding: `0 ${config.space.S200}`,
-                        transform: `translateY(${vItem.start}px)`,
-                      }}
-                      data-index={vItem.index}
-                      data-user-id={member.userId}
-                      ref={virtualizer.measureElement}
-                      key={`${room.roomId}-${member.userId}`}
-                      className={css.DrawerVirtualItem}
-                      variant="Background"
-                      radii="400"
-                      onClick={handleMemberClick}
-                      before={
-                        <Avatar size="200">
-                          {avatarUrl ? (
-                            <AvatarImage src={avatarUrl} />
-                          ) : (
-                            <AvatarFallback
-                              style={{
-                                background: colorMXID(member.userId),
-                                color: 'white',
-                              }}
-                            >
-                              <Text size="H6">{name[0]}</Text>
-                            </AvatarFallback>
-                          )}
-                        </Avatar>
-                      }
-                      after={
-                        typingMembers.find((tm) => tm.userId === member.userId) && (
-                          <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
-                            <TypingIndicator size="300" />
-                          </Badge>
-                        )
-                      }
-                    >
-                      <Box grow="Yes">
-                        <Text size="T400" truncate>
-                          {name}
-                        </Text>
-                      </Box>
-                    </MenuItem>
-                  );
-                })}
-              </div>
-            </Box>
-
-            {fetchingMembers && (
-              <Box justifyContent="Center">
-                <Spinner />
-              </Box>
-            )}
-          </Box>
-        </Scroll>
-      </Box>
-    </Box>
-  );
-}
index 11a00074d596c8cfead35a4db75ba1fffe2bf4ce..69f8f9dd3a55feace0d068d080f10f35689f5e16 100644 (file)
@@ -4,6 +4,7 @@
 .room {
   @extend .cp-fx__row;
   height: 100%;
+  flex-grow: 1;
 
   &__content {
     @extend .cp-fx__item-one;
diff --git a/src/app/organisms/room/Room.tsx b/src/app/organisms/room/Room.tsx
deleted file mode 100644 (file)
index 6158547..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-import './Room.scss';
-import { Room } from 'matrix-js-sdk';
-import { Line } from 'folds';
-
-import RoomView from './RoomView';
-import RoomSettings from './RoomSettings';
-import { MembersDrawer } from './MembersDrawer';
-import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
-import {
-  roomIdToTypingMembersAtom,
-  useBindRoomIdToTypingMembersAtom,
-} from '../../state/typingMembers';
-
-export type RoomBaseViewProps = {
-  room: Room;
-  eventId?: string;
-};
-export function RoomBaseView({ room, eventId }: RoomBaseViewProps) {
-  useBindRoomIdToTypingMembersAtom(room.client, roomIdToTypingMembersAtom);
-
-  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
-  const [screenSize] = useScreenSize();
-  const powerLevelAPI = usePowerLevels(room);
-
-  return (
-    <PowerLevelsContextProvider value={powerLevelAPI}>
-      <div className="room">
-        <div className="room__content">
-          <RoomSettings roomId={room.roomId} />
-          <RoomView room={room} eventId={eventId} />
-        </div>
-
-        {screenSize === ScreenSize.Desktop && isDrawer && (
-          <>
-            <Line variant="Background" direction="Vertical" size="300" />
-            <MembersDrawer key={room.roomId} room={room} />
-          </>
-        )}
-      </div>
-    </PowerLevelsContextProvider>
-  );
-}
diff --git a/src/app/organisms/room/RoomInput.tsx b/src/app/organisms/room/RoomInput.tsx
deleted file mode 100644 (file)
index e6c4fb7..0000000
+++ /dev/null
@@ -1,595 +0,0 @@
-import React, {
-  KeyboardEventHandler,
-  RefObject,
-  forwardRef,
-  useCallback,
-  useEffect,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
-import { useAtom } from 'jotai';
-import { isKeyHotkey } from 'is-hotkey';
-import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
-import { ReactEditor } from 'slate-react';
-import { Transforms, Editor } from 'slate';
-import {
-  Box,
-  Dialog,
-  Icon,
-  IconButton,
-  Icons,
-  Line,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  PopOut,
-  Scroll,
-  Text,
-  config,
-  toRem,
-} from 'folds';
-
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import {
-  CustomEditor,
-  Toolbar,
-  toMatrixCustomHTML,
-  toPlainText,
-  AUTOCOMPLETE_PREFIXES,
-  AutocompletePrefix,
-  AutocompleteQuery,
-  getAutocompleteQuery,
-  getPrevWorldRange,
-  resetEditor,
-  RoomMentionAutocomplete,
-  UserMentionAutocomplete,
-  EmoticonAutocomplete,
-  createEmoticonElement,
-  moveCursor,
-  resetEditorHistory,
-  customHtmlEqualsPlainText,
-  trimCustomHtml,
-  isEmptyEditor,
-  getBeginCommand,
-  trimCommand,
-} from '../../components/editor';
-import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
-import { UseStateProvider } from '../../components/UseStateProvider';
-import initMatrix from '../../../client/initMatrix';
-import { TUploadContent, encryptFile, getImageInfo } from '../../utils/matrix';
-import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
-import { useFilePicker } from '../../hooks/useFilePicker';
-import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
-import { useFileDropZone } from '../../hooks/useFileDrop';
-import {
-  TUploadItem,
-  roomIdToMsgDraftAtomFamily,
-  roomIdToReplyDraftAtomFamily,
-  roomIdToUploadItemsAtomFamily,
-  roomUploadAtomFamily,
-} from '../../state/roomInputDrafts';
-import { UploadCardRenderer } from '../../components/upload-card';
-import {
-  UploadBoard,
-  UploadBoardContent,
-  UploadBoardHeader,
-  UploadBoardImperativeHandlers,
-} from '../../components/upload-board';
-import {
-  Upload,
-  UploadStatus,
-  UploadSuccess,
-  createUploadFamilyObserverAtom,
-} from '../../state/upload';
-import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
-import { safeFile } from '../../utils/mimeTypes';
-import { fulfilledPromiseSettledResult } from '../../utils/common';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-import {
-  getAudioMsgContent,
-  getFileMsgContent,
-  getImageMsgContent,
-  getVideoMsgContent,
-} from './msgContent';
-import { MessageReply } from '../../molecules/message/Message';
-import colorMXID from '../../../util/colorMXID';
-import {
-  parseReplyBody,
-  parseReplyFormattedBody,
-  trimReplyFromBody,
-  trimReplyFromFormattedBody,
-} from '../../utils/room';
-import { sanitizeText } from '../../utils/sanitize';
-import { useScreenSize } from '../../hooks/useScreenSize';
-import { CommandAutocomplete } from './CommandAutocomplete';
-import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
-import { mobileOrTablet } from '../../utils/user-agent';
-
-interface RoomInputProps {
-  editor: Editor;
-  roomViewRef: RefObject<HTMLElement>;
-  roomId: string;
-  room: Room;
-}
-export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
-  ({ editor, roomViewRef, roomId, room }, ref) => {
-    const mx = useMatrixClient();
-    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
-    const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
-    const commands = useCommands(mx, room);
-
-    const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
-    const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
-    const [uploadBoard, setUploadBoard] = useState(true);
-    const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
-    const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
-      roomUploadAtomFamily,
-      selectedFiles.map((f) => f.file)
-    );
-    const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
-
-    const imagePackRooms: Room[] = useMemo(() => {
-      const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
-      return allParentSpaces.reduce<Room[]>((list, rId) => {
-        const r = mx.getRoom(rId);
-        if (r) list.push(r);
-        return list;
-      }, []);
-    }, [mx, roomId]);
-
-    const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
-    const [autocompleteQuery, setAutocompleteQuery] =
-      useState<AutocompleteQuery<AutocompletePrefix>>();
-
-    const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
-
-    const handleFiles = useCallback(
-      async (files: File[]) => {
-        setUploadBoard(true);
-        const safeFiles = files.map(safeFile);
-        const fileItems: TUploadItem[] = [];
-
-        if (mx.isRoomEncrypted(roomId)) {
-          const encryptFiles = fulfilledPromiseSettledResult(
-            await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
-          );
-          encryptFiles.forEach((ef) => fileItems.push(ef));
-        } else {
-          safeFiles.forEach((f) =>
-            fileItems.push({ file: f, originalFile: f, encInfo: undefined })
-          );
-        }
-        setSelectedFiles({
-          type: 'PUT',
-          item: fileItems,
-        });
-      },
-      [setSelectedFiles, roomId, mx]
-    );
-    const pickFile = useFilePicker(handleFiles, true);
-    const handlePaste = useFilePasteHandler(handleFiles);
-    const dropZoneVisible = useFileDropZone(roomViewRef, handleFiles);
-
-    const [, screenWidth] = useScreenSize();
-    const hideStickerBtn = screenWidth < 500;
-
-    useEffect(() => {
-      Transforms.insertFragment(editor, msgDraft);
-    }, [editor, msgDraft]);
-
-    useEffect(() => {
-      if (!mobileOrTablet()) ReactEditor.focus(editor);
-      return () => {
-        if (!isEmptyEditor(editor)) {
-          const parsedDraft = JSON.parse(JSON.stringify(editor.children));
-          setMsgDraft(parsedDraft);
-        } else {
-          setMsgDraft([]);
-        }
-        resetEditor(editor);
-        resetEditorHistory(editor);
-      };
-    }, [roomId, editor, setMsgDraft]);
-
-    const handleRemoveUpload = useCallback(
-      (upload: TUploadContent | TUploadContent[]) => {
-        const uploads = Array.isArray(upload) ? upload : [upload];
-        setSelectedFiles({
-          type: 'DELETE',
-          item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
-        });
-        uploads.forEach((u) => roomUploadAtomFamily.remove(u));
-      },
-      [setSelectedFiles, selectedFiles]
-    );
-
-    const handleCancelUpload = (uploads: Upload[]) => {
-      uploads.forEach((upload) => {
-        if (upload.status === UploadStatus.Loading) {
-          mx.cancelUpload(upload.promise);
-        }
-      });
-      handleRemoveUpload(uploads.map((upload) => upload.file));
-    };
-
-    const handleSendUpload = async (uploads: UploadSuccess[]) => {
-      const contentsPromises = uploads.map(async (upload) => {
-        const fileItem = selectedFiles.find((f) => f.file === upload.file);
-        if (!fileItem) throw new Error('Broken upload');
-
-        if (fileItem.file.type.startsWith('image')) {
-          return getImageMsgContent(mx, fileItem, upload.mxc);
-        }
-        if (fileItem.file.type.startsWith('video')) {
-          return getVideoMsgContent(mx, fileItem, upload.mxc);
-        }
-        if (fileItem.file.type.startsWith('audio')) {
-          return getAudioMsgContent(fileItem, upload.mxc);
-        }
-        return getFileMsgContent(fileItem, upload.mxc);
-      });
-      handleCancelUpload(uploads);
-      const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
-      contents.forEach((content) => mx.sendMessage(roomId, content));
-    };
-
-    const submit = useCallback(() => {
-      uploadBoardHandlers.current?.handleSend();
-
-      const commandName = getBeginCommand(editor);
-
-      let plainText = toPlainText(editor.children).trim();
-      let customHtml = trimCustomHtml(
-        toMatrixCustomHTML(editor.children, {
-          allowTextFormatting: true,
-          allowBlockMarkdown: isMarkdown,
-          allowInlineMarkdown: isMarkdown,
-        })
-      );
-      let msgType = MsgType.Text;
-
-      if (commandName) {
-        plainText = trimCommand(commandName, plainText);
-        customHtml = trimCommand(commandName, customHtml);
-      }
-      if (commandName === Command.Me) {
-        msgType = MsgType.Emote;
-      } else if (commandName === Command.Notice) {
-        msgType = MsgType.Notice;
-      } else if (commandName === Command.Shrug) {
-        plainText = `${SHRUG} ${plainText}`;
-        customHtml = `${SHRUG} ${customHtml}`;
-      } else if (commandName) {
-        const commandContent = commands[commandName as Command];
-        if (commandContent) {
-          commandContent.exe(plainText);
-        }
-        resetEditor(editor);
-        resetEditorHistory(editor);
-        sendTypingStatus(false);
-        return;
-      }
-
-      if (plainText === '') return;
-
-      let body = plainText;
-      let formattedBody = customHtml;
-      if (replyDraft) {
-        body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
-        formattedBody =
-          parseReplyFormattedBody(
-            roomId,
-            replyDraft.userId,
-            replyDraft.eventId,
-            replyDraft.formattedBody
-              ? trimReplyFromFormattedBody(replyDraft.formattedBody)
-              : sanitizeText(replyDraft.body)
-          ) + formattedBody;
-      }
-
-      const content: IContent = {
-        msgtype: msgType,
-        body,
-      };
-      if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
-        content.format = 'org.matrix.custom.html';
-        content.formatted_body = formattedBody;
-      }
-      if (replyDraft) {
-        content['m.relates_to'] = {
-          'm.in_reply_to': {
-            event_id: replyDraft.eventId,
-          },
-        };
-      }
-      mx.sendMessage(roomId, content);
-      resetEditor(editor);
-      resetEditorHistory(editor);
-      setReplyDraft();
-      sendTypingStatus(false);
-    }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
-
-    const handleKeyDown: KeyboardEventHandler = useCallback(
-      (evt) => {
-        if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
-          evt.preventDefault();
-          submit();
-        }
-        if (isKeyHotkey('escape', evt)) {
-          evt.preventDefault();
-          setReplyDraft();
-        }
-      },
-      [submit, setReplyDraft, enterForNewline]
-    );
-
-    const handleKeyUp: KeyboardEventHandler = useCallback(
-      (evt) => {
-        if (isKeyHotkey('escape', evt)) {
-          evt.preventDefault();
-          return;
-        }
-
-        sendTypingStatus(!isEmptyEditor(editor));
-
-        const prevWordRange = getPrevWorldRange(editor);
-        const query = prevWordRange
-          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
-          : undefined;
-        setAutocompleteQuery(query);
-      },
-      [editor, sendTypingStatus]
-    );
-
-    const handleCloseAutocomplete = useCallback(() => {
-      setAutocompleteQuery(undefined);
-      ReactEditor.focus(editor);
-    }, [editor]);
-
-    const handleEmoticonSelect = (key: string, shortcode: string) => {
-      editor.insertNode(createEmoticonElement(key, shortcode));
-      moveCursor(editor);
-    };
-
-    const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
-      const stickerUrl = mx.mxcUrlToHttp(mxc);
-      if (!stickerUrl) return;
-
-      const info = await getImageInfo(
-        await loadImageElement(stickerUrl),
-        await getImageUrlBlob(stickerUrl)
-      );
-
-      mx.sendEvent(roomId, EventType.Sticker, {
-        body: label,
-        url: mxc,
-        info,
-      });
-    };
-
-    return (
-      <div ref={ref}>
-        {selectedFiles.length > 0 && (
-          <UploadBoard
-            header={
-              <UploadBoardHeader
-                open={uploadBoard}
-                onToggle={() => setUploadBoard(!uploadBoard)}
-                uploadFamilyObserverAtom={uploadFamilyObserverAtom}
-                onSend={handleSendUpload}
-                imperativeHandlerRef={uploadBoardHandlers}
-                onCancel={handleCancelUpload}
-              />
-            }
-          >
-            {uploadBoard && (
-              <Scroll size="300" hideTrack visibility="Hover">
-                <UploadBoardContent>
-                  {Array.from(selectedFiles)
-                    .reverse()
-                    .map((fileItem, index) => (
-                      <UploadCardRenderer
-                        // eslint-disable-next-line react/no-array-index-key
-                        key={index}
-                        file={fileItem.file}
-                        isEncrypted={!!fileItem.encInfo}
-                        uploadAtom={roomUploadAtomFamily(fileItem.file)}
-                        onRemove={handleRemoveUpload}
-                      />
-                    ))}
-                </UploadBoardContent>
-              </Scroll>
-            )}
-          </UploadBoard>
-        )}
-        <Overlay
-          open={dropZoneVisible}
-          backdrop={<OverlayBackdrop />}
-          style={{ pointerEvents: 'none' }}
-        >
-          <OverlayCenter>
-            <Dialog variant="Primary">
-              <Box
-                direction="Column"
-                justifyContent="Center"
-                alignItems="Center"
-                gap="500"
-                style={{ padding: toRem(60) }}
-              >
-                <Icon size="600" src={Icons.File} />
-                <Text size="H4" align="Center">
-                  {`Drop Files in "${room?.name || 'Room'}"`}
-                </Text>
-                <Text align="Center">Drag and drop files here or click for selection dialog</Text>
-              </Box>
-            </Dialog>
-          </OverlayCenter>
-        </Overlay>
-        {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
-          <RoomMentionAutocomplete
-            roomId={roomId}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
-          <UserMentionAutocomplete
-            room={room}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
-          <EmoticonAutocomplete
-            imagePackRooms={imagePackRooms}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
-          <CommandAutocomplete
-            room={room}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        <CustomEditor
-          editableName="RoomInput"
-          editor={editor}
-          placeholder="Send a message..."
-          onKeyDown={handleKeyDown}
-          onKeyUp={handleKeyUp}
-          onPaste={handlePaste}
-          top={
-            replyDraft && (
-              <div>
-                <Box
-                  alignItems="Center"
-                  gap="300"
-                  style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
-                >
-                  <IconButton
-                    onClick={() => setReplyDraft()}
-                    variant="SurfaceVariant"
-                    size="300"
-                    radii="300"
-                  >
-                    <Icon src={Icons.Cross} size="50" />
-                  </IconButton>
-                  <MessageReply
-                    color={colorMXID(replyDraft.userId)}
-                    name={room?.getMember(replyDraft.userId)?.name ?? replyDraft.userId}
-                    body={replyDraft.body}
-                  />
-                </Box>
-              </div>
-            )
-          }
-          before={
-            <IconButton
-              onClick={() => pickFile('*')}
-              variant="SurfaceVariant"
-              size="300"
-              radii="300"
-            >
-              <Icon src={Icons.PlusCircle} />
-            </IconButton>
-          }
-          after={
-            <>
-              <IconButton
-                variant="SurfaceVariant"
-                size="300"
-                radii="300"
-                onClick={() => setToolbar(!toolbar)}
-              >
-                <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
-              </IconButton>
-              <UseStateProvider initial={undefined}>
-                {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
-                  <PopOut
-                    offset={16}
-                    alignOffset={-44}
-                    position="Top"
-                    align="End"
-                    open={!!emojiBoardTab}
-                    content={
-                      <EmojiBoard
-                        tab={emojiBoardTab}
-                        onTabChange={setEmojiBoardTab}
-                        imagePackRooms={imagePackRooms}
-                        returnFocusOnDeactivate={false}
-                        onEmojiSelect={handleEmoticonSelect}
-                        onCustomEmojiSelect={handleEmoticonSelect}
-                        onStickerSelect={handleStickerSelect}
-                        requestClose={() => {
-                          setEmojiBoardTab(undefined);
-                          if (!mobileOrTablet()) ReactEditor.focus(editor);
-                        }}
-                      />
-                    }
-                  >
-                    {(anchorRef) => (
-                      <>
-                        {!hideStickerBtn && (
-                          <IconButton
-                            aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
-                            onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
-                            variant="SurfaceVariant"
-                            size="300"
-                            radii="300"
-                          >
-                            <Icon
-                              src={Icons.Sticker}
-                              filled={emojiBoardTab === EmojiBoardTab.Sticker}
-                            />
-                          </IconButton>
-                        )}
-                        <IconButton
-                          ref={anchorRef}
-                          aria-pressed={
-                            hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
-                          }
-                          onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
-                          variant="SurfaceVariant"
-                          size="300"
-                          radii="300"
-                        >
-                          <Icon
-                            src={Icons.Smile}
-                            filled={
-                              hideStickerBtn
-                                ? !!emojiBoardTab
-                                : emojiBoardTab === EmojiBoardTab.Emoji
-                            }
-                          />
-                        </IconButton>
-                      </>
-                    )}
-                  </PopOut>
-                )}
-              </UseStateProvider>
-              <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
-                <Icon src={Icons.Send} />
-              </IconButton>
-            </>
-          }
-          bottom={
-            toolbar && (
-              <div>
-                <Line variant="SurfaceVariant" size="300" />
-                <Toolbar />
-              </div>
-            )
-          }
-        />
-      </div>
-    );
-  }
-);
diff --git a/src/app/organisms/room/RoomInputPlaceholder.css.ts b/src/app/organisms/room/RoomInputPlaceholder.css.ts
deleted file mode 100644 (file)
index d0873da..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { color, config, toRem } from 'folds';
-
-export const RoomInputPlaceholder = style({
-  minHeight: toRem(48),
-  backgroundColor: color.SurfaceVariant.Container,
-  color: color.SurfaceVariant.OnContainer,
-  boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
-  borderRadius: config.radii.R400,
-});
diff --git a/src/app/organisms/room/RoomInputPlaceholder.tsx b/src/app/organisms/room/RoomInputPlaceholder.tsx
deleted file mode 100644 (file)
index 77c7ccf..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-import React, { ComponentProps } from 'react';
-import { Box, as } from 'folds';
-import classNames from 'classnames';
-
-import * as css from './RoomInputPlaceholder.css';
-
-export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
-  ({ className, ...props }, ref) => (
-    <Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
-  )
-);
index 6327734713a7b5eff56a7aa55ae962a5d321e427..1e617ae7ee2879a4ef950182767ddf2bca23c5b9 100644 (file)
@@ -2,22 +2,15 @@ import React, { useState, useEffect } from 'react';
 import PropTypes from 'prop-types';
 import './RoomSettings.scss';
 
-import { blurOnBubbling } from '../../atoms/button/script';
-
 import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
 import navigation from '../../../client/state/navigation';
-import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
 import * as roomActions from '../../../client/action/room';
 
 import Text from '../../atoms/text/Text';
-import RawIcon from '../../atoms/system-icons/RawIcon';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import ScrollView from '../../atoms/scroll/ScrollView';
 import Tabs from '../../atoms/tabs/Tabs';
 import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
 import RoomProfile from '../../molecules/room-profile/RoomProfile';
-import RoomSearch from '../../molecules/room-search/RoomSearch';
 import RoomNotification from '../../molecules/room-notification/RoomNotification';
 import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
 import RoomAliases from '../../molecules/room-aliases/RoomAliases';
@@ -30,67 +23,59 @@ import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
 import UserIC from '../../../../public/res/ic/outlined/user.svg';
 import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
 import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
-import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
 import LockIC from '../../../../public/res/ic/outlined/lock.svg';
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
 import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 
-import { useForceUpdate } from '../../hooks/useForceUpdate';
 import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import IconButton from '../../atoms/button/IconButton';
 
 const tabText = {
   GENERAL: 'General',
-  SEARCH: 'Search',
   MEMBERS: 'Members',
   EMOJIS: 'Emojis',
   PERMISSIONS: 'Permissions',
   SECURITY: 'Security',
 };
 
-const tabItems = [{
-  iconSrc: SettingsIC,
-  text: tabText.GENERAL,
-  disabled: false,
-}, {
-  iconSrc: SearchIC,
-  text: tabText.SEARCH,
-  disabled: false,
-}, {
-  iconSrc: UserIC,
-  text: tabText.MEMBERS,
-  disabled: false,
-}, {
-  iconSrc: EmojiIC,
-  text: tabText.EMOJIS,
-  disabled: false,
-}, {
-  iconSrc: ShieldUserIC,
-  text: tabText.PERMISSIONS,
-  disabled: false,
-}, {
-  iconSrc: LockIC,
-  text: tabText.SECURITY,
-  disabled: false,
-}];
+const tabItems = [
+  {
+    iconSrc: SettingsIC,
+    text: tabText.GENERAL,
+    disabled: false,
+  },
+  {
+    iconSrc: UserIC,
+    text: tabText.MEMBERS,
+    disabled: false,
+  },
+  {
+    iconSrc: EmojiIC,
+    text: tabText.EMOJIS,
+    disabled: false,
+  },
+  {
+    iconSrc: ShieldUserIC,
+    text: tabText.PERMISSIONS,
+    disabled: false,
+  },
+  {
+    iconSrc: LockIC,
+    text: tabText.SECURITY,
+    disabled: false,
+  },
+];
 
 function GeneralSettings({ roomId }) {
   const mx = initMatrix.matrixClient;
   const room = mx.getRoom(roomId);
-  const canInvite = room.canInvite(mx.getUserId());
 
   return (
     <>
       <div className="room-settings__card">
         <MenuHeader>Options</MenuHeader>
-        <MenuItem
-          disabled={!canInvite}
-          onClick={() => openInviteUser(roomId)}
-          iconSrc={AddUserIC}
-        >
-          Invite
-        </MenuItem>
         <MenuItem
           variant="danger"
           onClick={async () => {
@@ -98,7 +83,7 @@ function GeneralSettings({ roomId }) {
               'Leave room',
               `Are you sure that you want to leave "${room.name}" room?`,
               'Leave',
-              'danger',
+              'danger'
             );
             if (!isConfirmed) return;
             roomActions.leave(roomId);
@@ -146,54 +131,52 @@ SecuritySettings.propTypes = {
   roomId: PropTypes.string.isRequired,
 };
 
-function RoomSettings({ roomId }) {
-  const [, forceUpdate] = useForceUpdate();
-  const [selectedTab, setSelectedTab] = useState(tabItems[0]);
-  const room = initMatrix.matrixClient.getRoom(roomId);
-
-  const handleTabChange = (tabItem) => {
-    setSelectedTab(tabItem);
-  };
+function useWindowToggle(setSelectedTab) {
+  const [window, setWindow] = useState(null);
 
   useEffect(() => {
-    let mounted = true;
-    const settingsToggle = (isVisible, tab) => {
-      if (!mounted) return;
-      if (isVisible) {
-        const tabItem = tabItems.find((item) => item.text === tab);
-        if (tabItem) setSelectedTab(tabItem);
-        forceUpdate();
-      } else setTimeout(() => forceUpdate(), 200);
+    const openRoomSettings = (roomId, tab) => {
+      setWindow({ roomId, tabText });
+      const tabItem = tabItems.find((item) => item.text === tab);
+      if (tabItem) setSelectedTab(tabItem);
     };
-    navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
+    navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, openRoomSettings);
     return () => {
-      mounted = false;
-      navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
+      navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, openRoomSettings);
     };
-  }, []);
+  }, [setSelectedTab]);
+
+  const requestClose = () => setWindow(null);
 
-  if (!navigation.isRoomSettings) return null;
+  return [window, requestClose];
+}
+
+function RoomSettings() {
+  const [selectedTab, setSelectedTab] = useState(tabItems[0]);
+  const [window, requestClose] = useWindowToggle(setSelectedTab);
+  const isOpen = window !== null;
+  const roomId = window?.roomId;
+  const room = initMatrix.matrixClient.getRoom(roomId);
+
+  const handleTabChange = (tabItem) => {
+    setSelectedTab(tabItem);
+  };
 
   return (
-    <div className="room-settings">
-      <ScrollView autoHide>
+    <PopupWindow
+      isOpen={isOpen}
+      className="room-settings"
+      title={
+        <Text variant="s1" weight="medium" primary>
+          {isOpen && room.name}
+          <span style={{ color: 'var(--tc-surface-low)' }}> â€” room settings</span>
+        </Text>
+      }
+      contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
+      onRequestClose={requestClose}
+    >
+      {isOpen && (
         <div className="room-settings__content">
-          <Header>
-            <button
-              className="room-settings__header-btn"
-              onClick={() => toggleRoomSettings()}
-              type="button"
-              onMouseUp={(e) => blurOnBubbling(e, '.room-settings__header-btn')}
-            >
-              <TitleWrapper>
-                <Text variant="s1" weight="medium" primary>
-                  {`${room.name}`}
-                  <span style={{ color: 'var(--tc-surface-low)' }}> â€” room settings</span>
-                </Text>
-              </TitleWrapper>
-              <RawIcon size="small" src={ChevronTopIC} />
-            </button>
-          </Header>
           <RoomProfile roomId={roomId} />
           <Tabs
             items={tabItems}
@@ -202,21 +185,16 @@ function RoomSettings({ roomId }) {
           />
           <div className="room-settings__cards-wrapper">
             {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
-            {selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
             {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
             {selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
             {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
             {selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
           </div>
         </div>
-      </ScrollView>
-    </div>
+      )}
+    </PopupWindow>
   );
 }
 
-RoomSettings.propTypes = {
-  roomId: PropTypes.string.isRequired,
-};
-
 export default RoomSettings;
 export { tabText };
index ab7fca5c89b3f8e89d2a7413bc194bc0404cc152..fd3af501c8e0af55652a4f06b67b3aecfb4a10be 100644 (file)
@@ -2,59 +2,18 @@
 @use '../../partials/flex';
 
 .room-settings {
-  height: 100%;
-  & .scrollbar {
-    position: relative;
-  }
-
-  & .header {
-    padding: 0 var(--sp-extra-tight);
+  & .pw {
+    background-color: var(--bg-surface-low);
   }
 
-  &__header-btn {
-    min-width: 0;
-    @extend .cp-fx__row--s-c;
-    @include dir.side(margin, 0, auto);
-    padding: var(--sp-ultra-tight) var(--sp-extra-tight);
-    border-radius: calc(var(--bo-radius) / 2);
-    cursor: pointer;
-    
-    @media (hover:hover) {
-      &:hover {
-        background-color: var(--bg-surface-hover);
-        box-shadow: var(--bs-surface-outline);
-      }
-    }
-    &:focus,
-    &:active {
-      background-color: var(--bg-surface-active);
-      box-shadow: var(--bs-surface-outline);
-      outline: none;
-    }
+  & .room-profile {
+    padding: var(--sp-loose) var(--sp-extra-loose);
   }
 
-  &__content {
-    padding-bottom: calc(2 * var(--sp-extra-loose));
-
-    & .room-profile {
-      margin: var(--sp-extra-loose);
-    }
+  & .tabs__content {
+    padding: 0 var(--sp-normal);
   }
-  
-  & .tabs {
-    position: sticky;
-    top: 0;
-    z-index: 999;
-    width: 100%;
-    background-color: var(--bg-surface-low);
-    box-shadow: 0 -4px 0 var(--bg-surface-low),
-      inset 0 -1px 0 var(--bg-surface-border);
 
-    &__content {
-      padding: 0 var(--sp-normal);
-    }
-  }
-  
   &__cards-wrapper {
     padding: 0 var(--sp-normal);
     @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
@@ -75,7 +34,7 @@
 
 .room-settings .room-permissions__card,
 .room-settings .room-search__form,
-.room-settings .room-search__result-item ,
+.room-settings .room-search__result-item,
 .room-settings .room-members {
   @extend .room-settings__card;
-}
\ No newline at end of file
+}
diff --git a/src/app/organisms/room/RoomTimeline.css.ts b/src/app/organisms/room/RoomTimeline.css.ts
deleted file mode 100644 (file)
index 9cd428e..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
-import { DefaultReset, config } from 'folds';
-
-export const TimelineFloat = recipe({
-  base: [
-    DefaultReset,
-    {
-      position: 'absolute',
-      left: '50%',
-      transform: 'translateX(-50%)',
-      zIndex: 1,
-      minWidth: 'max-content',
-    },
-  ],
-  variants: {
-    position: {
-      Top: {
-        top: config.space.S400,
-      },
-      Bottom: {
-        bottom: config.space.S400,
-      },
-    },
-  },
-  defaultVariants: {
-    position: 'Top',
-  },
-});
-
-export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
diff --git a/src/app/organisms/room/RoomTimeline.tsx b/src/app/organisms/room/RoomTimeline.tsx
deleted file mode 100644 (file)
index 0c74de5..0000000
+++ /dev/null
@@ -1,1827 +0,0 @@
-import React, {
-  Dispatch,
-  MouseEventHandler,
-  RefObject,
-  SetStateAction,
-  useCallback,
-  useEffect,
-  useLayoutEffect,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
-import {
-  Direction,
-  EventTimeline,
-  EventTimelineSet,
-  EventTimelineSetHandlerMap,
-  IEncryptedFile,
-  MatrixClient,
-  MatrixEvent,
-  Room,
-  RoomEvent,
-  RoomEventHandlerMap,
-} from 'matrix-js-sdk';
-import parse, { HTMLReactParserOptions } from 'html-react-parser';
-import classNames from 'classnames';
-import { ReactEditor } from 'slate-react';
-import { Editor } from 'slate';
-import to from 'await-to-js';
-import { useSetAtom } from 'jotai';
-import {
-  Badge,
-  Box,
-  Chip,
-  ContainerColor,
-  Icon,
-  Icons,
-  Line,
-  Scroll,
-  Text,
-  as,
-  color,
-  config,
-  toRem,
-} from 'folds';
-import { isKeyHotkey } from 'is-hotkey';
-import {
-  decryptFile,
-  eventWithShortcode,
-  factoryEventSentBy,
-  getMxIdLocalPart,
-  isRoomId,
-  isUserId,
-} from '../../utils/matrix';
-import { sanitizeCustomHtml } from '../../utils/sanitize';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
-import { useAlive } from '../../hooks/useAlive';
-import { editableActiveElement, scrollToBottom } from '../../utils/dom';
-import {
-  DefaultPlaceholder,
-  CompactPlaceholder,
-  Reply,
-  MessageBase,
-  MessageDeletedContent,
-  MessageBrokenContent,
-  MessageUnsupportedContent,
-  MessageEditedContent,
-  MessageEmptyContent,
-  AttachmentBox,
-  Attachment,
-  AttachmentContent,
-  AttachmentHeader,
-  Time,
-  MessageBadEncryptedContent,
-  MessageNotDecryptedContent,
-  MessageTextBody,
-} from '../../components/message';
-import {
-  emojifyAndLinkify,
-  getReactCustomHtmlParser,
-} from '../../plugins/react-custom-html-parser';
-import {
-  canEditEvent,
-  decryptAllTimelineEvent,
-  getEditedEvent,
-  getEventReactions,
-  getLatestEditableEvt,
-  getMemberDisplayName,
-  getReactionContent,
-  isMembershipChanged,
-  reactionOrEditEvent,
-  trimReplyFromBody,
-} from '../../utils/room';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-import {
-  openJoinAlias,
-  openProfileViewer,
-  selectRoom,
-  selectTab,
-} from '../../../client/action/navigation';
-import { useForceUpdate } from '../../hooks/useForceUpdate';
-import { parseGeoUri, scaleYDimension } from '../../utils/common';
-import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
-import { useRoomMsgContentRenderer } from '../../hooks/useRoomMsgContentRenderer';
-import { IAudioContent, IImageContent, IVideoContent } from '../../../types/matrix/common';
-import { getBlobSafeMimeType } from '../../utils/mimeTypes';
-import {
-  ImageContent,
-  VideoContent,
-  FileHeader,
-  fileRenderer,
-  AudioContent,
-  Reactions,
-  EventContent,
-  Message,
-  Event,
-  EncryptedContent,
-  StickerContent,
-} from './message';
-import { useMemberEventParser } from '../../hooks/useMemberEventParser';
-import * as customHtmlCss from '../../styles/CustomHtml.css';
-import { RoomIntro } from '../../components/room-intro';
-import {
-  getIntersectionObserverEntry,
-  useIntersectionObserver,
-} from '../../hooks/useIntersectionObserver';
-import { markAsRead } from '../../../client/action/notifications';
-import { useDebounce } from '../../hooks/useDebounce';
-import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
-import * as css from './RoomTimeline.css';
-import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
-import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
-import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
-import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
-import { MessageEvent } from '../../../types/matrix/room';
-import initMatrix from '../../../client/initMatrix';
-import { useKeyDown } from '../../hooks/useKeyDown';
-import cons from '../../../client/state/cons';
-import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
-import { EMOJI_PATTERN, HTTP_URL_PATTERN, VARIATION_SELECTOR_PATTERN } from '../../utils/regex';
-import { UrlPreviewCard, UrlPreviewHolder } from './message/UrlPreviewCard';
-
-// Thumbs up emoji found to have Variation Selector 16 at the end
-// so included variation selector pattern in regex
-const JUMBO_EMOJI_REG = new RegExp(
-  `^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
-);
-const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
-
-const TimelineFloat = as<'div', css.TimelineFloatVariants>(
-  ({ position, className, ...props }, ref) => (
-    <Box
-      className={classNames(css.TimelineFloat({ position }), className)}
-      justifyContent="Center"
-      alignItems="Center"
-      gap="200"
-      {...props}
-      ref={ref}
-    />
-  )
-);
-
-const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
-  ({ variant, children, ...props }, ref) => (
-    <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
-      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
-      {children}
-      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
-    </Box>
-  )
-);
-
-export const getLiveTimeline = (room: Room): EventTimeline =>
-  room.getUnfilteredTimelineSet().getLiveTimeline();
-
-export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
-  const timelineSet = room.getUnfilteredTimelineSet();
-  return timelineSet.getTimelineForEvent(eventId) ?? undefined;
-};
-
-export const getFirstLinkedTimeline = (
-  timeline: EventTimeline,
-  direction: Direction
-): EventTimeline => {
-  const linkedTm = timeline.getNeighbouringTimeline(direction);
-  if (!linkedTm) return timeline;
-  return getFirstLinkedTimeline(linkedTm, direction);
-};
-
-export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
-  const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
-  const timelines: EventTimeline[] = [];
-
-  for (
-    let nextTimeline: EventTimeline | null = firstTimeline;
-    nextTimeline;
-    nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
-  ) {
-    timelines.push(nextTimeline);
-  }
-  return timelines;
-};
-
-export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
-export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
-  const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
-    count + timelineToEventsCount(tm);
-  return timelines.reduce(timelineEventCountReducer, 0);
-};
-
-export const getTimelineAndBaseIndex = (
-  timelines: EventTimeline[],
-  index: number
-): [EventTimeline | undefined, number] => {
-  let uptoTimelineLen = 0;
-  const timeline = timelines.find((t) => {
-    uptoTimelineLen += t.getEvents().length;
-    if (index < uptoTimelineLen) return true;
-    return false;
-  });
-  if (!timeline) return [undefined, 0];
-  return [timeline, uptoTimelineLen - timeline.getEvents().length];
-};
-
-export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
-  absoluteIndex - timelineBaseIndex;
-
-export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
-  timeline.getEvents()[index];
-
-export const getEventIdAbsoluteIndex = (
-  timelines: EventTimeline[],
-  eventTimeline: EventTimeline,
-  eventId: string
-): number | undefined => {
-  const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
-  if (timelineIndex === -1) return undefined;
-  const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
-  if (eventIndex === -1) return undefined;
-  const baseIndex = timelines
-    .slice(0, timelineIndex)
-    .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
-  return baseIndex + eventIndex;
-};
-
-export const factoryGetFileSrcUrl =
-  (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
-    if (encFile) {
-      if (typeof httpUrl !== 'string') throw new Error('Malformed event');
-      const encRes = await fetch(httpUrl, { method: 'GET' });
-      const encData = await encRes.arrayBuffer();
-      const decryptedBlob = await decryptFile(encData, mimeType, encFile);
-      return URL.createObjectURL(decryptedBlob);
-    }
-    return httpUrl;
-  };
-
-type RoomTimelineProps = {
-  room: Room;
-  eventId?: string;
-  roomInputRef: RefObject<HTMLElement>;
-  editor: Editor;
-};
-
-const PAGINATION_LIMIT = 80;
-
-type Timeline = {
-  linkedTimelines: EventTimeline[];
-  range: ItemRange;
-};
-
-const useEventTimelineLoader = (
-  mx: MatrixClient,
-  room: Room,
-  onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
-  onError: (err: Error | null) => void
-) => {
-  const loadEventTimeline = useCallback(
-    async (eventId: string) => {
-      const [err, replyEvtTimeline] = await to(
-        mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
-      );
-      if (!replyEvtTimeline) {
-        onError(err ?? null);
-        return;
-      }
-      const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
-      const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
-
-      if (absIndex === undefined) {
-        onError(err ?? null);
-        return;
-      }
-
-      onLoad(eventId, linkedTimelines, absIndex);
-    },
-    [mx, room, onLoad, onError]
-  );
-
-  return loadEventTimeline;
-};
-
-const useTimelinePagination = (
-  mx: MatrixClient,
-  timeline: Timeline,
-  setTimeline: Dispatch<SetStateAction<Timeline>>,
-  limit: number
-) => {
-  const timelineRef = useRef(timeline);
-  timelineRef.current = timeline;
-  const alive = useAlive();
-
-  const handleTimelinePagination = useMemo(() => {
-    let fetching = false;
-
-    const recalibratePagination = (
-      linkedTimelines: EventTimeline[],
-      timelinesEventsCount: number[],
-      backwards: boolean
-    ) => {
-      const topTimeline = linkedTimelines[0];
-      const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
-
-      const newLTimelines = getLinkedTimelines(topTimeline);
-      const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
-      const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
-
-      const topTmAddedEvt =
-        timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
-      const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
-
-      setTimeline((currentTimeline) => ({
-        linkedTimelines: newLTimelines,
-        range:
-          offsetRange > 0
-            ? {
-                start: currentTimeline.range.start + offsetRange,
-                end: currentTimeline.range.end + offsetRange,
-              }
-            : { ...currentTimeline.range },
-      }));
-    };
-
-    return async (backwards: boolean) => {
-      if (fetching) return;
-      const { linkedTimelines: lTimelines } = timelineRef.current;
-      const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
-
-      const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
-      if (!timelineToPaginate) return;
-
-      const paginationToken = timelineToPaginate.getPaginationToken(
-        backwards ? Direction.Backward : Direction.Forward
-      );
-      if (
-        !paginationToken &&
-        getTimelinesEventsCount(lTimelines) !==
-          getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
-      ) {
-        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
-        return;
-      }
-
-      fetching = true;
-      const [err] = await to(
-        mx.paginateEventTimeline(timelineToPaginate, {
-          backwards,
-          limit,
-        })
-      );
-      if (err) {
-        // TODO: handle pagination error.
-        return;
-      }
-      const fetchedTimeline =
-        timelineToPaginate.getNeighbouringTimeline(
-          backwards ? Direction.Backward : Direction.Forward
-        ) ?? timelineToPaginate;
-      // Decrypt all event ahead of render cycle
-      if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
-        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
-      }
-
-      fetching = false;
-      if (alive()) {
-        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
-      }
-    };
-  }, [mx, alive, setTimeline, limit]);
-  return handleTimelinePagination;
-};
-
-const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
-  useEffect(() => {
-    const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
-      mEvent,
-      eventRoom,
-      toStartOfTimeline,
-      removed,
-      data
-    ) => {
-      if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
-      onArrive(mEvent);
-    };
-    const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
-      if (eventRoom?.roomId !== room.roomId) return;
-      onArrive(mEvent);
-    };
-
-    room.on(RoomEvent.Timeline, handleTimelineEvent);
-    room.on(RoomEvent.Redaction, handleRedaction);
-    return () => {
-      room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
-      room.removeListener(RoomEvent.Redaction, handleRedaction);
-    };
-  }, [room, onArrive]);
-};
-
-const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
-  useEffect(() => {
-    const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
-      if (r.roomId !== room.roomId) return;
-      onRefresh();
-    };
-
-    room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
-    return () => {
-      room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
-    };
-  }, [room, onRefresh]);
-};
-
-const getInitialTimeline = (room: Room) => {
-  const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
-  const evLength = getTimelinesEventsCount(linkedTimelines);
-  return {
-    linkedTimelines,
-    range: {
-      start: Math.max(evLength - PAGINATION_LIMIT, 0),
-      end: evLength,
-    },
-  };
-};
-
-const getEmptyTimeline = () => ({
-  range: { start: 0, end: 0 },
-  linkedTimelines: [],
-});
-
-const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
-  const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
-  if (!readUptoEventId) return undefined;
-  const evtTimeline = getEventTimeline(room, readUptoEventId);
-  const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
-  return {
-    readUptoEventId,
-    inLiveTimeline: latestTimeline === room.getLiveTimeline(),
-    scrollTo,
-  };
-};
-
-export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
-  const mx = useMatrixClient();
-  const encryptedRoom = mx.isRoomEncrypted(room.roomId);
-  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
-  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
-  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
-  const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
-  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
-  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
-  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
-  const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
-  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
-  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
-  const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
-  const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
-  const canRedact = canDoAction('redact', myPowerLevel);
-  const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
-  const [editId, setEditId] = useState<string>();
-
-  const imagePackRooms: Room[] = useMemo(() => {
-    const allParentSpaces = [
-      room.roomId,
-      ...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
-    ];
-    return allParentSpaces.reduce<Room[]>((list, rId) => {
-      const r = mx.getRoom(rId);
-      if (r) list.push(r);
-      return list;
-    }, []);
-  }, [mx, room]);
-
-  const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
-  const readUptoEventIdRef = useRef<string>();
-  if (unreadInfo) {
-    readUptoEventIdRef.current = unreadInfo.readUptoEventId;
-  }
-
-  const atBottomAnchorRef = useRef<HTMLElement>(null);
-  const [atBottom, setAtBottom] = useState<boolean>(true);
-  const atBottomRef = useRef(atBottom);
-  atBottomRef.current = atBottom;
-
-  const scrollRef = useRef<HTMLDivElement>(null);
-  const scrollToBottomRef = useRef({
-    count: 0,
-    smooth: true,
-  });
-
-  const focusItem = useRef<{
-    index: number;
-    scrollTo: boolean;
-    highlight: boolean;
-  }>();
-  const alive = useAlive();
-  const [, forceUpdate] = useForceUpdate();
-
-  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
-    () =>
-      getReactCustomHtmlParser(mx, room, {
-        handleSpoilerClick: (evt) => {
-          const target = evt.currentTarget;
-          if (target.getAttribute('aria-pressed') === 'true') {
-            evt.stopPropagation();
-            target.setAttribute('aria-pressed', 'false');
-            target.style.cursor = 'initial';
-          }
-        },
-        handleMentionClick: (evt) => {
-          const target = evt.currentTarget;
-          const mentionId = target.getAttribute('data-mention-id');
-          if (typeof mentionId !== 'string') return;
-          if (isUserId(mentionId)) {
-            openProfileViewer(mentionId, room.roomId);
-            return;
-          }
-          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
-            if (mx.getRoom(mentionId)?.isSpaceRoom()) selectTab(mentionId);
-            else selectRoom(mentionId);
-            return;
-          }
-          openJoinAlias(mentionId);
-        },
-      }),
-    [mx, room]
-  );
-  const parseMemberEvent = useMemberEventParser();
-
-  const [timeline, setTimeline] = useState<Timeline>(() =>
-    eventId ? getEmptyTimeline() : getInitialTimeline(room)
-  );
-  const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
-  const liveTimelineLinked =
-    timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
-  const canPaginateBack =
-    typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
-  const rangeAtStart = timeline.range.start === 0;
-  const rangeAtEnd = timeline.range.end === eventsLength;
-  const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
-  atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
-
-  const handleTimelinePagination = useTimelinePagination(
-    mx,
-    timeline,
-    setTimeline,
-    PAGINATION_LIMIT
-  );
-
-  const getScrollElement = useCallback(() => scrollRef.current, []);
-
-  const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
-    useVirtualPaginator({
-      count: eventsLength,
-      limit: PAGINATION_LIMIT,
-      range: timeline.range,
-      onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
-      getScrollElement,
-      getItemElement: useCallback(
-        (index: number) =>
-          (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
-          undefined,
-        []
-      ),
-      onEnd: handleTimelinePagination,
-    });
-
-  const loadEventTimeline = useEventTimelineLoader(
-    mx,
-    room,
-    useCallback(
-      (evtId, lTimelines, evtAbsIndex) => {
-        if (!alive()) return;
-        const evLength = getTimelinesEventsCount(lTimelines);
-
-        focusItem.current = {
-          index: evtAbsIndex,
-          scrollTo: true,
-          highlight: evtId !== readUptoEventIdRef.current,
-        };
-        setTimeline({
-          linkedTimelines: lTimelines,
-          range: {
-            start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
-            end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
-          },
-        });
-      },
-      [alive]
-    ),
-    useCallback(() => {
-      if (!alive()) return;
-      setTimeline(getInitialTimeline(room));
-      scrollToBottomRef.current.count += 1;
-      scrollToBottomRef.current.smooth = false;
-    }, [alive, room])
-  );
-
-  useLiveEventArrive(
-    room,
-    useCallback(
-      (mEvt: MatrixEvent) => {
-        // if user is at bottom of timeline
-        // keep paginating timeline and conditionally mark as read
-        // otherwise we update timeline without paginating
-        // so timeline can be updated with evt like: edits, reactions etc
-        if (atBottomRef.current) {
-          if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
-            requestAnimationFrame(() => markAsRead(mEvt.getRoomId()));
-          }
-
-          if (document.hasFocus()) {
-            scrollToBottomRef.current.count += 1;
-            scrollToBottomRef.current.smooth = true;
-          } else if (!unreadInfo) {
-            setUnreadInfo(getRoomUnreadInfo(room));
-          }
-          setTimeline((ct) => ({
-            ...ct,
-            range: {
-              start: ct.range.start + 1,
-              end: ct.range.end + 1,
-            },
-          }));
-          return;
-        }
-        setTimeline((ct) => ({ ...ct }));
-        if (!unreadInfo) {
-          setUnreadInfo(getRoomUnreadInfo(room));
-        }
-      },
-      [mx, room, unreadInfo]
-    )
-  );
-
-  useLiveTimelineRefresh(
-    room,
-    useCallback(() => {
-      if (liveTimelineLinked) {
-        setTimeline(getInitialTimeline(room));
-      }
-    }, [room, liveTimelineLinked])
-  );
-
-  // Stay at bottom when room editor resize
-  useResizeObserver(
-    useMemo(() => {
-      let mounted = false;
-      return (entries) => {
-        if (!mounted) {
-          // skip initial mounting call
-          mounted = true;
-          return;
-        }
-        if (!roomInputRef.current) return;
-        const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
-        const scrollElement = getScrollElement();
-        if (!editorBaseEntry || !scrollElement) return;
-
-        if (atBottomRef.current) {
-          scrollToBottom(scrollElement);
-        }
-      };
-    }, [getScrollElement, roomInputRef]),
-    useCallback(() => roomInputRef.current, [roomInputRef])
-  );
-
-  const tryAutoMarkAsRead = useCallback(() => {
-    if (!unreadInfo) {
-      requestAnimationFrame(() => markAsRead(room.roomId));
-      return;
-    }
-    const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
-    const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
-    if (latestTimeline === room.getLiveTimeline()) {
-      requestAnimationFrame(() => markAsRead(room.roomId));
-    }
-  }, [room, unreadInfo]);
-
-  const debounceSetAtBottom = useDebounce(
-    useCallback((entry: IntersectionObserverEntry) => {
-      if (!entry.isIntersecting) setAtBottom(false);
-    }, []),
-    { wait: 1000 }
-  );
-  useIntersectionObserver(
-    useCallback(
-      (entries) => {
-        const target = atBottomAnchorRef.current;
-        if (!target) return;
-        const targetEntry = getIntersectionObserverEntry(target, entries);
-        if (targetEntry) debounceSetAtBottom(targetEntry);
-        if (targetEntry?.isIntersecting && atLiveEndRef.current) {
-          setAtBottom(true);
-          tryAutoMarkAsRead();
-        }
-      },
-      [debounceSetAtBottom, tryAutoMarkAsRead]
-    ),
-    useCallback(
-      () => ({
-        root: getScrollElement(),
-        rootMargin: '100px',
-      }),
-      [getScrollElement]
-    ),
-    useCallback(() => atBottomAnchorRef.current, [])
-  );
-
-  useDocumentFocusChange(
-    useCallback(
-      (inFocus) => {
-        if (inFocus && atBottomRef.current) {
-          tryAutoMarkAsRead();
-        }
-      },
-      [tryAutoMarkAsRead]
-    )
-  );
-
-  // Handle up arrow edit
-  useKeyDown(
-    window,
-    useCallback(
-      (evt) => {
-        if (
-          isKeyHotkey('arrowup', evt) &&
-          editableActiveElement() &&
-          document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
-          isEmptyEditor(editor)
-        ) {
-          const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
-            canEditEvent(mx, mEvt)
-          );
-          const editableEvtId = editableEvt?.getId();
-          if (!editableEvtId) return;
-          setEditId(editableEvtId);
-        }
-      },
-      [mx, room, editor]
-    )
-  );
-
-  useEffect(() => {
-    if (eventId) {
-      setTimeline(getEmptyTimeline());
-      loadEventTimeline(eventId);
-    }
-  }, [eventId, loadEventTimeline]);
-
-  // Scroll to bottom on initial timeline load
-  useLayoutEffect(() => {
-    const scrollEl = scrollRef.current;
-    if (scrollEl) {
-      scrollToBottom(scrollEl);
-    }
-  }, []);
-
-  // if live timeline is linked and unreadInfo change
-  // Scroll to last read message
-  useLayoutEffect(() => {
-    const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
-    if (readUptoEventId && inLiveTimeline && scrollTo) {
-      const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
-      const evtTimeline = getEventTimeline(room, readUptoEventId);
-      const absoluteIndex =
-        evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
-      if (absoluteIndex) {
-        scrollToItem(absoluteIndex, {
-          behavior: 'instant',
-          align: 'start',
-          stopInView: true,
-        });
-      }
-    }
-  }, [room, unreadInfo, scrollToItem]);
-
-  // scroll to focused message
-  const focusItm = focusItem.current;
-  useLayoutEffect(() => {
-    if (focusItm && focusItm.scrollTo) {
-      scrollToItem(focusItm.index, {
-        behavior: 'instant',
-        align: 'center',
-        stopInView: true,
-      });
-    }
-
-    focusItem.current = undefined;
-  }, [focusItm, scrollToItem]);
-
-  // scroll to bottom of timeline
-  const scrollToBottomCount = scrollToBottomRef.current.count;
-  useLayoutEffect(() => {
-    if (scrollToBottomCount > 0) {
-      const scrollEl = scrollRef.current;
-      if (scrollEl)
-        scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
-    }
-  }, [scrollToBottomCount]);
-
-  // Remove unreadInfo on mark as read
-  useEffect(() => {
-    const handleFullRead = (rId: string) => {
-      if (rId !== room.roomId) return;
-      setUnreadInfo(undefined);
-    };
-    initMatrix.notifications?.on(cons.events.notifications.FULL_READ, handleFullRead);
-    return () => {
-      initMatrix.notifications?.removeListener(cons.events.notifications.FULL_READ, handleFullRead);
-    };
-  }, [room]);
-
-  // scroll out of view msg editor in view.
-  useEffect(() => {
-    if (editId) {
-      const editMsgElement =
-        (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
-        undefined;
-      if (editMsgElement) {
-        scrollToElement(editMsgElement, {
-          align: 'center',
-          behavior: 'smooth',
-          stopInView: true,
-        });
-      }
-    }
-  }, [scrollToElement, editId]);
-
-  const handleJumpToLatest = () => {
-    setTimeline(getInitialTimeline(room));
-    scrollToBottomRef.current.count += 1;
-    scrollToBottomRef.current.smooth = false;
-  };
-
-  const handleJumpToUnread = () => {
-    if (unreadInfo?.readUptoEventId) {
-      setTimeline(getEmptyTimeline());
-      loadEventTimeline(unreadInfo.readUptoEventId);
-    }
-  };
-
-  const handleMarkAsRead = () => {
-    markAsRead(room.roomId);
-  };
-
-  const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
-    async (evt) => {
-      const replyId = evt.currentTarget.getAttribute('data-reply-id');
-      if (typeof replyId !== 'string') return;
-      const replyTimeline = getEventTimeline(room, replyId);
-      const absoluteIndex =
-        replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
-
-      if (typeof absoluteIndex === 'number') {
-        scrollToItem(absoluteIndex, {
-          behavior: 'smooth',
-          align: 'center',
-          stopInView: true,
-        });
-        focusItem.current = {
-          index: absoluteIndex,
-          scrollTo: false,
-          highlight: true,
-        };
-        forceUpdate();
-      } else {
-        setTimeline(getEmptyTimeline());
-        loadEventTimeline(replyId);
-      }
-    },
-    [room, timeline, scrollToItem, loadEventTimeline, forceUpdate]
-  );
-
-  const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
-    (evt) => {
-      evt.preventDefault();
-      evt.stopPropagation();
-      const userId = evt.currentTarget.getAttribute('data-user-id');
-      if (!userId) {
-        console.warn('Button should have "data-user-id" attribute!');
-        return;
-      }
-      openProfileViewer(userId, room.roomId);
-    },
-    [room]
-  );
-  const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
-    (evt) => {
-      evt.preventDefault();
-      const userId = evt.currentTarget.getAttribute('data-user-id');
-      if (!userId) {
-        console.warn('Button should have "data-user-id" attribute!');
-        return;
-      }
-      const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
-      editor.insertNode(
-        createMentionElement(
-          userId,
-          name.startsWith('@') ? name : `@${name}`,
-          userId === mx.getUserId()
-        )
-      );
-      ReactEditor.focus(editor);
-      moveCursor(editor);
-    },
-    [mx, room, editor]
-  );
-
-  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
-    (evt) => {
-      const replyId = evt.currentTarget.getAttribute('data-event-id');
-      if (!replyId) {
-        console.warn('Button should have "data-event-id" attribute!');
-        return;
-      }
-      const replyEvt = room.findEventById(replyId);
-      if (!replyEvt) return;
-      const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
-      const { body, formatted_body: formattedBody }: Record<string, string> =
-        editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
-      const senderId = replyEvt.getSender();
-      if (senderId && typeof body === 'string') {
-        setReplyDraft({
-          userId: senderId,
-          eventId: replyId,
-          body,
-          formattedBody,
-        });
-        setTimeout(() => ReactEditor.focus(editor), 100);
-      }
-    },
-    [room, setReplyDraft, editor]
-  );
-
-  const handleReactionToggle = useCallback(
-    (targetEventId: string, key: string, shortcode?: string) => {
-      const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
-      const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
-      const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
-      const reactions = reactionsSet ? Array.from(reactionsSet) : [];
-      const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
-
-      if (myReaction && !!myReaction?.isRelation()) {
-        mx.redactEvent(room.roomId, myReaction.getId()!);
-        return;
-      }
-      const rShortcode =
-        shortcode ||
-        (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
-      mx.sendEvent(
-        room.roomId,
-        MessageEvent.Reaction,
-        getReactionContent(targetEventId, key, rShortcode)
-      );
-    },
-    [mx, room]
-  );
-  const handleEdit = useCallback(
-    (editEvtId?: string) => {
-      if (editEvtId) {
-        setEditId(editEvtId);
-        return;
-      }
-      setEditId(undefined);
-      ReactEditor.focus(editor);
-    },
-    [editor]
-  );
-
-  const renderBody = (body: string, customBody?: string) => {
-    if (body === '') <MessageEmptyContent />;
-    if (customBody) {
-      if (customBody === '') <MessageEmptyContent />;
-      return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
-    }
-    return emojifyAndLinkify(body, true);
-  };
-
-  const renderRoomMsgContent = useRoomMsgContentRenderer<[EventTimelineSet]>({
-    renderText: (mEventId, mEvent, timelineSet) => {
-      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
-      const { body, formatted_body: customBody }: Record<string, unknown> =
-        editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-
-      if (typeof body !== 'string') return null;
-      const trimmedBody = trimReplyFromBody(body);
-      const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
-      const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
-
-      return (
-        <>
-          <MessageTextBody
-            preWrap={typeof customBody !== 'string'}
-            jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
-          >
-            {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
-            {!!editedEvent && <MessageEditedContent />}
-          </MessageTextBody>
-          {urls && urls.length > 0 && (
-            <UrlPreviewHolder>
-              {urls.map((url) => (
-                <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
-              ))}
-            </UrlPreviewHolder>
-          )}
-        </>
-      );
-    },
-    renderEmote: (mEventId, mEvent, timelineSet) => {
-      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
-      const { body, formatted_body: customBody } =
-        editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-      const senderId = mEvent.getSender() ?? '';
-
-      const senderDisplayName =
-        getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
-
-      if (typeof body !== 'string') return null;
-      const trimmedBody = trimReplyFromBody(body);
-      const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
-      const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
-
-      return (
-        <>
-          <MessageTextBody
-            emote
-            preWrap={typeof customBody !== 'string'}
-            jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
-          >
-            <b>{`${senderDisplayName} `}</b>
-            {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
-            {!!editedEvent && <MessageEditedContent />}
-          </MessageTextBody>
-          {urls && urls.length > 0 && (
-            <UrlPreviewHolder>
-              {urls.map((url) => (
-                <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
-              ))}
-            </UrlPreviewHolder>
-          )}
-        </>
-      );
-    },
-    renderNotice: (mEventId, mEvent, timelineSet) => {
-      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
-      const { body, formatted_body: customBody }: Record<string, unknown> =
-        editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-
-      if (typeof body !== 'string') return null;
-      const trimmedBody = trimReplyFromBody(body);
-      const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
-      const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
-
-      return (
-        <>
-          <MessageTextBody
-            notice
-            preWrap={typeof customBody !== 'string'}
-            jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
-          >
-            {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
-            {!!editedEvent && <MessageEditedContent />}
-          </MessageTextBody>
-          {urls && urls.length > 0 && (
-            <UrlPreviewHolder>
-              {urls.map((url) => (
-                <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
-              ))}
-            </UrlPreviewHolder>
-          )}
-        </>
-      );
-    },
-    renderImage: (mEventId, mEvent) => {
-      const content = mEvent.getContent<IImageContent>();
-      const imgInfo = content?.info;
-      const mxcUrl = content.file?.url ?? content.url;
-      if (typeof mxcUrl !== 'string') {
-        return null;
-      }
-      const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
-
-      return (
-        <Attachment>
-          <AttachmentBox
-            style={{
-              height: toRem(height < 48 ? 48 : height),
-            }}
-          >
-            <ImageContent
-              body={content.body || 'Image'}
-              info={imgInfo}
-              mimeType={imgInfo?.mimetype}
-              url={mxcUrl}
-              encInfo={content.file}
-              autoPlay={mediaAutoLoad}
-            />
-          </AttachmentBox>
-        </Attachment>
-      );
-    },
-    renderVideo: (mEventId, mEvent) => {
-      const content = mEvent.getContent<IVideoContent>();
-
-      const videoInfo = content?.info;
-      const mxcUrl = content.file?.url ?? content.url;
-      const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
-
-      if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
-        if (mxcUrl) {
-          return fileRenderer(mEventId, mEvent);
-        }
-        return null;
-      }
-
-      const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
-
-      return (
-        <Attachment>
-          <AttachmentBox
-            style={{
-              height: toRem(height < 48 ? 48 : height),
-            }}
-          >
-            <VideoContent
-              body={content.body || 'Video'}
-              info={videoInfo}
-              mimeType={safeMimeType}
-              url={mxcUrl}
-              encInfo={content.file}
-              loadThumbnail={mediaAutoLoad}
-            />
-          </AttachmentBox>
-        </Attachment>
-      );
-    },
-    renderAudio: (mEventId, mEvent) => {
-      const content = mEvent.getContent<IAudioContent>();
-
-      const audioInfo = content?.info;
-      const mxcUrl = content.file?.url ?? content.url;
-      const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
-
-      if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
-        if (mxcUrl) {
-          return fileRenderer(mEventId, mEvent);
-        }
-        return null;
-      }
-
-      return (
-        <Attachment>
-          <AttachmentHeader>
-            <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
-          </AttachmentHeader>
-          <AttachmentBox>
-            <AttachmentContent>
-              <AudioContent
-                info={audioInfo}
-                mimeType={safeMimeType}
-                url={mxcUrl}
-                encInfo={content.file}
-              />
-            </AttachmentContent>
-          </AttachmentBox>
-        </Attachment>
-      );
-    },
-    renderLocation: (mEventId, mEvent) => {
-      const content = mEvent.getContent();
-      const geoUri = content.geo_uri;
-      if (typeof geoUri !== 'string') return null;
-      const location = parseGeoUri(geoUri);
-      return (
-        <Box direction="Column" alignItems="Start" gap="100">
-          <Text size="T400">{geoUri}</Text>
-          <Chip
-            as="a"
-            size="400"
-            href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
-            target="_blank"
-            rel="noreferrer noopener"
-            variant="Primary"
-            radii="Pill"
-            before={<Icon src={Icons.External} size="50" />}
-          >
-            <Text size="B300">Open Location</Text>
-          </Chip>
-        </Box>
-      );
-    },
-    renderFile: fileRenderer,
-    renderBadEncrypted: () => (
-      <Text>
-        <MessageBadEncryptedContent />
-      </Text>
-    ),
-    renderUnsupported: (mEventId, mEvent) => {
-      if (mEvent.isRedacted()) {
-        const redactedEvt = mEvent.getRedactionEvent();
-        const reason =
-          redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
-
-        return (
-          <Text>
-            <MessageDeletedContent reason={reason} />
-          </Text>
-        );
-      }
-      return (
-        <Text>
-          <MessageUnsupportedContent />
-        </Text>
-      );
-    },
-    renderBrokenFallback: (mEventId, mEvent) => {
-      if (mEvent.isRedacted()) {
-        const redactedEvt = mEvent.getRedactionEvent();
-        const reason =
-          redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
-        return (
-          <Text>
-            <MessageDeletedContent reason={reason} />
-          </Text>
-        );
-      }
-      return (
-        <Text>
-          <MessageBrokenContent />
-        </Text>
-      );
-    },
-  });
-
-  const renderMatrixEvent = useMatrixEventRenderer<[number, EventTimelineSet, boolean]>({
-    renderRoomMessage: (mEventId, mEvent, item, timelineSet, collapse) => {
-      const reactionRelations = getEventReactions(timelineSet, mEventId);
-      const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
-      const hasReactions = reactions && reactions.length > 0;
-      const { replyEventId } = mEvent;
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-
-      return (
-        <Message
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          messageSpacing={messageSpacing}
-          messageLayout={messageLayout}
-          collapse={collapse}
-          highlight={highlighted}
-          edit={editId === mEventId}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-          canSendReaction={canSendReaction}
-          imagePackRooms={imagePackRooms}
-          relations={hasReactions ? reactionRelations : undefined}
-          onUserClick={handleUserClick}
-          onUsernameClick={handleUsernameClick}
-          onReplyClick={handleReplyClick}
-          onReactionToggle={handleReactionToggle}
-          onEditId={handleEdit}
-          reply={
-            replyEventId && (
-              <Reply
-                as="button"
-                mx={mx}
-                room={room}
-                timelineSet={timelineSet}
-                eventId={replyEventId}
-                data-reply-id={replyEventId}
-                onClick={handleOpenReply}
-              />
-            )
-          }
-          reactions={
-            reactionRelations && (
-              <Reactions
-                style={{ marginTop: config.space.S200 }}
-                room={room}
-                relations={reactionRelations}
-                mEventId={mEventId}
-                canSendReaction={canSendReaction}
-                onReactionToggle={handleReactionToggle}
-              />
-            )
-          }
-        >
-          {renderRoomMsgContent(mEventId, mEvent, timelineSet)}
-        </Message>
-      );
-    },
-    renderRoomEncrypted: (mEventId, mEvent, item, timelineSet, collapse) => {
-      const reactionRelations = getEventReactions(timelineSet, mEventId);
-      const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
-      const hasReactions = reactions && reactions.length > 0;
-      const { replyEventId } = mEvent;
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-
-      return (
-        <Message
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          messageSpacing={messageSpacing}
-          messageLayout={messageLayout}
-          collapse={collapse}
-          highlight={highlighted}
-          edit={editId === mEventId}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-          canSendReaction={canSendReaction}
-          imagePackRooms={imagePackRooms}
-          relations={hasReactions ? reactionRelations : undefined}
-          onUserClick={handleUserClick}
-          onUsernameClick={handleUsernameClick}
-          onReplyClick={handleReplyClick}
-          onReactionToggle={handleReactionToggle}
-          onEditId={handleEdit}
-          reply={
-            replyEventId && (
-              <Reply
-                as="button"
-                mx={mx}
-                room={room}
-                timelineSet={timelineSet}
-                eventId={replyEventId}
-                data-reply-id={replyEventId}
-                onClick={handleOpenReply}
-              />
-            )
-          }
-          reactions={
-            reactionRelations && (
-              <Reactions
-                style={{ marginTop: config.space.S200 }}
-                room={room}
-                relations={reactionRelations}
-                mEventId={mEventId}
-                canSendReaction={canSendReaction}
-                onReactionToggle={handleReactionToggle}
-              />
-            )
-          }
-        >
-          <EncryptedContent mEvent={mEvent}>
-            {() => {
-              if (mEvent.isRedacted()) return <MessageDeletedContent />;
-              if (mEvent.getType() === MessageEvent.Sticker)
-                return <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />;
-              if (mEvent.getType() === MessageEvent.RoomMessage)
-                return renderRoomMsgContent(mEventId, mEvent, timelineSet);
-              if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
-                return (
-                  <Text>
-                    <MessageNotDecryptedContent />
-                  </Text>
-                );
-              return (
-                <Text>
-                  <MessageUnsupportedContent />
-                </Text>
-              );
-            }}
-          </EncryptedContent>
-        </Message>
-      );
-    },
-    renderSticker: (mEventId, mEvent, item, timelineSet, collapse) => {
-      const reactionRelations = getEventReactions(timelineSet, mEventId);
-      const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
-      const hasReactions = reactions && reactions.length > 0;
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-
-      return (
-        <Message
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          messageSpacing={messageSpacing}
-          messageLayout={messageLayout}
-          collapse={collapse}
-          highlight={highlighted}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-          canSendReaction={canSendReaction}
-          imagePackRooms={imagePackRooms}
-          relations={hasReactions ? reactionRelations : undefined}
-          onUserClick={handleUserClick}
-          onUsernameClick={handleUsernameClick}
-          onReplyClick={handleReplyClick}
-          onReactionToggle={handleReactionToggle}
-          reactions={
-            reactionRelations && (
-              <Reactions
-                style={{ marginTop: config.space.S200 }}
-                room={room}
-                relations={reactionRelations}
-                mEventId={mEventId}
-                canSendReaction={canSendReaction}
-                onReactionToggle={handleReactionToggle}
-              />
-            )
-          }
-        >
-          <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />
-        </Message>
-      );
-    },
-    renderRoomMember: (mEventId, mEvent, item) => {
-      const membershipChanged = isMembershipChanged(mEvent);
-      if (membershipChanged && hideMembershipEvents) return null;
-      if (!membershipChanged && hideNickAvatarEvents) return null;
-
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-      const parsed = parseMemberEvent(mEvent);
-
-      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
-      return (
-        <Event
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          highlight={highlighted}
-          messageSpacing={messageSpacing}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-        >
-          <EventContent
-            messageLayout={messageLayout}
-            time={timeJSX}
-            iconSrc={parsed.icon}
-            content={
-              <Box grow="Yes" direction="Column">
-                <Text size="T300" priority="300">
-                  {parsed.body}
-                </Text>
-              </Box>
-            }
-          />
-        </Event>
-      );
-    },
-    renderRoomName: (mEventId, mEvent, item) => {
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-      const senderId = mEvent.getSender() ?? '';
-      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
-      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
-      return (
-        <Event
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          highlight={highlighted}
-          messageSpacing={messageSpacing}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-        >
-          <EventContent
-            messageLayout={messageLayout}
-            time={timeJSX}
-            iconSrc={Icons.Hash}
-            content={
-              <Box grow="Yes" direction="Column">
-                <Text size="T300" priority="300">
-                  <b>{senderName}</b>
-                  {' changed room name'}
-                </Text>
-              </Box>
-            }
-          />
-        </Event>
-      );
-    },
-    renderRoomTopic: (mEventId, mEvent, item) => {
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-      const senderId = mEvent.getSender() ?? '';
-      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
-      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
-      return (
-        <Event
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          highlight={highlighted}
-          messageSpacing={messageSpacing}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-        >
-          <EventContent
-            messageLayout={messageLayout}
-            time={timeJSX}
-            iconSrc={Icons.Hash}
-            content={
-              <Box grow="Yes" direction="Column">
-                <Text size="T300" priority="300">
-                  <b>{senderName}</b>
-                  {' changed room topic'}
-                </Text>
-              </Box>
-            }
-          />
-        </Event>
-      );
-    },
-    renderRoomAvatar: (mEventId, mEvent, item) => {
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-      const senderId = mEvent.getSender() ?? '';
-      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
-      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
-      return (
-        <Event
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          highlight={highlighted}
-          messageSpacing={messageSpacing}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-        >
-          <EventContent
-            messageLayout={messageLayout}
-            time={timeJSX}
-            iconSrc={Icons.Hash}
-            content={
-              <Box grow="Yes" direction="Column">
-                <Text size="T300" priority="300">
-                  <b>{senderName}</b>
-                  {' changed room avatar'}
-                </Text>
-              </Box>
-            }
-          />
-        </Event>
-      );
-    },
-    renderStateEvent: (mEventId, mEvent, item) => {
-      if (!showHiddenEvents) return null;
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-      const senderId = mEvent.getSender() ?? '';
-      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
-      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
-      return (
-        <Event
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          highlight={highlighted}
-          messageSpacing={messageSpacing}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-        >
-          <EventContent
-            messageLayout={messageLayout}
-            time={timeJSX}
-            iconSrc={Icons.Code}
-            content={
-              <Box grow="Yes" direction="Column">
-                <Text size="T300" priority="300">
-                  <b>{senderName}</b>
-                  {' sent '}
-                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
-                  {' state event'}
-                </Text>
-              </Box>
-            }
-          />
-        </Event>
-      );
-    },
-    renderEvent: (mEventId, mEvent, item) => {
-      if (!showHiddenEvents) return null;
-      if (Object.keys(mEvent.getContent()).length === 0) return null;
-      if (mEvent.getRelation()) return null;
-      if (mEvent.isRedaction()) return null;
-
-      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
-      const senderId = mEvent.getSender() ?? '';
-      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
-
-      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
-
-      return (
-        <Event
-          key={mEvent.getId()}
-          data-message-item={item}
-          data-message-id={mEventId}
-          room={room}
-          mEvent={mEvent}
-          highlight={highlighted}
-          messageSpacing={messageSpacing}
-          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
-        >
-          <EventContent
-            messageLayout={messageLayout}
-            time={timeJSX}
-            iconSrc={Icons.Code}
-            content={
-              <Box grow="Yes" direction="Column">
-                <Text size="T300" priority="300">
-                  <b>{senderName}</b>
-                  {' sent '}
-                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
-                  {' event'}
-                </Text>
-              </Box>
-            }
-          />
-        </Event>
-      );
-    },
-  });
-
-  let prevEvent: MatrixEvent | undefined;
-  let isPrevRendered = false;
-  let newDivider = false;
-  let dayDivider = false;
-  const eventRenderer = (item: number) => {
-    const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
-    if (!eventTimeline) return null;
-    const timelineSet = eventTimeline?.getTimelineSet();
-    const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
-    const mEventId = mEvent?.getId();
-
-    if (!mEvent || !mEventId) return null;
-
-    if (!newDivider && readUptoEventIdRef.current) {
-      newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
-    }
-    if (!dayDivider) {
-      dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
-    }
-
-    const collapsed =
-      isPrevRendered &&
-      !dayDivider &&
-      (!newDivider || mEvent.getSender() === mx.getUserId()) &&
-      prevEvent !== undefined &&
-      prevEvent.getSender() === mEvent.getSender() &&
-      prevEvent.getType() === mEvent.getType() &&
-      minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
-
-    const eventJSX = reactionOrEditEvent(mEvent)
-      ? null
-      : renderMatrixEvent(mEventId, mEvent, item, timelineSet, collapsed);
-    prevEvent = mEvent;
-    isPrevRendered = !!eventJSX;
-
-    const newDividerJSX =
-      newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
-        <MessageBase space={messageSpacing}>
-          <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
-            <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
-              <Text size="L400">New Messages</Text>
-            </Badge>
-          </TimelineDivider>
-        </MessageBase>
-      ) : null;
-
-    const dayDividerJSX =
-      dayDivider && eventJSX ? (
-        <MessageBase space={messageSpacing}>
-          <TimelineDivider variant="Surface">
-            <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
-              <Text size="L400">
-                {(() => {
-                  if (today(mEvent.getTs())) return 'Today';
-                  if (yesterday(mEvent.getTs())) return 'Yesterday';
-                  return timeDayMonthYear(mEvent.getTs());
-                })()}
-              </Text>
-            </Badge>
-          </TimelineDivider>
-        </MessageBase>
-      ) : null;
-
-    if (eventJSX && (newDividerJSX || dayDividerJSX)) {
-      if (newDividerJSX) newDivider = false;
-      if (dayDividerJSX) dayDivider = false;
-
-      return (
-        <React.Fragment key={mEventId}>
-          {newDividerJSX}
-          {dayDividerJSX}
-          {eventJSX}
-        </React.Fragment>
-      );
-    }
-
-    return eventJSX;
-  };
-
-  return (
-    <Box style={{ height: '100%', color: color.Surface.OnContainer }} grow="Yes">
-      {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
-        <TimelineFloat position="Top">
-          <Chip
-            variant="Primary"
-            radii="Pill"
-            outlined
-            before={<Icon size="50" src={Icons.MessageUnread} />}
-            onClick={handleJumpToUnread}
-          >
-            <Text size="L400">Jump to Unread</Text>
-          </Chip>
-
-          <Chip
-            variant="SurfaceVariant"
-            radii="Pill"
-            outlined
-            before={<Icon size="50" src={Icons.CheckTwice} />}
-            onClick={handleMarkAsRead}
-          >
-            <Text size="L400">Mark as Read</Text>
-          </Chip>
-        </TimelineFloat>
-      )}
-      <Scroll ref={scrollRef} visibility="Hover">
-        <Box
-          direction="Column"
-          justifyContent="End"
-          style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
-        >
-          {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
-            <div
-              style={{
-                padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
-                  messageLayout === 1 ? config.space.S400 : toRem(64)
-                }`,
-              }}
-            >
-              <RoomIntro room={room} />
-            </div>
-          )}
-          {(canPaginateBack || !rangeAtStart) &&
-            (messageLayout === 1 ? (
-              <>
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder ref={observeBackAnchor} />
-              </>
-            ) : (
-              <>
-                <DefaultPlaceholder />
-                <DefaultPlaceholder />
-                <DefaultPlaceholder ref={observeBackAnchor} />
-              </>
-            ))}
-
-          {getItems().map(eventRenderer)}
-
-          {(!liveTimelineLinked || !rangeAtEnd) &&
-            (messageLayout === 1 ? (
-              <>
-                <CompactPlaceholder ref={observeFrontAnchor} />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-              </>
-            ) : (
-              <>
-                <DefaultPlaceholder ref={observeFrontAnchor} />
-                <DefaultPlaceholder />
-                <DefaultPlaceholder />
-              </>
-            ))}
-          <span ref={atBottomAnchorRef} />
-        </Box>
-      </Scroll>
-      {!atBottom && (
-        <TimelineFloat position="Bottom">
-          <Chip
-            variant="SurfaceVariant"
-            radii="Pill"
-            outlined
-            before={<Icon size="50" src={Icons.ArrowBottom} />}
-            onClick={handleJumpToLatest}
-          >
-            <Text size="L400">Jump to Latest</Text>
-          </Chip>
-        </TimelineFloat>
-      )}
-    </Box>
-  );
-}
diff --git a/src/app/organisms/room/RoomTombstone.css.ts b/src/app/organisms/room/RoomTombstone.css.ts
deleted file mode 100644 (file)
index c4c0461..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { config } from 'folds';
-
-export const RoomTombstone = style({
-  padding: config.space.S200,
-  paddingLeft: config.space.S400,
-});
diff --git a/src/app/organisms/room/RoomTombstone.tsx b/src/app/organisms/room/RoomTombstone.tsx
deleted file mode 100644 (file)
index 39f0e63..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-import React, { useCallback } from 'react';
-import { Box, Button, Spinner, Text, color } from 'folds';
-
-import { selectRoom } from '../../../client/action/navigation';
-
-import * as css from './RoomTombstone.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { genRoomVia } from '../../../util/matrixUtil';
-import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { Membership } from '../../../types/matrix/room';
-import { RoomInputPlaceholder } from './RoomInputPlaceholder';
-
-type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
-export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
-  const mx = useMatrixClient();
-
-  const [joinState, handleJoin] = useAsyncCallback(
-    useCallback(() => {
-      const currentRoom = mx.getRoom(roomId);
-      const via = currentRoom ? genRoomVia(currentRoom) : [];
-      return mx.joinRoom(replacementRoomId, {
-        viaServers: via,
-      });
-    }, [mx, roomId, replacementRoomId])
-  );
-  const replacementRoom = mx.getRoom(replacementRoomId);
-
-  const handleOpen = () => {
-    if (replacementRoom) selectRoom(replacementRoom.roomId);
-    if (joinState.status === AsyncStatus.Success) selectRoom(joinState.data.roomId);
-  };
-
-  return (
-    <RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
-      <Box direction="Column" grow="Yes">
-        <Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
-        {joinState.status === AsyncStatus.Error && (
-          <Text style={{ color: color.Critical.Main }} size="T200">
-            {(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
-          </Text>
-        )}
-      </Box>
-      {replacementRoom?.getMyMembership() === Membership.Join ||
-      joinState.status === AsyncStatus.Success ? (
-        <Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
-          <Text size="B300">Open New Room</Text>
-        </Button>
-      ) : (
-        <Button
-          onClick={handleJoin}
-          size="300"
-          variant="Primary"
-          fill="Solid"
-          radii="300"
-          before={
-            joinState.status === AsyncStatus.Loading && (
-              <Spinner size="100" variant="Primary" fill="Solid" />
-            )
-          }
-          disabled={joinState.status === AsyncStatus.Loading}
-        >
-          <Text size="B300">Join New Room</Text>
-        </Button>
-      )}
-    </RoomInputPlaceholder>
-  );
-}
diff --git a/src/app/organisms/room/RoomView.jsx b/src/app/organisms/room/RoomView.jsx
deleted file mode 100644 (file)
index 9d97cb6..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './RoomView.scss';
-import { Text, config } from 'folds';
-import { EventType } from 'matrix-js-sdk';
-
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-
-import RoomViewHeader from './RoomViewHeader';
-import { RoomInput } from './RoomInput';
-import { useStateEvent } from '../../hooks/useStateEvent';
-import { StateEvent } from '../../../types/matrix/room';
-import { RoomTombstone } from './RoomTombstone';
-import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { RoomInputPlaceholder } from './RoomInputPlaceholder';
-import { RoomTimeline } from './RoomTimeline';
-import { RoomViewTyping } from './RoomViewTyping';
-import { RoomViewFollowing } from './RoomViewFollowing';
-import { useEditor } from '../../components/editor';
-
-function RoomView({ room, eventId }) {
-  const roomInputRef = useRef(null);
-  const roomViewRef = useRef(null);
-
-  // eslint-disable-next-line react/prop-types
-  const { roomId } = room;
-  const editor = useEditor();
-
-  const mx = useMatrixClient();
-  const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
-  const { getPowerLevel, canSendEvent } = usePowerLevelsAPI();
-  const myUserId = mx.getUserId();
-  const canMessage = myUserId
-    ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
-    : false;
-
-  useEffect(() => {
-    const settingsToggle = (isVisible) => {
-      const roomView = roomViewRef.current;
-      roomView.classList.toggle('room-view--dropped');
-
-      const roomViewContent = roomView.children[1];
-      if (isVisible) {
-        setTimeout(() => {
-          if (!navigation.isRoomSettings) return;
-          roomViewContent.style.visibility = 'hidden';
-        }, 200);
-      } else roomViewContent.style.visibility = 'visible';
-    };
-    navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
-    return () => {
-      navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
-    };
-  }, []);
-
-  return (
-    <div className="room-view" ref={roomViewRef}>
-      <RoomViewHeader roomId={roomId} />
-      <div className="room-view__content-wrapper">
-        <div className="room-view__scrollable">
-          <RoomTimeline
-            key={roomId}
-            room={room}
-            eventId={eventId}
-            roomInputRef={roomInputRef}
-            editor={editor}
-          />
-          <RoomViewTyping room={room} />
-        </div>
-        <div className="room-view__sticky">
-          <div className="room-view__editor">
-            {tombstoneEvent ? (
-              <RoomTombstone
-                roomId={roomId}
-                body={tombstoneEvent.getContent().body}
-                replacementRoomId={tombstoneEvent.getContent().replacement_room}
-              />
-            ) : (
-              <>
-                {canMessage && (
-                  <RoomInput
-                    room={room}
-                    editor={editor}
-                    roomId={roomId}
-                    roomViewRef={roomViewRef}
-                    ref={roomInputRef}
-                  />
-                )}
-                {!canMessage && (
-                  <RoomInputPlaceholder
-                    style={{ padding: config.space.S200 }}
-                    alignItems="Center"
-                    justifyContent="Center"
-                  >
-                    <Text align="Center">You do not have permission to post in this room</Text>
-                  </RoomInputPlaceholder>
-                )}
-              </>
-            )}
-          </div>
-          <RoomViewFollowing room={room} />
-        </div>
-      </div>
-    </div>
-  );
-}
-
-RoomView.defaultProps = {
-  eventId: null,
-};
-RoomView.propTypes = {
-  room: PropTypes.shape({}).isRequired,
-  eventId: PropTypes.string,
-};
-
-export default RoomView;
diff --git a/src/app/organisms/room/RoomViewFollowing.css.ts b/src/app/organisms/room/RoomViewFollowing.css.ts
deleted file mode 100644 (file)
index 0a0358e..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-import { recipe } from '@vanilla-extract/recipes';
-import { DefaultReset, color, config, toRem } from 'folds';
-
-export const RoomViewFollowing = recipe({
-  base: [
-    DefaultReset,
-    {
-      minHeight: toRem(28),
-      padding: `0 ${config.space.S400}`,
-      width: '100%',
-      backgroundColor: color.Surface.Container,
-      color: color.Surface.OnContainer,
-      outline: 'none',
-    },
-  ],
-  variants: {
-    clickable: {
-      true: {
-        cursor: 'pointer',
-        selectors: {
-          '&:hover, &:focus-visible': {
-            color: color.Primary.Main,
-          },
-          '&:active': {
-            color: color.Primary.Main,
-          },
-        },
-      },
-    },
-  },
-});
diff --git a/src/app/organisms/room/RoomViewFollowing.tsx b/src/app/organisms/room/RoomViewFollowing.tsx
deleted file mode 100644 (file)
index 2f7a583..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-import React, { useState } from 'react';
-import {
-  Box,
-  Icon,
-  Icons,
-  Modal,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  Text,
-  as,
-  config,
-} from 'folds';
-import { Room } from 'matrix-js-sdk';
-import classNames from 'classnames';
-import FocusTrap from 'focus-trap-react';
-
-import { getMemberDisplayName } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import * as css from './RoomViewFollowing.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
-import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
-import { EventReaders } from '../../components/event-readers';
-
-export type RoomViewFollowingProps = {
-  room: Room;
-};
-export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
-  ({ className, room, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const [open, setOpen] = useState(false);
-    const latestEvent = useRoomLatestRenderedEvent(room);
-    const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
-    const names = latestEventReaders
-      .filter((readerId) => readerId !== mx.getUserId())
-      .map(
-        (readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId
-      );
-
-    const eventId = latestEvent?.getId();
-
-    return (
-      <>
-        {eventId && (
-          <Overlay open={open} backdrop={<OverlayBackdrop />}>
-            <OverlayCenter>
-              <FocusTrap
-                focusTrapOptions={{
-                  initialFocus: false,
-                  onDeactivate: () => setOpen(false),
-                  clickOutsideDeactivates: true,
-                }}
-              >
-                <Modal variant="Surface" size="300">
-                  <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
-                </Modal>
-              </FocusTrap>
-            </OverlayCenter>
-          </Overlay>
-        )}
-        <Box
-          as={names.length > 0 ? 'button' : 'div'}
-          onClick={names.length > 0 ? () => setOpen(true) : undefined}
-          className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
-          alignItems="Center"
-          justifyContent="End"
-          gap="200"
-          {...props}
-          ref={ref}
-        >
-          {names.length > 0 && (
-            <>
-              <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
-              <Text size="T300" truncate>
-                {names.length === 1 && (
-                  <>
-                    <b>{names[0]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' is following the conversation.'}
-                    </Text>
-                  </>
-                )}
-                {names.length === 2 && (
-                  <>
-                    <b>{names[0]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' and '}
-                    </Text>
-                    <b>{names[1]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' are following the conversation.'}
-                    </Text>
-                  </>
-                )}
-                {names.length === 3 && (
-                  <>
-                    <b>{names[0]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {', '}
-                    </Text>
-                    <b>{names[1]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' and '}
-                    </Text>
-                    <b>{names[2]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' are following the conversation.'}
-                    </Text>
-                  </>
-                )}
-                {names.length > 3 && (
-                  <>
-                    <b>{names[0]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {', '}
-                    </Text>
-                    <b>{names[1]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {', '}
-                    </Text>
-                    <b>{names[2]}</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' and '}
-                    </Text>
-                    <b>{names.length - 3} others</b>
-                    <Text as="span" size="Inherit" priority="300">
-                      {' are following the conversation.'}
-                    </Text>
-                  </>
-                )}
-              </Text>
-            </>
-          )}
-        </Box>
-      </>
-    );
-  }
-);
diff --git a/src/app/organisms/room/RoomViewTyping.css.ts b/src/app/organisms/room/RoomViewTyping.css.ts
deleted file mode 100644 (file)
index 5c90a17..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-import { keyframes, style } from '@vanilla-extract/css';
-import { DefaultReset, color, config } from 'folds';
-
-const SlideUpAnime = keyframes({
-  from: {
-    transform: 'translateY(100%)',
-  },
-  to: {
-    transform: 'translateY(0)',
-  },
-});
-
-export const RoomViewTyping = style([
-  DefaultReset,
-  {
-    padding: `0 ${config.space.S500}`,
-    width: '100%',
-    backgroundColor: color.Surface.Container,
-    color: color.Surface.OnContainer,
-    position: 'absolute',
-    bottom: 0,
-    animation: `${SlideUpAnime} 100ms ease-in-out`,
-  },
-]);
-export const TypingText = style({
-  flexGrow: 1,
-});
diff --git a/src/app/organisms/room/RoomViewTyping.tsx b/src/app/organisms/room/RoomViewTyping.tsx
deleted file mode 100644 (file)
index c393f3a..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-import React, { useMemo } from 'react';
-import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
-import { Room } from 'matrix-js-sdk';
-import classNames from 'classnames';
-import { useAtomValue, useSetAtom } from 'jotai';
-import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
-import { TypingIndicator } from '../../components/typing-indicator';
-import { getMemberDisplayName } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import * as css from './RoomViewTyping.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-
-export type RoomViewTypingProps = {
-  room: Room;
-};
-export const RoomViewTyping = as<'div', RoomViewTypingProps>(
-  ({ className, room, ...props }, ref) => {
-    const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
-    const mx = useMatrixClient();
-    const typingMembers = useAtomValue(
-      useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
-    );
-
-    const typingNames = typingMembers
-      .filter((member) => member.userId !== mx.getUserId())
-      .map((member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId))
-      .reverse();
-
-    if (typingNames.length === 0) {
-      return null;
-    }
-
-    const handleDropAll = () => {
-      // some homeserver does not timeout typing status
-      // we have given option so user can drop their typing status
-      typingMembers.forEach((member) =>
-        setTypingMembers({
-          type: 'DELETE',
-          roomId: room.roomId,
-          member,
-        })
-      );
-    };
-
-    return (
-      <Box
-        className={classNames(css.RoomViewTyping, className)}
-        alignItems="Center"
-        gap="400"
-        {...props}
-        ref={ref}
-      >
-        <TypingIndicator />
-        <Text className={css.TypingText} size="T300" truncate>
-          {typingNames.length === 1 && (
-            <>
-              <b>{typingNames[0]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' is typing...'}
-              </Text>
-            </>
-          )}
-          {typingNames.length === 2 && (
-            <>
-              <b>{typingNames[0]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' and '}
-              </Text>
-              <b>{typingNames[1]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' are typing...'}
-              </Text>
-            </>
-          )}
-          {typingNames.length === 3 && (
-            <>
-              <b>{typingNames[0]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {', '}
-              </Text>
-              <b>{typingNames[1]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' and '}
-              </Text>
-              <b>{typingNames[2]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' are typing...'}
-              </Text>
-            </>
-          )}
-          {typingNames.length > 3 && (
-            <>
-              <b>{typingNames[0]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {', '}
-              </Text>
-              <b>{typingNames[1]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {', '}
-              </Text>
-              <b>{typingNames[2]}</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' and '}
-              </Text>
-              <b>{typingNames.length - 3} others</b>
-              <Text as="span" size="Inherit" priority="300">
-                {' are typing...'}
-              </Text>
-            </>
-          )}
-        </Text>
-        <IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
-          <Icon size="50" src={Icons.Cross} />
-        </IconButton>
-      </Box>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/AudioContent.tsx b/src/app/organisms/room/message/AudioContent.tsx
deleted file mode 100644 (file)
index 83e5dc0..0000000
+++ /dev/null
@@ -1,194 +0,0 @@
-/* eslint-disable jsx-a11y/media-has-caption */
-import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, as, toRem } from 'folds';
-import React, { useCallback, useRef, useState } from 'react';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { Range } from 'react-range';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { getFileSrcUrl } from './util';
-import { IAudioInfo } from '../../../../types/matrix/common';
-import { MediaControl } from '../../../components/media';
-import {
-  PlayTimeCallback,
-  useMediaLoading,
-  useMediaPlay,
-  useMediaPlayTimeCallback,
-  useMediaSeek,
-  useMediaVolume,
-} from '../../../hooks/media';
-import { useThrottle } from '../../../hooks/useThrottle';
-import { secondsToMinutesAndSeconds } from '../../../utils/common';
-
-const PLAY_TIME_THROTTLE_OPS = {
-  wait: 500,
-  immediate: true,
-};
-
-export type AudioContentProps = {
-  mimeType: string;
-  url: string;
-  info: IAudioInfo;
-  encInfo?: EncryptedAttachmentInfo;
-};
-export const AudioContent = as<'div', AudioContentProps>(
-  ({ mimeType, url, info, encInfo, ...props }, ref) => {
-    const mx = useMatrixClient();
-
-    const [srcState, loadSrc] = useAsyncCallback(
-      useCallback(
-        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
-        [mx, url, mimeType, encInfo]
-      )
-    );
-
-    const audioRef = useRef<HTMLAudioElement | null>(null);
-
-    const [currentTime, setCurrentTime] = useState(0);
-    // duration in seconds. (NOTE: info.duration is in milliseconds)
-    const infoDuration = info.duration ?? 0;
-    const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
-
-    const getAudioRef = useCallback(() => audioRef.current, []);
-    const { loading } = useMediaLoading(getAudioRef);
-    const { playing, setPlaying } = useMediaPlay(getAudioRef);
-    const { seek } = useMediaSeek(getAudioRef);
-    const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
-    const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
-      setDuration(d);
-      setCurrentTime(ct);
-    }, []);
-    useMediaPlayTimeCallback(
-      getAudioRef,
-      useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
-    );
-
-    const handlePlay = () => {
-      if (srcState.status === AsyncStatus.Success) {
-        setPlaying(!playing);
-      } else if (srcState.status !== AsyncStatus.Loading) {
-        loadSrc();
-      }
-    };
-
-    return (
-      <MediaControl
-        after={
-          <Range
-            step={1}
-            min={0}
-            max={duration || 1}
-            values={[currentTime]}
-            onChange={(values) => seek(values[0])}
-            renderTrack={(params) => (
-              <div {...params.props}>
-                {params.children}
-                <ProgressBar
-                  as="div"
-                  variant="Secondary"
-                  size="300"
-                  min={0}
-                  max={duration}
-                  value={currentTime}
-                  radii="300"
-                />
-              </div>
-            )}
-            renderThumb={(params) => (
-              <Badge
-                size="300"
-                variant="Secondary"
-                fill="Solid"
-                radii="Pill"
-                outlined
-                {...params.props}
-                style={{
-                  ...params.props.style,
-                  zIndex: 0,
-                }}
-              />
-            )}
-          />
-        }
-        leftControl={
-          <>
-            <Chip
-              onClick={handlePlay}
-              variant="Secondary"
-              radii="300"
-              disabled={srcState.status === AsyncStatus.Loading}
-              before={
-                srcState.status === AsyncStatus.Loading || loading ? (
-                  <Spinner variant="Secondary" size="50" />
-                ) : (
-                  <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
-                )
-              }
-            >
-              <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
-            </Chip>
-
-            <Text size="T200">{`${secondsToMinutesAndSeconds(
-              currentTime
-            )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
-          </>
-        }
-        rightControl={
-          <>
-            <IconButton
-              variant="SurfaceVariant"
-              size="300"
-              radii="Pill"
-              onClick={() => setMute(!mute)}
-              aria-pressed={mute}
-            >
-              <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
-            </IconButton>
-            <Range
-              step={0.1}
-              min={0}
-              max={1}
-              values={[volume]}
-              onChange={(values) => setVolume(values[0])}
-              renderTrack={(params) => (
-                <div {...params.props}>
-                  {params.children}
-                  <ProgressBar
-                    style={{ width: toRem(48) }}
-                    variant="Secondary"
-                    size="300"
-                    min={0}
-                    max={1}
-                    value={volume}
-                    radii="300"
-                  />
-                </div>
-              )}
-              renderThumb={(params) => (
-                <Badge
-                  size="300"
-                  variant="Secondary"
-                  fill="Solid"
-                  radii="Pill"
-                  outlined
-                  {...params.props}
-                  style={{
-                    ...params.props.style,
-                    zIndex: 0,
-                  }}
-                />
-              )}
-            />
-          </>
-        }
-        {...props}
-        ref={ref}
-      >
-        <audio controls={false} autoPlay ref={audioRef}>
-          {srcState.status === AsyncStatus.Success && (
-            <source src={srcState.data} type={mimeType} />
-          )}
-        </audio>
-      </MediaControl>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/EncryptedContent.tsx b/src/app/organisms/room/message/EncryptedContent.tsx
deleted file mode 100644 (file)
index bf0fd19..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
-import React, { ReactNode, useEffect, useState } from 'react';
-
-type EncryptedContentProps = {
-  mEvent: MatrixEvent;
-  children: () => ReactNode;
-};
-
-export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
-  const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted());
-
-  useEffect(() => {
-    const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => {
-      toggleDecrypted((s) => !s);
-    };
-    mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
-    return () => {
-      mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
-    };
-  }, [mEvent]);
-
-  return <>{children()}</>;
-}
diff --git a/src/app/organisms/room/message/EventContent.tsx b/src/app/organisms/room/message/EventContent.tsx
deleted file mode 100644 (file)
index e60333d..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Box, Icon, IconSrc } from 'folds';
-import React, { ReactNode } from 'react';
-import { CompactLayout, ModernLayout } from '../../../components/message';
-
-export type EventContentProps = {
-  messageLayout: number;
-  time: ReactNode;
-  iconSrc: IconSrc;
-  content: ReactNode;
-};
-export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
-  const beforeJSX = (
-    <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
-      {messageLayout === 1 && time}
-      <Box
-        grow={messageLayout === 1 ? undefined : 'Yes'}
-        alignItems="Center"
-        justifyContent="Center"
-      >
-        <Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
-      </Box>
-    </Box>
-  );
-
-  const msgContentJSX = (
-    <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
-      {content}
-      {messageLayout !== 1 && time}
-    </Box>
-  );
-
-  return messageLayout === 1 ? (
-    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
-  ) : (
-    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
-  );
-}
diff --git a/src/app/organisms/room/message/FileContent.tsx b/src/app/organisms/room/message/FileContent.tsx
deleted file mode 100644 (file)
index 9c65966..0000000
+++ /dev/null
@@ -1,269 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import {
-  Box,
-  Button,
-  Icon,
-  Icons,
-  Modal,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  Spinner,
-  Text,
-  Tooltip,
-  TooltipProvider,
-  as,
-} from 'folds';
-import FileSaver from 'file-saver';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import FocusTrap from 'focus-trap-react';
-import { IFileInfo } from '../../../../types/matrix/common';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getFileSrcUrl, getSrcFile } from './util';
-import { bytesToSize } from '../../../utils/common';
-import { TextViewer } from '../../../components/text-viewer';
-import {
-  READABLE_EXT_TO_MIME_TYPE,
-  READABLE_TEXT_MIME_TYPES,
-  getFileNameExt,
-  mimeTypeToExt,
-} from '../../../utils/mimeTypes';
-import { PdfViewer } from '../../../components/Pdf-viewer';
-import * as css from './styles.css';
-
-export type FileContentProps = {
-  body: string;
-  mimeType: string;
-  url: string;
-  info: IFileInfo;
-  encInfo?: EncryptedAttachmentInfo;
-};
-
-const renderErrorButton = (retry: () => void, text: string) => (
-  <TooltipProvider
-    tooltip={
-      <Tooltip variant="Critical">
-        <Text>Failed to load file!</Text>
-      </Tooltip>
-    }
-    position="Top"
-    align="Center"
-  >
-    {(triggerRef) => (
-      <Button
-        ref={triggerRef}
-        size="400"
-        variant="Critical"
-        fill="Soft"
-        outlined
-        radii="300"
-        onClick={retry}
-        before={<Icon size="100" src={Icons.Warning} filled />}
-      >
-        <Text size="B400" truncate>
-          {text}
-        </Text>
-      </Button>
-    )}
-  </TooltipProvider>
-);
-
-function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
-  const mx = useMatrixClient();
-  const [textViewer, setTextViewer] = useState(false);
-
-  const loadSrc = useCallback(
-    () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
-    [mx, url, mimeType, encInfo]
-  );
-
-  const [textState, loadText] = useAsyncCallback(
-    useCallback(async () => {
-      const src = await loadSrc();
-      const blob = await getSrcFile(src);
-      const text = blob.text();
-      setTextViewer(true);
-      return text;
-    }, [loadSrc])
-  );
-
-  return (
-    <>
-      {textState.status === AsyncStatus.Success && (
-        <Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
-          <OverlayCenter>
-            <FocusTrap
-              focusTrapOptions={{
-                initialFocus: false,
-                onDeactivate: () => setTextViewer(false),
-                clickOutsideDeactivates: true,
-              }}
-            >
-              <Modal
-                className={css.ModalWide}
-                size="500"
-                onContextMenu={(evt: any) => evt.stopPropagation()}
-              >
-                <TextViewer
-                  name={body}
-                  text={textState.data}
-                  langName={
-                    READABLE_TEXT_MIME_TYPES.includes(mimeType)
-                      ? mimeTypeToExt(mimeType)
-                      : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
-                  }
-                  requestClose={() => setTextViewer(false)}
-                />
-              </Modal>
-            </FocusTrap>
-          </OverlayCenter>
-        </Overlay>
-      )}
-      {textState.status === AsyncStatus.Error ? (
-        renderErrorButton(loadText, 'Open File')
-      ) : (
-        <Button
-          variant="Secondary"
-          fill="Solid"
-          radii="300"
-          size="400"
-          onClick={() =>
-            textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
-          }
-          disabled={textState.status === AsyncStatus.Loading}
-          before={
-            textState.status === AsyncStatus.Loading ? (
-              <Spinner fill="Solid" size="100" variant="Secondary" />
-            ) : (
-              <Icon size="100" src={Icons.ArrowRight} filled />
-            )
-          }
-        >
-          <Text size="B400" truncate>
-            Open File
-          </Text>
-        </Button>
-      )}
-    </>
-  );
-}
-
-function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
-  const mx = useMatrixClient();
-  const [pdfViewer, setPdfViewer] = useState(false);
-
-  const [pdfState, loadPdf] = useAsyncCallback(
-    useCallback(async () => {
-      const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
-      setPdfViewer(true);
-      return httpUrl;
-    }, [mx, url, mimeType, encInfo])
-  );
-
-  return (
-    <>
-      {pdfState.status === AsyncStatus.Success && (
-        <Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
-          <OverlayCenter>
-            <FocusTrap
-              focusTrapOptions={{
-                initialFocus: false,
-                onDeactivate: () => setPdfViewer(false),
-                clickOutsideDeactivates: true,
-              }}
-            >
-              <Modal
-                className={css.ModalWide}
-                size="500"
-                onContextMenu={(evt: any) => evt.stopPropagation()}
-              >
-                <PdfViewer
-                  name={body}
-                  src={pdfState.data}
-                  requestClose={() => setPdfViewer(false)}
-                />
-              </Modal>
-            </FocusTrap>
-          </OverlayCenter>
-        </Overlay>
-      )}
-      {pdfState.status === AsyncStatus.Error ? (
-        renderErrorButton(loadPdf, 'Open PDF')
-      ) : (
-        <Button
-          variant="Secondary"
-          fill="Solid"
-          radii="300"
-          size="400"
-          onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
-          disabled={pdfState.status === AsyncStatus.Loading}
-          before={
-            pdfState.status === AsyncStatus.Loading ? (
-              <Spinner fill="Solid" size="100" variant="Secondary" />
-            ) : (
-              <Icon size="100" src={Icons.ArrowRight} filled />
-            )
-          }
-        >
-          <Text size="B400" truncate>
-            Open PDF
-          </Text>
-        </Button>
-      )}
-    </>
-  );
-}
-
-function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps) {
-  const mx = useMatrixClient();
-
-  const [downloadState, download] = useAsyncCallback(
-    useCallback(async () => {
-      const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
-      FileSaver.saveAs(httpUrl, body);
-      return httpUrl;
-    }, [mx, url, mimeType, encInfo, body])
-  );
-
-  return downloadState.status === AsyncStatus.Error ? (
-    renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
-  ) : (
-    <Button
-      variant="Secondary"
-      fill="Soft"
-      radii="300"
-      size="400"
-      onClick={() =>
-        downloadState.status === AsyncStatus.Success
-          ? FileSaver.saveAs(downloadState.data, body)
-          : download()
-      }
-      disabled={downloadState.status === AsyncStatus.Loading}
-      before={
-        downloadState.status === AsyncStatus.Loading ? (
-          <Spinner fill="Soft" size="100" variant="Secondary" />
-        ) : (
-          <Icon size="100" src={Icons.Download} filled />
-        )
-      }
-    >
-      <Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
-    </Button>
-  );
-}
-
-export const FileContent = as<'div', FileContentProps>(
-  ({ body, mimeType, url, info, encInfo, ...props }, ref) => (
-    <Box direction="Column" gap="300" {...props} ref={ref}>
-      {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
-        READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
-        <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
-      )}
-      {mimeType === 'application/pdf' && (
-        <ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
-      )}
-      <DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
-    </Box>
-  )
-);
diff --git a/src/app/organisms/room/message/FileHeader.tsx b/src/app/organisms/room/message/FileHeader.tsx
deleted file mode 100644 (file)
index 8d523b3..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Badge, Box, Text, as, toRem } from 'folds';
-import React from 'react';
-import { mimeTypeToExt } from '../../../utils/mimeTypes';
-
-const badgeStyles = { maxWidth: toRem(100) };
-
-export type FileHeaderProps = {
-  body: string;
-  mimeType: string;
-};
-export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
-  <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
-    <Badge style={badgeStyles} variant="Secondary" radii="Pill">
-      <Text size="O400" truncate>
-        {mimeTypeToExt(mimeType)}
-      </Text>
-    </Badge>
-    <Text size="T300" truncate>
-      {body}
-    </Text>
-  </Box>
-));
diff --git a/src/app/organisms/room/message/ImageContent.tsx b/src/app/organisms/room/message/ImageContent.tsx
deleted file mode 100644 (file)
index 6e28802..0000000
+++ /dev/null
@@ -1,181 +0,0 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import {
-  Badge,
-  Box,
-  Button,
-  Icon,
-  Icons,
-  Modal,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  Spinner,
-  Text,
-  Tooltip,
-  TooltipProvider,
-  as,
-} from 'folds';
-import classNames from 'classnames';
-import { BlurhashCanvas } from 'react-blurhash';
-import FocusTrap from 'focus-trap-react';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getFileSrcUrl } from './util';
-import { Image } from '../../../components/media';
-import * as css from './styles.css';
-import { bytesToSize } from '../../../utils/common';
-import { ImageViewer } from '../../../components/image-viewer';
-import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
-
-export type ImageContentProps = {
-  body: string;
-  mimeType?: string;
-  url: string;
-  info?: IImageInfo;
-  encInfo?: EncryptedAttachmentInfo;
-  autoPlay?: boolean;
-};
-export const ImageContent = as<'div', ImageContentProps>(
-  ({ className, body, mimeType, url, info, encInfo, autoPlay, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
-
-    const [load, setLoad] = useState(false);
-    const [error, setError] = useState(false);
-    const [viewer, setViewer] = useState(false);
-
-    const [srcState, loadSrc] = useAsyncCallback(
-      useCallback(
-        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType || FALLBACK_MIMETYPE, encInfo),
-        [mx, url, mimeType, encInfo]
-      )
-    );
-
-    const handleLoad = () => {
-      setLoad(true);
-    };
-    const handleError = () => {
-      setLoad(false);
-      setError(true);
-    };
-
-    const handleRetry = () => {
-      setError(false);
-      loadSrc();
-    };
-
-    useEffect(() => {
-      if (autoPlay) loadSrc();
-    }, [autoPlay, loadSrc]);
-
-    return (
-      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
-        {srcState.status === AsyncStatus.Success && (
-          <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
-            <OverlayCenter>
-              <FocusTrap
-                focusTrapOptions={{
-                  initialFocus: false,
-                  onDeactivate: () => setViewer(false),
-                  clickOutsideDeactivates: true,
-                }}
-              >
-                <Modal
-                  className={css.ModalWide}
-                  size="500"
-                  onContextMenu={(evt: any) => evt.stopPropagation()}
-                >
-                  <ImageViewer
-                    src={srcState.data}
-                    alt={body}
-                    requestClose={() => setViewer(false)}
-                  />
-                </Modal>
-              </FocusTrap>
-            </OverlayCenter>
-          </Overlay>
-        )}
-        {typeof blurHash === 'string' && !load && (
-          <BlurhashCanvas
-            style={{ width: '100%', height: '100%' }}
-            width={32}
-            height={32}
-            hash={blurHash}
-            punch={1}
-          />
-        )}
-        {!autoPlay && srcState.status === AsyncStatus.Idle && (
-          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-            <Button
-              variant="Secondary"
-              fill="Solid"
-              radii="300"
-              size="300"
-              onClick={loadSrc}
-              before={<Icon size="Inherit" src={Icons.Photo} filled />}
-            >
-              <Text size="B300">View</Text>
-            </Button>
-          </Box>
-        )}
-        {srcState.status === AsyncStatus.Success && (
-          <Box className={css.AbsoluteContainer}>
-            <Image
-              alt={body}
-              title={body}
-              src={srcState.data}
-              loading="lazy"
-              onLoad={handleLoad}
-              onError={handleError}
-              onClick={() => setViewer(true)}
-              tabIndex={0}
-            />
-          </Box>
-        )}
-        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
-          !load && (
-            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-              <Spinner variant="Secondary" />
-            </Box>
-          )}
-        {(error || srcState.status === AsyncStatus.Error) && (
-          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-            <TooltipProvider
-              tooltip={
-                <Tooltip variant="Critical">
-                  <Text>Failed to load image!</Text>
-                </Tooltip>
-              }
-              position="Top"
-              align="Center"
-            >
-              {(triggerRef) => (
-                <Button
-                  ref={triggerRef}
-                  size="300"
-                  variant="Critical"
-                  fill="Soft"
-                  outlined
-                  radii="300"
-                  onClick={handleRetry}
-                  before={<Icon size="Inherit" src={Icons.Warning} filled />}
-                >
-                  <Text size="B300">Retry</Text>
-                </Button>
-              )}
-            </TooltipProvider>
-          </Box>
-        )}
-        {!load && typeof info?.size === 'number' && (
-          <Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
-            <Badge variant="Secondary" fill="Soft">
-              <Text size="L400">{bytesToSize(info.size)}</Text>
-            </Badge>
-          </Box>
-        )}
-      </Box>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/Message.tsx b/src/app/organisms/room/message/Message.tsx
deleted file mode 100644 (file)
index 25d894f..0000000
+++ /dev/null
@@ -1,1073 +0,0 @@
-import {
-  Avatar,
-  AvatarFallback,
-  AvatarImage,
-  Box,
-  Button,
-  Dialog,
-  Header,
-  Icon,
-  IconButton,
-  Icons,
-  Input,
-  Line,
-  Menu,
-  MenuItem,
-  Modal,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  PopOut,
-  Spinner,
-  Text,
-  as,
-  color,
-  config,
-} from 'folds';
-import React, {
-  FormEventHandler,
-  MouseEventHandler,
-  ReactNode,
-  useCallback,
-  useState,
-} from 'react';
-import FocusTrap from 'focus-trap-react';
-import { useHover, useFocusWithin } from 'react-aria';
-import { MatrixEvent, Room } from 'matrix-js-sdk';
-import { Relations } from 'matrix-js-sdk/lib/models/relations';
-import classNames from 'classnames';
-import {
-  AvatarBase,
-  BubbleLayout,
-  CompactLayout,
-  MessageBase,
-  ModernLayout,
-  Time,
-  Username,
-} from '../../../components/message';
-import colorMXID from '../../../../util/colorMXID';
-import {
-  canEditEvent,
-  getEventEdits,
-  getMemberAvatarMxc,
-  getMemberDisplayName,
-} from '../../../utils/room';
-import { getMxIdLocalPart } from '../../../utils/matrix';
-import { MessageLayout, MessageSpacing } from '../../../state/settings';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
-import * as css from './styles.css';
-import { EventReaders } from '../../../components/event-readers';
-import { TextViewer } from '../../../components/text-viewer';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { EmojiBoard } from '../../../components/emoji-board';
-import { ReactionViewer } from '../reaction-viewer';
-import { MessageEditor } from './MessageEditor';
-
-export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
-
-type MessageQuickReactionsProps = {
-  onReaction: ReactionHandler;
-};
-export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
-  ({ onReaction, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const recentEmojis = useRecentEmoji(mx, 4);
-
-    if (recentEmojis.length === 0) return <span />;
-    return (
-      <>
-        <Box
-          style={{ padding: config.space.S200 }}
-          alignItems="Center"
-          justifyContent="Center"
-          gap="200"
-          {...props}
-          ref={ref}
-        >
-          {recentEmojis.map((emoji) => (
-            <IconButton
-              key={emoji.unicode}
-              className={css.MessageQuickReaction}
-              size="300"
-              variant="SurfaceVariant"
-              radii="Pill"
-              title={emoji.shortcode}
-              aria-label={emoji.shortcode}
-              onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
-            >
-              <Text size="T500">{emoji.unicode}</Text>
-            </IconButton>
-          ))}
-        </Box>
-        <Line size="300" />
-      </>
-    );
-  }
-);
-
-export const MessageAllReactionItem = as<
-  'button',
-  {
-    room: Room;
-    relations: Relations;
-    onClose?: () => void;
-  }
->(({ room, relations, onClose, ...props }, ref) => {
-  const [open, setOpen] = useState(false);
-
-  const handleClose = () => {
-    setOpen(false);
-    onClose?.();
-  };
-
-  return (
-    <>
-      <Overlay
-        onContextMenu={(evt: any) => {
-          evt.stopPropagation();
-        }}
-        open={open}
-        backdrop={<OverlayBackdrop />}
-      >
-        <OverlayCenter>
-          <FocusTrap
-            focusTrapOptions={{
-              initialFocus: false,
-              returnFocusOnDeactivate: false,
-              onDeactivate: () => handleClose(),
-              clickOutsideDeactivates: true,
-            }}
-          >
-            <Modal variant="Surface" size="300">
-              <ReactionViewer
-                room={room}
-                relations={relations}
-                requestClose={() => setOpen(false)}
-              />
-            </Modal>
-          </FocusTrap>
-        </OverlayCenter>
-      </Overlay>
-      <MenuItem
-        size="300"
-        after={<Icon size="100" src={Icons.Smile} />}
-        radii="300"
-        onClick={() => setOpen(true)}
-        {...props}
-        ref={ref}
-        aria-pressed={open}
-      >
-        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
-          View Reactions
-        </Text>
-      </MenuItem>
-    </>
-  );
-});
-
-export const MessageReadReceiptItem = as<
-  'button',
-  {
-    room: Room;
-    eventId: string;
-    onClose?: () => void;
-  }
->(({ room, eventId, onClose, ...props }, ref) => {
-  const [open, setOpen] = useState(false);
-
-  const handleClose = () => {
-    setOpen(false);
-    onClose?.();
-  };
-
-  return (
-    <>
-      <Overlay open={open} backdrop={<OverlayBackdrop />}>
-        <OverlayCenter>
-          <FocusTrap
-            focusTrapOptions={{
-              initialFocus: false,
-              onDeactivate: handleClose,
-              clickOutsideDeactivates: true,
-            }}
-          >
-            <Modal variant="Surface" size="300">
-              <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
-            </Modal>
-          </FocusTrap>
-        </OverlayCenter>
-      </Overlay>
-      <MenuItem
-        size="300"
-        after={<Icon size="100" src={Icons.CheckTwice} />}
-        radii="300"
-        onClick={() => setOpen(true)}
-        {...props}
-        ref={ref}
-        aria-pressed={open}
-      >
-        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
-          Read Receipts
-        </Text>
-      </MenuItem>
-    </>
-  );
-});
-
-export const MessageSourceCodeItem = as<
-  'button',
-  {
-    room: Room;
-    mEvent: MatrixEvent;
-    onClose?: () => void;
-  }
->(({ room, mEvent, onClose, ...props }, ref) => {
-  const [open, setOpen] = useState(false);
-
-  const getContent = (evt: MatrixEvent) =>
-    evt.isEncrypted()
-      ? {
-          [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
-          [`<== ORIGINAL_EVENT ==>`]: evt.event,
-        }
-      : evt.event;
-
-  const getText = (): string => {
-    const evtId = mEvent.getId()!;
-    const evtTimeline = room.getTimelineForEvent(evtId);
-    const edits =
-      evtTimeline &&
-      getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
-
-    if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
-
-    const content: Record<string, unknown> = {
-      '<== MAIN_EVENT ==>': getContent(mEvent),
-    };
-
-    edits.forEach((editEvt, index) => {
-      content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
-    });
-
-    return JSON.stringify(content, null, 2);
-  };
-
-  const handleClose = () => {
-    setOpen(false);
-    onClose?.();
-  };
-
-  return (
-    <>
-      <Overlay open={open} backdrop={<OverlayBackdrop />}>
-        <OverlayCenter>
-          <FocusTrap
-            focusTrapOptions={{
-              initialFocus: false,
-              onDeactivate: handleClose,
-              clickOutsideDeactivates: true,
-            }}
-          >
-            <Modal variant="Surface" size="500">
-              <TextViewer
-                name="Source Code"
-                langName="json"
-                text={getText()}
-                requestClose={handleClose}
-              />
-            </Modal>
-          </FocusTrap>
-        </OverlayCenter>
-      </Overlay>
-      <MenuItem
-        size="300"
-        after={<Icon size="100" src={Icons.BlockCode} />}
-        radii="300"
-        onClick={() => setOpen(true)}
-        {...props}
-        ref={ref}
-        aria-pressed={open}
-      >
-        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
-          View Source
-        </Text>
-      </MenuItem>
-    </>
-  );
-});
-
-export const MessageDeleteItem = as<
-  'button',
-  {
-    room: Room;
-    mEvent: MatrixEvent;
-    onClose?: () => void;
-  }
->(({ room, mEvent, onClose, ...props }, ref) => {
-  const mx = useMatrixClient();
-  const [open, setOpen] = useState(false);
-
-  const [deleteState, deleteMessage] = useAsyncCallback(
-    useCallback(
-      (eventId: string, reason?: string) =>
-        mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
-      [mx, room]
-    )
-  );
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    const eventId = mEvent.getId();
-    if (
-      !eventId ||
-      deleteState.status === AsyncStatus.Loading ||
-      deleteState.status === AsyncStatus.Success
-    )
-      return;
-    const target = evt.target as HTMLFormElement | undefined;
-    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
-    const reason = reasonInput && reasonInput.value.trim();
-    deleteMessage(eventId, reason);
-  };
-
-  const handleClose = () => {
-    setOpen(false);
-    onClose?.();
-  };
-
-  return (
-    <>
-      <Overlay open={open} backdrop={<OverlayBackdrop />}>
-        <OverlayCenter>
-          <FocusTrap
-            focusTrapOptions={{
-              initialFocus: false,
-              onDeactivate: handleClose,
-              clickOutsideDeactivates: true,
-            }}
-          >
-            <Dialog variant="Surface">
-              <Header
-                style={{
-                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
-                  borderBottomWidth: config.borderWidth.B300,
-                }}
-                variant="Surface"
-                size="500"
-              >
-                <Box grow="Yes">
-                  <Text size="H4">Delete Message</Text>
-                </Box>
-                <IconButton size="300" onClick={handleClose} radii="300">
-                  <Icon src={Icons.Cross} />
-                </IconButton>
-              </Header>
-              <Box
-                as="form"
-                onSubmit={handleSubmit}
-                style={{ padding: config.space.S400 }}
-                direction="Column"
-                gap="400"
-              >
-                <Text priority="400">
-                  This action is irreversible! Are you sure that you want to delete this message?
-                </Text>
-                <Box direction="Column" gap="100">
-                  <Text size="L400">
-                    Reason{' '}
-                    <Text as="span" size="T200">
-                      (optional)
-                    </Text>
-                  </Text>
-                  <Input name="reasonInput" variant="Background" />
-                  {deleteState.status === AsyncStatus.Error && (
-                    <Text style={{ color: color.Critical.Main }} size="T300">
-                      Failed to delete message! Please try again.
-                    </Text>
-                  )}
-                </Box>
-                <Button
-                  type="submit"
-                  variant="Critical"
-                  before={
-                    deleteState.status === AsyncStatus.Loading ? (
-                      <Spinner fill="Solid" variant="Critical" size="200" />
-                    ) : undefined
-                  }
-                  aria-disabled={deleteState.status === AsyncStatus.Loading}
-                >
-                  <Text size="B400">
-                    {deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
-                  </Text>
-                </Button>
-              </Box>
-            </Dialog>
-          </FocusTrap>
-        </OverlayCenter>
-      </Overlay>
-      <Button
-        variant="Critical"
-        fill="None"
-        size="300"
-        after={<Icon size="100" src={Icons.Delete} />}
-        radii="300"
-        onClick={() => setOpen(true)}
-        aria-pressed={open}
-        {...props}
-        ref={ref}
-      >
-        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
-          Delete
-        </Text>
-      </Button>
-    </>
-  );
-});
-
-export const MessageReportItem = as<
-  'button',
-  {
-    room: Room;
-    mEvent: MatrixEvent;
-    onClose?: () => void;
-  }
->(({ room, mEvent, onClose, ...props }, ref) => {
-  const mx = useMatrixClient();
-  const [open, setOpen] = useState(false);
-
-  const [reportState, reportMessage] = useAsyncCallback(
-    useCallback(
-      (eventId: string, score: number, reason: string) =>
-        mx.reportEvent(room.roomId, eventId, score, reason),
-      [mx, room]
-    )
-  );
-
-  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
-    evt.preventDefault();
-    const eventId = mEvent.getId();
-    if (
-      !eventId ||
-      reportState.status === AsyncStatus.Loading ||
-      reportState.status === AsyncStatus.Success
-    )
-      return;
-    const target = evt.target as HTMLFormElement | undefined;
-    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
-    const reason = reasonInput && reasonInput.value.trim();
-    if (reasonInput) reasonInput.value = '';
-    reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
-  };
-
-  const handleClose = () => {
-    setOpen(false);
-    onClose?.();
-  };
-
-  return (
-    <>
-      <Overlay open={open} backdrop={<OverlayBackdrop />}>
-        <OverlayCenter>
-          <FocusTrap
-            focusTrapOptions={{
-              initialFocus: false,
-              onDeactivate: handleClose,
-              clickOutsideDeactivates: true,
-            }}
-          >
-            <Dialog variant="Surface">
-              <Header
-                style={{
-                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
-                  borderBottomWidth: config.borderWidth.B300,
-                }}
-                variant="Surface"
-                size="500"
-              >
-                <Box grow="Yes">
-                  <Text size="H4">Report Message</Text>
-                </Box>
-                <IconButton size="300" onClick={handleClose} radii="300">
-                  <Icon src={Icons.Cross} />
-                </IconButton>
-              </Header>
-              <Box
-                as="form"
-                onSubmit={handleSubmit}
-                style={{ padding: config.space.S400 }}
-                direction="Column"
-                gap="400"
-              >
-                <Text priority="400">
-                  Report this message to server, which may then notify the appropriate people to
-                  take action.
-                </Text>
-                <Box direction="Column" gap="100">
-                  <Text size="L400">Reason</Text>
-                  <Input name="reasonInput" variant="Background" required />
-                  {reportState.status === AsyncStatus.Error && (
-                    <Text style={{ color: color.Critical.Main }} size="T300">
-                      Failed to report message! Please try again.
-                    </Text>
-                  )}
-                  {reportState.status === AsyncStatus.Success && (
-                    <Text style={{ color: color.Success.Main }} size="T300">
-                      Message has been reported to server.
-                    </Text>
-                  )}
-                </Box>
-                <Button
-                  type="submit"
-                  variant="Critical"
-                  before={
-                    reportState.status === AsyncStatus.Loading ? (
-                      <Spinner fill="Solid" variant="Critical" size="200" />
-                    ) : undefined
-                  }
-                  aria-disabled={
-                    reportState.status === AsyncStatus.Loading ||
-                    reportState.status === AsyncStatus.Success
-                  }
-                >
-                  <Text size="B400">
-                    {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
-                  </Text>
-                </Button>
-              </Box>
-            </Dialog>
-          </FocusTrap>
-        </OverlayCenter>
-      </Overlay>
-      <Button
-        variant="Critical"
-        fill="None"
-        size="300"
-        after={<Icon size="100" src={Icons.Warning} />}
-        radii="300"
-        onClick={() => setOpen(true)}
-        aria-pressed={open}
-        {...props}
-        ref={ref}
-      >
-        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
-          Report
-        </Text>
-      </Button>
-    </>
-  );
-});
-
-export type MessageProps = {
-  room: Room;
-  mEvent: MatrixEvent;
-  collapse: boolean;
-  highlight: boolean;
-  edit?: boolean;
-  canDelete?: boolean;
-  canSendReaction?: boolean;
-  imagePackRooms?: Room[];
-  relations?: Relations;
-  messageLayout: MessageLayout;
-  messageSpacing: MessageSpacing;
-  onUserClick: MouseEventHandler<HTMLButtonElement>;
-  onUsernameClick: MouseEventHandler<HTMLButtonElement>;
-  onReplyClick: MouseEventHandler<HTMLButtonElement>;
-  onEditId?: (eventId?: string) => void;
-  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
-  reply?: ReactNode;
-  reactions?: ReactNode;
-};
-export const Message = as<'div', MessageProps>(
-  (
-    {
-      className,
-      room,
-      mEvent,
-      collapse,
-      highlight,
-      edit,
-      canDelete,
-      canSendReaction,
-      imagePackRooms,
-      relations,
-      messageLayout,
-      messageSpacing,
-      onUserClick,
-      onUsernameClick,
-      onReplyClick,
-      onReactionToggle,
-      onEditId,
-      reply,
-      reactions,
-      children,
-      ...props
-    },
-    ref
-  ) => {
-    const mx = useMatrixClient();
-    const senderId = mEvent.getSender() ?? '';
-    const [hover, setHover] = useState(false);
-    const { hoverProps } = useHover({ onHoverChange: setHover });
-    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
-    const [menu, setMenu] = useState(false);
-    const [emojiBoard, setEmojiBoard] = useState(false);
-
-    const senderDisplayName =
-      getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
-    const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
-
-    const headerJSX = !collapse && (
-      <Box
-        gap="300"
-        direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
-        justifyContent="SpaceBetween"
-        alignItems="Baseline"
-        grow="Yes"
-      >
-        <Username
-          as="button"
-          style={{ color: colorMXID(senderId) }}
-          data-user-id={senderId}
-          onContextMenu={onUserClick}
-          onClick={onUsernameClick}
-        >
-          <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
-            <b>{senderDisplayName}</b>
-          </Text>
-        </Username>
-        <Box shrink="No" gap="100">
-          {messageLayout !== 1 && hover && (
-            <>
-              <Text as="span" size="T200" priority="300">
-                {senderId}
-              </Text>
-              <Text as="span" size="T200" priority="300">
-                |
-              </Text>
-            </>
-          )}
-          <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
-        </Box>
-      </Box>
-    );
-
-    const avatarJSX = !collapse && messageLayout !== 1 && (
-      <AvatarBase>
-        <Avatar
-          className={css.MessageAvatar}
-          as="button"
-          size="300"
-          data-user-id={senderId}
-          onClick={onUserClick}
-        >
-          {senderAvatarMxc ? (
-            <AvatarImage
-              src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
-            />
-          ) : (
-            <AvatarFallback
-              style={{
-                background: colorMXID(senderId),
-                color: 'white',
-              }}
-            >
-              <Text size="H4">{senderDisplayName[0]}</Text>
-            </AvatarFallback>
-          )}
-        </Avatar>
-      </AvatarBase>
-    );
-
-    const msgContentJSX = (
-      <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
-        {reply}
-        {edit && onEditId ? (
-          <MessageEditor
-            style={{
-              maxWidth: '100%',
-              width: '100vw',
-            }}
-            roomId={room.roomId}
-            room={room}
-            mEvent={mEvent}
-            imagePackRooms={imagePackRooms}
-            onCancel={() => onEditId()}
-          />
-        ) : (
-          children
-        )}
-        {reactions}
-      </Box>
-    );
-
-    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
-      if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
-      const tag = (evt.target as any).tagName;
-      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
-      evt.preventDefault();
-      setMenu(true);
-    };
-
-    const closeMenu = () => {
-      setMenu(false);
-    };
-
-    return (
-      <MessageBase
-        className={classNames(css.MessageBase, className)}
-        tabIndex={0}
-        space={messageSpacing}
-        collapse={collapse}
-        highlight={highlight}
-        selected={menu || emojiBoard}
-        {...props}
-        {...hoverProps}
-        {...focusWithinProps}
-        ref={ref}
-      >
-        {!edit && (hover || menu || emojiBoard) && (
-          <div className={css.MessageOptionsBase}>
-            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
-              <Box gap="100">
-                {canSendReaction && (
-                  <PopOut
-                    alignOffset={-65}
-                    position="Bottom"
-                    align="End"
-                    open={emojiBoard}
-                    content={
-                      <EmojiBoard
-                        imagePackRooms={imagePackRooms ?? []}
-                        returnFocusOnDeactivate={false}
-                        allowTextCustomEmoji
-                        onEmojiSelect={(key) => {
-                          onReactionToggle(mEvent.getId()!, key);
-                          setEmojiBoard(false);
-                        }}
-                        onCustomEmojiSelect={(mxc, shortcode) => {
-                          onReactionToggle(mEvent.getId()!, mxc, shortcode);
-                          setEmojiBoard(false);
-                        }}
-                        requestClose={() => {
-                          setEmojiBoard(false);
-                        }}
-                      />
-                    }
-                  >
-                    {(anchorRef) => (
-                      <IconButton
-                        ref={anchorRef}
-                        onClick={() => setEmojiBoard(true)}
-                        variant="SurfaceVariant"
-                        size="300"
-                        radii="300"
-                        aria-pressed={emojiBoard}
-                      >
-                        <Icon src={Icons.SmilePlus} size="100" />
-                      </IconButton>
-                    )}
-                  </PopOut>
-                )}
-                <IconButton
-                  onClick={onReplyClick}
-                  data-event-id={mEvent.getId()}
-                  variant="SurfaceVariant"
-                  size="300"
-                  radii="300"
-                >
-                  <Icon src={Icons.ReplyArrow} size="100" />
-                </IconButton>
-                {canEditEvent(mx, mEvent) && onEditId && (
-                  <IconButton
-                    onClick={() => onEditId(mEvent.getId())}
-                    variant="SurfaceVariant"
-                    size="300"
-                    radii="300"
-                  >
-                    <Icon src={Icons.Pencil} size="100" />
-                  </IconButton>
-                )}
-                <PopOut
-                  open={menu}
-                  alignOffset={-5}
-                  position="Bottom"
-                  align="End"
-                  content={
-                    <FocusTrap
-                      focusTrapOptions={{
-                        initialFocus: false,
-                        onDeactivate: () => setMenu(false),
-                        clickOutsideDeactivates: true,
-                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                      }}
-                    >
-                      <Menu {...props} ref={ref}>
-                        {canSendReaction && (
-                          <MessageQuickReactions
-                            onReaction={(key, shortcode) => {
-                              onReactionToggle(mEvent.getId()!, key, shortcode);
-                              closeMenu();
-                            }}
-                          />
-                        )}
-                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
-                          {canSendReaction && (
-                            <MenuItem
-                              size="300"
-                              after={<Icon size="100" src={Icons.SmilePlus} />}
-                              radii="300"
-                              onClick={() => {
-                                closeMenu();
-                                // open it with timeout because closeMenu
-                                // FocusTrap will return focus from emojiBoard
-                                setTimeout(() => setEmojiBoard(true), 100);
-                              }}
-                            >
-                              <Text
-                                className={css.MessageMenuItemText}
-                                as="span"
-                                size="T300"
-                                truncate
-                              >
-                                Add Reaction
-                              </Text>
-                            </MenuItem>
-                          )}
-                          {relations && (
-                            <MessageAllReactionItem
-                              room={room}
-                              relations={relations}
-                              onClose={closeMenu}
-                            />
-                          )}
-                          <MenuItem
-                            size="300"
-                            after={<Icon size="100" src={Icons.ReplyArrow} />}
-                            radii="300"
-                            data-event-id={mEvent.getId()}
-                            onClick={(evt: any) => {
-                              onReplyClick(evt);
-                              closeMenu();
-                            }}
-                          >
-                            <Text
-                              className={css.MessageMenuItemText}
-                              as="span"
-                              size="T300"
-                              truncate
-                            >
-                              Reply
-                            </Text>
-                          </MenuItem>
-                          {canEditEvent(mx, mEvent) && onEditId && (
-                            <MenuItem
-                              size="300"
-                              after={<Icon size="100" src={Icons.Pencil} />}
-                              radii="300"
-                              data-event-id={mEvent.getId()}
-                              onClick={() => {
-                                onEditId(mEvent.getId());
-                                closeMenu();
-                              }}
-                            >
-                              <Text
-                                className={css.MessageMenuItemText}
-                                as="span"
-                                size="T300"
-                                truncate
-                              >
-                                Edit Message
-                              </Text>
-                            </MenuItem>
-                          )}
-                          <MessageReadReceiptItem
-                            room={room}
-                            eventId={mEvent.getId() ?? ''}
-                            onClose={closeMenu}
-                          />
-                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
-                        </Box>
-                        {((!mEvent.isRedacted() && canDelete) ||
-                          mEvent.getSender() !== mx.getUserId()) && (
-                          <>
-                            <Line size="300" />
-                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
-                              {!mEvent.isRedacted() && canDelete && (
-                                <MessageDeleteItem
-                                  room={room}
-                                  mEvent={mEvent}
-                                  onClose={closeMenu}
-                                />
-                              )}
-                              {mEvent.getSender() !== mx.getUserId() && (
-                                <MessageReportItem
-                                  room={room}
-                                  mEvent={mEvent}
-                                  onClose={closeMenu}
-                                />
-                              )}
-                            </Box>
-                          </>
-                        )}
-                      </Menu>
-                    </FocusTrap>
-                  }
-                >
-                  {(targetRef) => (
-                    <IconButton
-                      ref={targetRef}
-                      variant="SurfaceVariant"
-                      size="300"
-                      radii="300"
-                      onClick={() => setMenu((v) => !v)}
-                      aria-pressed={menu}
-                    >
-                      <Icon src={Icons.VerticalDots} size="100" />
-                    </IconButton>
-                  )}
-                </PopOut>
-              </Box>
-            </Menu>
-          </div>
-        )}
-        {messageLayout === 1 && (
-          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
-            {msgContentJSX}
-          </CompactLayout>
-        )}
-        {messageLayout === 2 && (
-          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
-            {headerJSX}
-            {msgContentJSX}
-          </BubbleLayout>
-        )}
-        {messageLayout !== 1 && messageLayout !== 2 && (
-          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
-            {headerJSX}
-            {msgContentJSX}
-          </ModernLayout>
-        )}
-      </MessageBase>
-    );
-  }
-);
-
-export type EventProps = {
-  room: Room;
-  mEvent: MatrixEvent;
-  highlight: boolean;
-  canDelete?: boolean;
-  messageSpacing: MessageSpacing;
-};
-export const Event = as<'div', EventProps>(
-  ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const [hover, setHover] = useState(false);
-    const { hoverProps } = useHover({ onHoverChange: setHover });
-    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
-    const [menu, setMenu] = useState(false);
-    const stateEvent = typeof mEvent.getStateKey() === 'string';
-
-    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
-      if (evt.altKey || !window.getSelection()?.isCollapsed) return;
-      const tag = (evt.target as any).tagName;
-      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
-      evt.preventDefault();
-      setMenu(true);
-    };
-
-    const closeMenu = () => {
-      setMenu(false);
-    };
-
-    return (
-      <MessageBase
-        className={classNames(css.MessageBase, className)}
-        tabIndex={0}
-        space={messageSpacing}
-        autoCollapse
-        highlight={highlight}
-        selected={menu}
-        {...props}
-        {...hoverProps}
-        {...focusWithinProps}
-        ref={ref}
-      >
-        {(hover || menu) && (
-          <div className={css.MessageOptionsBase}>
-            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
-              <Box gap="100">
-                <PopOut
-                  open={menu}
-                  alignOffset={-5}
-                  position="Bottom"
-                  align="End"
-                  content={
-                    <FocusTrap
-                      focusTrapOptions={{
-                        initialFocus: false,
-                        onDeactivate: () => setMenu(false),
-                        clickOutsideDeactivates: true,
-                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
-                      }}
-                    >
-                      <Menu {...props} ref={ref}>
-                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
-                          <MessageReadReceiptItem
-                            room={room}
-                            eventId={mEvent.getId() ?? ''}
-                            onClose={closeMenu}
-                          />
-                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
-                        </Box>
-                        {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
-                          (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
-                          <>
-                            <Line size="300" />
-                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
-                              {!mEvent.isRedacted() && canDelete && (
-                                <MessageDeleteItem
-                                  room={room}
-                                  mEvent={mEvent}
-                                  onClose={closeMenu}
-                                />
-                              )}
-                              {mEvent.getSender() !== mx.getUserId() && (
-                                <MessageReportItem
-                                  room={room}
-                                  mEvent={mEvent}
-                                  onClose={closeMenu}
-                                />
-                              )}
-                            </Box>
-                          </>
-                        )}
-                      </Menu>
-                    </FocusTrap>
-                  }
-                >
-                  {(targetRef) => (
-                    <IconButton
-                      ref={targetRef}
-                      variant="SurfaceVariant"
-                      size="300"
-                      radii="300"
-                      onClick={() => setMenu((v) => !v)}
-                      aria-pressed={menu}
-                    >
-                      <Icon src={Icons.VerticalDots} size="100" />
-                    </IconButton>
-                  )}
-                </PopOut>
-              </Box>
-            </Menu>
-          </div>
-        )}
-        <div onContextMenu={handleContextMenu}>{children}</div>
-      </MessageBase>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/MessageEditor.tsx b/src/app/organisms/room/message/MessageEditor.tsx
deleted file mode 100644 (file)
index 006b46a..0000000
+++ /dev/null
@@ -1,310 +0,0 @@
-import React, { KeyboardEventHandler, useCallback, useEffect, useState } from 'react';
-import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, as, config } from 'folds';
-import { Editor, Transforms } from 'slate';
-import { ReactEditor } from 'slate-react';
-import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
-import { isKeyHotkey } from 'is-hotkey';
-import {
-  AUTOCOMPLETE_PREFIXES,
-  AutocompletePrefix,
-  AutocompleteQuery,
-  CustomEditor,
-  EmoticonAutocomplete,
-  RoomMentionAutocomplete,
-  Toolbar,
-  UserMentionAutocomplete,
-  createEmoticonElement,
-  customHtmlEqualsPlainText,
-  getAutocompleteQuery,
-  getPrevWorldRange,
-  htmlToEditorInput,
-  moveCursor,
-  plainToEditorInput,
-  toMatrixCustomHTML,
-  toPlainText,
-  trimCustomHtml,
-  useEditor,
-} from '../../../components/editor';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { EmojiBoard } from '../../../components/emoji-board';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
-import { mobileOrTablet } from '../../../utils/user-agent';
-
-type MessageEditorProps = {
-  roomId: string;
-  room: Room;
-  mEvent: MatrixEvent;
-  imagePackRooms?: Room[];
-  onCancel: () => void;
-};
-export const MessageEditor = as<'div', MessageEditorProps>(
-  ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const editor = useEditor();
-    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
-    const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
-    const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
-    const [toolbar, setToolbar] = useState(globalToolbar);
-
-    const [autocompleteQuery, setAutocompleteQuery] =
-      useState<AutocompleteQuery<AutocompletePrefix>>();
-
-    const getPrevBodyAndFormattedBody = useCallback((): [
-      string | undefined,
-      string | undefined
-    ] => {
-      const evtId = mEvent.getId()!;
-      const evtTimeline = room.getTimelineForEvent(evtId);
-      const editedEvent =
-        evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
-
-      const { body, formatted_body: customHtml }: Record<string, unknown> =
-        editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
-
-      return [
-        typeof body === 'string' ? body : undefined,
-        typeof customHtml === 'string' ? customHtml : undefined,
-      ];
-    }, [room, mEvent]);
-
-    const [saveState, save] = useAsyncCallback(
-      useCallback(async () => {
-        const plainText = toPlainText(editor.children).trim();
-        const customHtml = trimCustomHtml(
-          toMatrixCustomHTML(editor.children, {
-            allowTextFormatting: true,
-            allowBlockMarkdown: isMarkdown,
-            allowInlineMarkdown: isMarkdown,
-          })
-        );
-
-        const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
-
-        if (plainText === '') return undefined;
-        if (prevBody) {
-          if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
-            return undefined;
-          }
-          if (
-            !prevCustomHtml &&
-            prevBody === plainText &&
-            customHtmlEqualsPlainText(customHtml, plainText)
-          ) {
-            return undefined;
-          }
-        }
-
-        const newContent: IContent = {
-          msgtype: mEvent.getContent().msgtype,
-          body: plainText,
-        };
-
-        if (!customHtmlEqualsPlainText(customHtml, plainText)) {
-          newContent.format = 'org.matrix.custom.html';
-          newContent.formatted_body = customHtml;
-        }
-
-        const content: IContent = {
-          ...newContent,
-          body: `* ${plainText}`,
-          'm.new_content': newContent,
-          'm.relates_to': {
-            event_id: mEvent.getId(),
-            rel_type: RelationType.Replace,
-          },
-        };
-
-        return mx.sendMessage(roomId, content);
-      }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
-    );
-
-    const handleSave = useCallback(() => {
-      if (saveState.status !== AsyncStatus.Loading) {
-        save();
-      }
-    }, [saveState, save]);
-
-    const handleKeyDown: KeyboardEventHandler = useCallback(
-      (evt) => {
-        if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
-          evt.preventDefault();
-          handleSave();
-        }
-        if (isKeyHotkey('escape', evt)) {
-          evt.preventDefault();
-          onCancel();
-        }
-      },
-      [onCancel, handleSave, enterForNewline]
-    );
-
-    const handleKeyUp: KeyboardEventHandler = useCallback(
-      (evt) => {
-        if (isKeyHotkey('escape', evt)) {
-          evt.preventDefault();
-          return;
-        }
-
-        const prevWordRange = getPrevWorldRange(editor);
-        const query = prevWordRange
-          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
-          : undefined;
-        setAutocompleteQuery(query);
-      },
-      [editor]
-    );
-
-    const handleCloseAutocomplete = useCallback(() => {
-      ReactEditor.focus(editor);
-      setAutocompleteQuery(undefined);
-    }, [editor]);
-
-    const handleEmoticonSelect = (key: string, shortcode: string) => {
-      editor.insertNode(createEmoticonElement(key, shortcode));
-      moveCursor(editor);
-    };
-
-    useEffect(() => {
-      const [body, customHtml] = getPrevBodyAndFormattedBody();
-
-      const initialValue =
-        typeof customHtml === 'string'
-          ? htmlToEditorInput(customHtml)
-          : plainToEditorInput(typeof body === 'string' ? body : '');
-
-      Transforms.select(editor, {
-        anchor: Editor.start(editor, []),
-        focus: Editor.end(editor, []),
-      });
-
-      editor.insertFragment(initialValue);
-      if (!mobileOrTablet()) ReactEditor.focus(editor);
-    }, [editor, getPrevBodyAndFormattedBody]);
-
-    useEffect(() => {
-      if (saveState.status === AsyncStatus.Success) {
-        onCancel();
-      }
-    }, [saveState, onCancel]);
-
-    return (
-      <div {...props} ref={ref}>
-        {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
-          <RoomMentionAutocomplete
-            roomId={roomId}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
-          <UserMentionAutocomplete
-            room={room}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
-          <EmoticonAutocomplete
-            imagePackRooms={imagePackRooms || []}
-            editor={editor}
-            query={autocompleteQuery}
-            requestClose={handleCloseAutocomplete}
-          />
-        )}
-        <CustomEditor
-          editor={editor}
-          placeholder="Edit message..."
-          onKeyDown={handleKeyDown}
-          onKeyUp={handleKeyUp}
-          bottom={
-            <>
-              <Box
-                style={{ padding: config.space.S200, paddingTop: 0 }}
-                alignItems="End"
-                justifyContent="SpaceBetween"
-                gap="100"
-              >
-                <Box gap="Inherit">
-                  <Chip
-                    onClick={handleSave}
-                    variant="Primary"
-                    radii="Pill"
-                    disabled={saveState.status === AsyncStatus.Loading}
-                    outlined
-                    before={
-                      saveState.status === AsyncStatus.Loading ? (
-                        <Spinner variant="Primary" fill="Soft" size="100" />
-                      ) : undefined
-                    }
-                  >
-                    <Text size="B300">Save</Text>
-                  </Chip>
-                  <Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
-                    <Text size="B300">Cancel</Text>
-                  </Chip>
-                </Box>
-                <Box gap="Inherit">
-                  <IconButton
-                    variant="SurfaceVariant"
-                    size="300"
-                    radii="300"
-                    onClick={() => setToolbar(!toolbar)}
-                  >
-                    <Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
-                  </IconButton>
-                  <UseStateProvider initial={false}>
-                    {(emojiBoard: boolean, setEmojiBoard) => (
-                      <PopOut
-                        alignOffset={-8}
-                        position="Top"
-                        align="End"
-                        open={!!emojiBoard}
-                        content={
-                          <EmojiBoard
-                            imagePackRooms={imagePackRooms ?? []}
-                            returnFocusOnDeactivate={false}
-                            onEmojiSelect={handleEmoticonSelect}
-                            onCustomEmojiSelect={handleEmoticonSelect}
-                            requestClose={() => {
-                              setEmojiBoard(false);
-                              if (!mobileOrTablet()) ReactEditor.focus(editor);
-                            }}
-                          />
-                        }
-                      >
-                        {(anchorRef) => (
-                          <IconButton
-                            ref={anchorRef}
-                            aria-pressed={emojiBoard}
-                            onClick={() => setEmojiBoard(true)}
-                            variant="SurfaceVariant"
-                            size="300"
-                            radii="300"
-                          >
-                            <Icon size="400" src={Icons.Smile} filled={emojiBoard} />
-                          </IconButton>
-                        )}
-                      </PopOut>
-                    )}
-                  </UseStateProvider>
-                </Box>
-              </Box>
-              {toolbar && (
-                <div>
-                  <Line variant="SurfaceVariant" size="300" />
-                  <Toolbar />
-                </div>
-              )}
-            </>
-          }
-        />
-      </div>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/Reactions.tsx b/src/app/organisms/room/message/Reactions.tsx
deleted file mode 100644 (file)
index 728cf81..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-import React, { MouseEventHandler, useCallback, useState } from 'react';
-import {
-  Box,
-  Modal,
-  Overlay,
-  OverlayBackdrop,
-  OverlayCenter,
-  Text,
-  Tooltip,
-  TooltipProvider,
-  as,
-  toRem,
-} from 'folds';
-import classNames from 'classnames';
-import { Room } from 'matrix-js-sdk';
-import { type Relations } from 'matrix-js-sdk/lib/models/relations';
-import FocusTrap from 'focus-trap-react';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { factoryEventSentBy } from '../../../utils/matrix';
-import { Reaction, ReactionTooltipMsg } from '../../../components/message';
-import { useRelations } from '../../../hooks/useRelations';
-import * as css from './styles.css';
-import { ReactionViewer } from '../reaction-viewer';
-
-export type ReactionsProps = {
-  room: Room;
-  mEventId: string;
-  canSendReaction?: boolean;
-  relations: Relations;
-  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
-};
-export const Reactions = as<'div', ReactionsProps>(
-  ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const [viewer, setViewer] = useState<boolean | string>(false);
-    const myUserId = mx.getUserId();
-    const reactions = useRelations(
-      relations,
-      useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
-    );
-
-    const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
-      evt.stopPropagation();
-      evt.preventDefault();
-      const key = evt.currentTarget.getAttribute('data-reaction-key');
-      if (!key) setViewer(true);
-      else setViewer(key);
-    };
-
-    return (
-      <Box
-        className={classNames(css.ReactionsContainer, className)}
-        gap="200"
-        wrap="Wrap"
-        {...props}
-        ref={ref}
-      >
-        {reactions.map(([key, events]) => {
-          const rEvents = Array.from(events);
-          if (rEvents.length === 0 || typeof key !== 'string') return null;
-          const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
-          const isPressed = !!myREvent?.getRelation();
-
-          return (
-            <TooltipProvider
-              key={key}
-              position="Top"
-              tooltip={
-                <Tooltip style={{ maxWidth: toRem(200) }}>
-                  <Text className={css.ReactionsTooltipText} size="T300">
-                    <ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
-                  </Text>
-                </Tooltip>
-              }
-            >
-              {(targetRef) => (
-                <Reaction
-                  ref={targetRef}
-                  data-reaction-key={key}
-                  aria-pressed={isPressed}
-                  key={key}
-                  mx={mx}
-                  reaction={key}
-                  count={events.size}
-                  onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
-                  onContextMenu={handleViewReaction}
-                  aria-disabled={!canSendReaction}
-                />
-              )}
-            </TooltipProvider>
-          );
-        })}
-        {reactions.length > 0 && (
-          <Overlay
-            onContextMenu={(evt: any) => {
-              evt.stopPropagation();
-            }}
-            open={!!viewer}
-            backdrop={<OverlayBackdrop />}
-          >
-            <OverlayCenter>
-              <FocusTrap
-                focusTrapOptions={{
-                  initialFocus: false,
-                  returnFocusOnDeactivate: false,
-                  onDeactivate: () => setViewer(false),
-                  clickOutsideDeactivates: true,
-                }}
-              >
-                <Modal variant="Surface" size="300">
-                  <ReactionViewer
-                    room={room}
-                    initialKey={typeof viewer === 'string' ? viewer : undefined}
-                    relations={relations}
-                    requestClose={() => setViewer(false)}
-                  />
-                </Modal>
-              </FocusTrap>
-            </OverlayCenter>
-          </Overlay>
-        )}
-      </Box>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/StickerContent.tsx b/src/app/organisms/room/message/StickerContent.tsx
deleted file mode 100644 (file)
index 49b8b8d..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from 'react';
-import { as, toRem } from 'folds';
-import { MatrixEvent } from 'matrix-js-sdk';
-import {
-  AttachmentBox,
-  MessageBrokenContent,
-  MessageDeletedContent,
-} from '../../../components/message';
-import { ImageContent } from './ImageContent';
-import { scaleYDimension } from '../../../utils/common';
-import { IImageContent } from '../../../../types/matrix/common';
-
-type StickerContentProps = {
-  mEvent: MatrixEvent;
-  autoPlay: boolean;
-};
-export const StickerContent = as<'div', StickerContentProps>(
-  ({ mEvent, autoPlay, ...props }, ref) => {
-    if (mEvent.isRedacted()) return <MessageDeletedContent />;
-    const content = mEvent.getContent<IImageContent>();
-    const imgInfo = content?.info;
-    const mxcUrl = content.file?.url ?? content.url;
-    if (typeof mxcUrl !== 'string') {
-      return <MessageBrokenContent />;
-    }
-    const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
-
-    return (
-      <AttachmentBox
-        style={{
-          height: toRem(height < 48 ? 48 : height),
-          width: toRem(152),
-        }}
-        {...props}
-        ref={ref}
-      >
-        <ImageContent
-          autoPlay={autoPlay}
-          body={content.body || 'Image'}
-          info={imgInfo}
-          mimeType={imgInfo?.mimetype}
-          url={mxcUrl}
-          encInfo={content.file}
-        />
-      </AttachmentBox>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/UrlPreviewCard.tsx b/src/app/organisms/room/message/UrlPreviewCard.tsx
deleted file mode 100644 (file)
index b085e18..0000000
+++ /dev/null
@@ -1,186 +0,0 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { IPreviewUrlResponse } from 'matrix-js-sdk';
-import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import {
-  UrlPreview,
-  UrlPreviewContent,
-  UrlPreviewDescription,
-  UrlPreviewImg,
-} from '../../../components/url-preview';
-import {
-  getIntersectionObserverEntry,
-  useIntersectionObserver,
-} from '../../../hooks/useIntersectionObserver';
-import * as css from './styles.css';
-
-const linkStyles = { color: color.Success.Main };
-
-export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
-  ({ url, ts, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const [previewStatus, loadPreview] = useAsyncCallback(
-      useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
-    );
-
-    useEffect(() => {
-      loadPreview();
-    }, [loadPreview]);
-
-    if (previewStatus.status === AsyncStatus.Error) return null;
-
-    const renderContent = (prev: IPreviewUrlResponse) => {
-      const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
-
-      return (
-        <>
-          {imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
-          <UrlPreviewContent>
-            <Text
-              style={linkStyles}
-              truncate
-              as="a"
-              href={url}
-              target="_blank"
-              rel="no-referrer"
-              size="T200"
-              priority="300"
-            >
-              {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
-              {decodeURIComponent(url)}
-            </Text>
-            <Text truncate priority="400">
-              <b>{prev['og:title']}</b>
-            </Text>
-            <Text size="T200" priority="300">
-              <UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
-            </Text>
-          </UrlPreviewContent>
-        </>
-      );
-    };
-
-    return (
-      <UrlPreview {...props} ref={ref}>
-        {previewStatus.status === AsyncStatus.Success ? (
-          renderContent(previewStatus.data)
-        ) : (
-          <Box grow="Yes" alignItems="Center" justifyContent="Center">
-            <Spinner variant="Secondary" size="400" />
-          </Box>
-        )}
-      </UrlPreview>
-    );
-  }
-);
-
-export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
-  const scrollRef = useRef<HTMLDivElement>(null);
-  const backAnchorRef = useRef<HTMLDivElement>(null);
-  const frontAnchorRef = useRef<HTMLDivElement>(null);
-  const [backVisible, setBackVisible] = useState(true);
-  const [frontVisible, setFrontVisible] = useState(true);
-
-  const intersectionObserver = useIntersectionObserver(
-    useCallback((entries) => {
-      const backAnchor = backAnchorRef.current;
-      const frontAnchor = frontAnchorRef.current;
-      const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
-      const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
-      if (backEntry) {
-        setBackVisible(backEntry.isIntersecting);
-      }
-      if (frontEntry) {
-        setFrontVisible(frontEntry.isIntersecting);
-      }
-    }, []),
-    useCallback(
-      () => ({
-        root: scrollRef.current,
-        rootMargin: '10px',
-      }),
-      []
-    )
-  );
-
-  useEffect(() => {
-    const backAnchor = backAnchorRef.current;
-    const frontAnchor = frontAnchorRef.current;
-    if (backAnchor) intersectionObserver?.observe(backAnchor);
-    if (frontAnchor) intersectionObserver?.observe(frontAnchor);
-    return () => {
-      if (backAnchor) intersectionObserver?.unobserve(backAnchor);
-      if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
-    };
-  }, [intersectionObserver]);
-
-  const handleScrollBack = () => {
-    const scroll = scrollRef.current;
-    if (!scroll) return;
-    const { offsetWidth, scrollLeft } = scroll;
-    scroll.scrollTo({
-      left: scrollLeft - offsetWidth / 1.3,
-      behavior: 'smooth',
-    });
-  };
-  const handleScrollFront = () => {
-    const scroll = scrollRef.current;
-    if (!scroll) return;
-    const { offsetWidth, scrollLeft } = scroll;
-    scroll.scrollTo({
-      left: scrollLeft + offsetWidth / 1.3,
-      behavior: 'smooth',
-    });
-  };
-
-  return (
-    <Box
-      direction="Column"
-      {...props}
-      ref={ref}
-      style={{ marginTop: config.space.S200, position: 'relative' }}
-    >
-      <Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
-        <Box shrink="No" alignItems="Center">
-          <div ref={backAnchorRef} />
-          {!backVisible && (
-            <>
-              <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
-              <IconButton
-                className={css.UrlPreviewHolderBtn({ position: 'Left' })}
-                variant="Secondary"
-                radii="Pill"
-                size="300"
-                outlined
-                onClick={handleScrollBack}
-              >
-                <Icon size="300" src={Icons.ArrowLeft} />
-              </IconButton>
-            </>
-          )}
-          <Box alignItems="Inherit" gap="200">
-            {children}
-
-            {!frontVisible && (
-              <>
-                <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
-                <IconButton
-                  className={css.UrlPreviewHolderBtn({ position: 'Right' })}
-                  variant="Primary"
-                  radii="Pill"
-                  size="300"
-                  outlined
-                  onClick={handleScrollFront}
-                >
-                  <Icon size="300" src={Icons.ArrowRight} />
-                </IconButton>
-              </>
-            )}
-            <div ref={frontAnchorRef} />
-          </Box>
-        </Box>
-      </Scroll>
-    </Box>
-  );
-});
diff --git a/src/app/organisms/room/message/VideoContent.tsx b/src/app/organisms/room/message/VideoContent.tsx
deleted file mode 100644 (file)
index 8b3bd34..0000000
+++ /dev/null
@@ -1,182 +0,0 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import {
-  Badge,
-  Box,
-  Button,
-  Icon,
-  Icons,
-  Spinner,
-  Text,
-  Tooltip,
-  TooltipProvider,
-  as,
-} from 'folds';
-import classNames from 'classnames';
-import { BlurhashCanvas } from 'react-blurhash';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import {
-  IThumbnailContent,
-  IVideoInfo,
-  MATRIX_BLUR_HASH_PROPERTY_NAME,
-} from '../../../../types/matrix/common';
-import * as css from './styles.css';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { getFileSrcUrl } from './util';
-import { Image, Video } from '../../../components/media';
-import { bytesToSize } from '../../../../util/common';
-import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
-
-export type VideoContentProps = {
-  body: string;
-  mimeType: string;
-  url: string;
-  info: IVideoInfo & IThumbnailContent;
-  encInfo?: EncryptedAttachmentInfo;
-  autoPlay?: boolean;
-  loadThumbnail?: boolean;
-};
-export const VideoContent = as<'div', VideoContentProps>(
-  ({ className, body, mimeType, url, info, encInfo, autoPlay, loadThumbnail, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
-
-    const [load, setLoad] = useState(false);
-    const [error, setError] = useState(false);
-
-    const [srcState, loadSrc] = useAsyncCallback(
-      useCallback(
-        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
-        [mx, url, mimeType, encInfo]
-      )
-    );
-    const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
-      useCallback(() => {
-        const thumbInfo = info.thumbnail_info;
-        const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
-        if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
-          throw new Error('Failed to load thumbnail');
-        }
-        return getFileSrcUrl(
-          mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
-          thumbInfo.mimetype,
-          info.thumbnail_file
-        );
-      }, [mx, info])
-    );
-
-    const handleLoad = () => {
-      setLoad(true);
-    };
-    const handleError = () => {
-      setLoad(false);
-      setError(true);
-    };
-
-    const handleRetry = () => {
-      setError(false);
-      loadSrc();
-    };
-
-    useEffect(() => {
-      if (autoPlay) loadSrc();
-    }, [autoPlay, loadSrc]);
-    useEffect(() => {
-      if (loadThumbnail) loadThumbSrc();
-    }, [loadThumbnail, loadThumbSrc]);
-
-    return (
-      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
-        {typeof blurHash === 'string' && !load && (
-          <BlurhashCanvas
-            style={{ width: '100%', height: '100%' }}
-            width={32}
-            height={32}
-            hash={blurHash}
-            punch={1}
-          />
-        )}
-        {thumbSrcState.status === AsyncStatus.Success && !load && (
-          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-            <Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
-          </Box>
-        )}
-        {!autoPlay && srcState.status === AsyncStatus.Idle && (
-          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-            <Button
-              variant="Secondary"
-              fill="Solid"
-              radii="300"
-              size="300"
-              onClick={loadSrc}
-              before={<Icon size="Inherit" src={Icons.Play} filled />}
-            >
-              <Text size="B300">Watch</Text>
-            </Button>
-          </Box>
-        )}
-        {srcState.status === AsyncStatus.Success && (
-          <Box className={css.AbsoluteContainer}>
-            <Video
-              title={body}
-              src={srcState.data}
-              onLoadedMetadata={handleLoad}
-              onError={handleError}
-              autoPlay
-              controls
-            />
-          </Box>
-        )}
-        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
-          !load && (
-            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-              <Spinner variant="Secondary" />
-            </Box>
-          )}
-        {(error || srcState.status === AsyncStatus.Error) && (
-          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
-            <TooltipProvider
-              tooltip={
-                <Tooltip variant="Critical">
-                  <Text>Failed to load video!</Text>
-                </Tooltip>
-              }
-              position="Top"
-              align="Center"
-            >
-              {(triggerRef) => (
-                <Button
-                  ref={triggerRef}
-                  size="300"
-                  variant="Critical"
-                  fill="Soft"
-                  outlined
-                  radii="300"
-                  onClick={handleRetry}
-                  before={<Icon size="Inherit" src={Icons.Warning} filled />}
-                >
-                  <Text size="B300">Retry</Text>
-                </Button>
-              )}
-            </TooltipProvider>
-          </Box>
-        )}
-        {!load && typeof info.size === 'number' && (
-          <Box
-            className={css.AbsoluteFooter}
-            justifyContent="SpaceBetween"
-            alignContent="Center"
-            gap="200"
-          >
-            <Badge variant="Secondary" fill="Soft">
-              <Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
-            </Badge>
-            <Badge variant="Secondary" fill="Soft">
-              <Text size="L400">{bytesToSize(info.size)}</Text>
-            </Badge>
-          </Box>
-        )}
-      </Box>
-    );
-  }
-);
diff --git a/src/app/organisms/room/message/fileRenderer.tsx b/src/app/organisms/room/message/fileRenderer.tsx
deleted file mode 100644 (file)
index 5ff70b3..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react';
-import { MatrixEvent } from 'matrix-js-sdk';
-import { IFileContent } from '../../../../types/matrix/common';
-import {
-  Attachment,
-  AttachmentBox,
-  AttachmentContent,
-  AttachmentHeader,
-} from '../../../components/message';
-import { FileHeader } from './FileHeader';
-import { FileContent } from './FileContent';
-import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
-
-export const fileRenderer = (mEventId: string, mEvent: MatrixEvent) => {
-  const content = mEvent.getContent<IFileContent>();
-
-  const fileInfo = content?.info;
-  const mxcUrl = content.file?.url ?? content.url;
-
-  if (typeof mxcUrl !== 'string') {
-    return null;
-  }
-
-  return (
-    <Attachment>
-      <AttachmentHeader>
-        <FileHeader
-          body={content.body ?? 'Unnamed File'}
-          mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
-        />
-      </AttachmentHeader>
-      <AttachmentBox>
-        <AttachmentContent>
-          <FileContent
-            body={content.body ?? 'File'}
-            info={fileInfo ?? {}}
-            mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
-            url={mxcUrl}
-            encInfo={content.file}
-          />
-        </AttachmentContent>
-      </AttachmentBox>
-    </Attachment>
-  );
-};
diff --git a/src/app/organisms/room/message/index.ts b/src/app/organisms/room/message/index.ts
deleted file mode 100644 (file)
index d890861..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-export * from './ImageContent';
-export * from './VideoContent';
-export * from './FileHeader';
-export * from './fileRenderer';
-export * from './AudioContent';
-export * from './Reactions';
-export * from './EventContent';
-export * from './Message';
-export * from './EncryptedContent';
-export * from './StickerContent';
diff --git a/src/app/organisms/room/message/styles.css.ts b/src/app/organisms/room/message/styles.css.ts
deleted file mode 100644 (file)
index d42cf05..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { recipe } from '@vanilla-extract/recipes';
-import { DefaultReset, color, config, toRem } from 'folds';
-
-export const RelativeBase = style([
-  DefaultReset,
-  {
-    position: 'relative',
-    width: '100%',
-    height: '100%',
-  },
-]);
-
-export const AbsoluteContainer = style([
-  DefaultReset,
-  {
-    position: 'absolute',
-    top: 0,
-    left: 0,
-    width: '100%',
-    height: '100%',
-  },
-]);
-
-export const AbsoluteFooter = style([
-  DefaultReset,
-  {
-    position: 'absolute',
-    bottom: config.space.S100,
-    left: config.space.S100,
-    right: config.space.S100,
-  },
-]);
-
-export const ModalWide = style({
-  minWidth: '85vw',
-  minHeight: '90vh',
-});
-
-export const MessageBase = style({
-  position: 'relative',
-});
-
-export const MessageOptionsBase = style([
-  DefaultReset,
-  {
-    position: 'absolute',
-    top: toRem(-30),
-    right: 0,
-    zIndex: 1,
-  },
-]);
-export const MessageOptionsBar = style([
-  DefaultReset,
-  {
-    padding: config.space.S100,
-  },
-]);
-
-export const MessageAvatar = style({
-  cursor: 'pointer',
-});
-
-export const MessageQuickReaction = style({
-  minWidth: toRem(32),
-});
-
-export const MessageMenuGroup = style({
-  padding: config.space.S100,
-});
-
-export const MessageMenuItemText = style({
-  flexGrow: 1,
-});
-
-export const ReactionsContainer = style({
-  selectors: {
-    '&:empty': {
-      display: 'none',
-    },
-  },
-});
-
-export const ReactionsTooltipText = style({
-  wordBreak: 'break-word',
-});
-
-export const UrlPreviewHolderGradient = recipe({
-  base: [
-    DefaultReset,
-    {
-      position: 'absolute',
-      height: '100%',
-      width: toRem(10),
-      zIndex: 1,
-    },
-  ],
-  variants: {
-    position: {
-      Left: {
-        left: 0,
-        background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
-      },
-      Right: {
-        right: 0,
-        background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
-      },
-    },
-  },
-});
-export const UrlPreviewHolderBtn = recipe({
-  base: [
-    DefaultReset,
-    {
-      position: 'absolute',
-      zIndex: 1,
-    },
-  ],
-  variants: {
-    position: {
-      Left: {
-        left: 0,
-        transform: 'translateX(-25%)',
-      },
-      Right: {
-        right: 0,
-        transform: 'translateX(25%)',
-      },
-    },
-  },
-});
diff --git a/src/app/organisms/room/message/util.ts b/src/app/organisms/room/message/util.ts
deleted file mode 100644 (file)
index 2cc4341..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { decryptFile } from '../../../utils/matrix';
-
-export const getFileSrcUrl = async (
-  httpUrl: string,
-  mimeType: string,
-  encInfo?: EncryptedAttachmentInfo
-): Promise<string> => {
-  if (encInfo) {
-    if (typeof httpUrl !== 'string') throw new Error('Malformed event');
-    const encRes = await fetch(httpUrl, { method: 'GET' });
-    const encData = await encRes.arrayBuffer();
-    const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
-    return URL.createObjectURL(decryptedBlob);
-  }
-  return httpUrl;
-};
-
-export const getSrcFile = async (src: string): Promise<Blob> => {
-  const res = await fetch(src, { method: 'GET' });
-  const blob = await res.blob();
-  return blob;
-};
diff --git a/src/app/organisms/room/msgContent.ts b/src/app/organisms/room/msgContent.ts
deleted file mode 100644 (file)
index 0760ec9..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
-import to from 'await-to-js';
-import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
-import {
-  getImageFileUrl,
-  getThumbnail,
-  getThumbnailDimensions,
-  getVideoFileUrl,
-  loadImageElement,
-  loadVideoElement,
-} from '../../utils/dom';
-import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
-import { TUploadItem } from '../../state/roomInputDrafts';
-import { encodeBlurHash } from '../../utils/blurHash';
-import { scaleYDimension } from '../../utils/common';
-
-const generateThumbnailContent = async (
-  mx: MatrixClient,
-  img: HTMLImageElement | HTMLVideoElement,
-  dimensions: [number, number],
-  encrypt: boolean
-): Promise<IThumbnailContent> => {
-  const thumbnail = await getThumbnail(img, ...dimensions);
-  if (!thumbnail) throw new Error('Can not create thumbnail!');
-  const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
-  const thumbnailFile = encThumbData?.file ?? thumbnail;
-  if (!thumbnailFile) throw new Error('Can not create thumbnail!');
-
-  const data = await mx.uploadContent(thumbnailFile);
-  const thumbMxc = data?.content_uri;
-  if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
-  const thumbnailContent = getThumbnailContent({
-    thumbnail: thumbnailFile,
-    encInfo: encThumbData?.encInfo,
-    mxc: thumbMxc,
-    width: dimensions[0],
-    height: dimensions[1],
-  });
-  return thumbnailContent;
-};
-
-export const getImageMsgContent = async (
-  mx: MatrixClient,
-  item: TUploadItem,
-  mxc: string
-): Promise<IContent> => {
-  const { file, originalFile, encInfo } = item;
-  const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
-  if (imgError) console.warn(imgError);
-
-  const content: IContent = {
-    msgtype: MsgType.Image,
-    body: file.name,
-  };
-  if (imgEl) {
-    const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
-
-    content.info = {
-      ...getImageInfo(imgEl, file),
-      [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
-    };
-  }
-  if (encInfo) {
-    content.file = {
-      ...encInfo,
-      url: mxc,
-    };
-  } else {
-    content.url = mxc;
-  }
-  return content;
-};
-
-export const getVideoMsgContent = async (
-  mx: MatrixClient,
-  item: TUploadItem,
-  mxc: string
-): Promise<IContent> => {
-  const { file, originalFile, encInfo } = item;
-
-  const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
-  if (videoError) console.warn(videoError);
-
-  const content: IContent = {
-    msgtype: MsgType.Video,
-    body: file.name,
-  };
-  if (videoEl) {
-    const [thumbError, thumbContent] = await to(
-      generateThumbnailContent(
-        mx,
-        videoEl,
-        getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
-        !!encInfo
-      )
-    );
-    if (thumbContent && thumbContent.thumbnail_info) {
-      thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(
-        videoEl,
-        512,
-        scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
-      );
-    }
-    if (thumbError) console.warn(thumbError);
-    content.info = {
-      ...getVideoInfo(videoEl, file),
-      ...thumbContent,
-    };
-  }
-  if (encInfo) {
-    content.file = {
-      ...encInfo,
-      url: mxc,
-    };
-  } else {
-    content.url = mxc;
-  }
-  return content;
-};
-
-export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
-  const { file, encInfo } = item;
-  const content: IContent = {
-    msgtype: MsgType.Audio,
-    body: file.name,
-    info: {
-      mimetype: file.type,
-      size: file.size,
-    },
-  };
-  if (encInfo) {
-    content.file = {
-      ...encInfo,
-      url: mxc,
-    };
-  } else {
-    content.url = mxc;
-  }
-  return content;
-};
-
-export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
-  const { file, encInfo } = item;
-  const content: IContent = {
-    msgtype: MsgType.File,
-    body: file.name,
-    filename: file.name,
-    info: {
-      mimetype: file.type,
-      size: file.size,
-    },
-  };
-  if (encInfo) {
-    content.file = {
-      ...encInfo,
-      url: mxc,
-    };
-  } else {
-    content.url = mxc;
-  }
-  return content;
-};
diff --git a/src/app/organisms/room/reaction-viewer/ReactionViewer.css.ts b/src/app/organisms/room/reaction-viewer/ReactionViewer.css.ts
deleted file mode 100644 (file)
index a8a85b0..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, color, config } from 'folds';
-
-export const ReactionViewer = style([
-  DefaultReset,
-  {
-    height: '100%',
-  },
-]);
-
-export const Sidebar = style({
-  backgroundColor: color.Background.Container,
-  color: color.Background.OnContainer,
-});
-export const SidebarContent = style({
-  padding: config.space.S200,
-  paddingRight: 0,
-});
-
-export const Header = style({
-  paddingLeft: config.space.S400,
-  paddingRight: config.space.S300,
-
-  flexShrink: 0,
-  gap: config.space.S200,
-});
-
-export const Content = style({
-  paddingLeft: config.space.S200,
-  paddingBottom: config.space.S400,
-});
diff --git a/src/app/organisms/room/reaction-viewer/ReactionViewer.tsx b/src/app/organisms/room/reaction-viewer/ReactionViewer.tsx
deleted file mode 100644 (file)
index 7bcc0cc..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import classNames from 'classnames';
-import {
-  Avatar,
-  AvatarFallback,
-  AvatarImage,
-  Box,
-  Header,
-  Icon,
-  IconButton,
-  Icons,
-  Line,
-  MenuItem,
-  Scroll,
-  Text,
-  as,
-  config,
-} from 'folds';
-import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
-import { Relations } from 'matrix-js-sdk/lib/models/relations';
-import { getMemberDisplayName } from '../../../utils/room';
-import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
-import * as css from './ReactionViewer.css';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import colorMXID from '../../../../util/colorMXID';
-import { openProfileViewer } from '../../../../client/action/navigation';
-import { useRelations } from '../../../hooks/useRelations';
-import { Reaction } from '../../../components/message';
-import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
-
-export type ReactionViewerProps = {
-  room: Room;
-  initialKey?: string;
-  relations: Relations;
-  requestClose: () => void;
-};
-export const ReactionViewer = as<'div', ReactionViewerProps>(
-  ({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
-    const mx = useMatrixClient();
-    const reactions = useRelations(
-      relations,
-      useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
-    );
-
-    const [selectedKey, setSelectedKey] = useState<string>(() => {
-      if (initialKey) return initialKey;
-      const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
-      return defaultReaction ? defaultReaction[0] : '';
-    });
-
-    const getName = (member: RoomMember) =>
-      getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
-
-    const getReactionsForKey = (key: string): MatrixEvent[] => {
-      const reactSet = reactions.find(([k]) => k === key)?.[1];
-      if (!reactSet) return [];
-      return Array.from(reactSet);
-    };
-
-    const selectedReactions = getReactionsForKey(selectedKey);
-    const selectedShortcode =
-      selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
-      getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
-      selectedKey;
-
-    return (
-      <Box
-        className={classNames(css.ReactionViewer, className)}
-        direction="Row"
-        {...props}
-        ref={ref}
-      >
-        <Box shrink="No" className={css.Sidebar}>
-          <Scroll visibility="Hover" hideTrack size="300">
-            <Box className={css.SidebarContent} direction="Column" gap="200">
-              {reactions.map(([key, evts]) => {
-                if (typeof key !== 'string') return null;
-                return (
-                  <Reaction
-                    key={key}
-                    mx={mx}
-                    reaction={key}
-                    count={evts.size}
-                    aria-selected={key === selectedKey}
-                    onClick={() => setSelectedKey(key)}
-                  />
-                );
-              })}
-            </Box>
-          </Scroll>
-        </Box>
-        <Line variant="Surface" direction="Vertical" size="300" />
-        <Box grow="Yes" direction="Column">
-          <Header className={css.Header} variant="Surface" size="600">
-            <Box grow="Yes">
-              <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
-            </Box>
-            <IconButton size="300" onClick={requestClose}>
-              <Icon src={Icons.Cross} />
-            </IconButton>
-          </Header>
-
-          <Box grow="Yes">
-            <Scroll visibility="Hover" hideTrack size="300">
-              <Box className={css.Content} direction="Column">
-                {selectedReactions.map((mEvent) => {
-                  const senderId = mEvent.getSender();
-                  if (!senderId) return null;
-                  const member = room.getMember(senderId);
-                  const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
-
-                  const avatarUrl = member?.getAvatarUrl(
-                    mx.baseUrl,
-                    100,
-                    100,
-                    'crop',
-                    undefined,
-                    false
-                  );
-
-                  return (
-                    <MenuItem
-                      key={senderId}
-                      style={{ padding: `0 ${config.space.S200}` }}
-                      radii="400"
-                      onClick={() => {
-                        requestClose();
-                        openProfileViewer(senderId, room.roomId);
-                      }}
-                      before={
-                        <Avatar size="200">
-                          {avatarUrl ? (
-                            <AvatarImage src={avatarUrl} />
-                          ) : (
-                            <AvatarFallback
-                              style={{
-                                background: colorMXID(senderId),
-                                color: 'white',
-                              }}
-                            >
-                              <Text size="H6">{name[0]}</Text>
-                            </AvatarFallback>
-                          )}
-                        </Avatar>
-                      }
-                    >
-                      <Box grow="Yes">
-                        <Text size="T400" truncate>
-                          {name}
-                        </Text>
-                      </Box>
-                    </MenuItem>
-                  );
-                })}
-              </Box>
-            </Scroll>
-          </Box>
-        </Box>
-      </Box>
-    );
-  }
-);
diff --git a/src/app/organisms/room/reaction-viewer/index.ts b/src/app/organisms/room/reaction-viewer/index.ts
deleted file mode 100644 (file)
index 172e6f3..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './ReactionViewer';
index 64c898bfdae3fa9b5a1c1853f3b1982c32611df6..66b6851149438358024d780252acfba89689dd8a 100644 (file)
@@ -5,7 +5,6 @@ import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
 import navigation from '../../../client/state/navigation';
 import AsyncSearch from '../../../util/AsyncSearch';
-import { selectRoom, selectTab } from '../../../client/action/navigation';
 import { joinRuleToIconSrc } from '../../../util/matrixUtil';
 import { roomIdByActivity } from '../../../util/sort';
 
@@ -19,6 +18,7 @@ import RoomSelector from '../../molecules/room-selector/RoomSelector';
 
 import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 
 function useVisiblityToggle(setResult) {
   const [isOpen, setIsOpen] = useState(false);
@@ -64,13 +64,13 @@ function mapRoomIds(roomIds) {
     if (room.isSpaceRoom()) type = 'space';
     else if (directs.has(roomId)) type = 'direct';
 
-    return ({
+    return {
       type,
       name: room.name,
       parents,
       roomId,
       room,
-    });
+    };
   });
 }
 
@@ -80,6 +80,7 @@ function Search() {
   const [isOpen, requestClose] = useVisiblityToggle(setResult);
   const searchRef = useRef(null);
   const mx = initMatrix.matrixClient;
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
 
   const handleSearchResults = (chunk, term) => {
     setResult({
@@ -155,8 +156,8 @@ function Search() {
   };
 
   const openItem = (roomId, type) => {
-    if (type === 'space') selectTab(roomId);
-    else selectRoom(roomId);
+    if (type === 'space') navigateSpace(roomId);
+    else navigateRoom(roomId);
     requestClose();
   };
 
@@ -173,7 +174,8 @@ function Search() {
     let imageSrc = null;
     let iconSrc = null;
     if (item.type === 'direct') {
-      imageSrc = item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
+      imageSrc =
+        item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
     } else {
       iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
     }
@@ -204,19 +206,21 @@ function Search() {
       size="small"
     >
       <div className="search-dialog">
-        <form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult(); }}>
+        <form
+          className="search-dialog__input"
+          onSubmit={(e) => {
+            e.preventDefault();
+            openFirstResult();
+          }}
+        >
           <RawIcon src={SearchIC} size="small" />
-          <Input
-            onChange={handleOnChange}
-            forwardRef={searchRef}
-            placeholder="Search"
-          />
+          <Input onChange={handleOnChange} forwardRef={searchRef} placeholder="Search" />
           <IconButton size="small" src={CrossIC} type="reset" onClick={handleCross} tabIndex={-1} />
         </form>
         <div className="search-dialog__content-wrapper">
           <ScrollView autoHide>
             <div className="search-dialog__content">
-              { Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector) }
+              {Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector)}
             </div>
           </ScrollView>
         </div>
index 2c9d6d46190402ca5e676b1995116bd6134b6b5e..46fe7b3f73588d638cc1742965d44e754a1fc36c 100644 (file)
@@ -8,12 +8,6 @@ import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
 import navigation from '../../../client/state/navigation';
 import { leave } from '../../../client/action/room';
-import {
-  createSpaceShortcut,
-  deleteSpaceShortcut,
-  categorizeSpace,
-  unCategorizeSpace,
-} from '../../../client/action/accountData';
 
 import Text from '../../atoms/text/Text';
 import IconButton from '../../atoms/button/IconButton';
@@ -32,14 +26,9 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
 import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
 import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import PinIC from '../../../../public/res/ic/outlined/pin.svg';
-import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
-import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
-import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
 import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
 
 import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
-import { useForceUpdate } from '../../hooks/useForceUpdate';
 
 const tabText = {
   GENERAL: 'General',
@@ -48,54 +37,36 @@ const tabText = {
   PERMISSIONS: 'Permissions',
 };
 
-const tabItems = [{
-  iconSrc: SettingsIC,
-  text: tabText.GENERAL,
-  disabled: false,
-}, {
-  iconSrc: UserIC,
-  text: tabText.MEMBERS,
-  disabled: false,
-}, {
-  iconSrc: EmojiIC,
-  text: tabText.EMOJIS,
-  disabled: false,
-}, {
-  iconSrc: ShieldUserIC,
-  text: tabText.PERMISSIONS,
-  disabled: false,
-}];
+const tabItems = [
+  {
+    iconSrc: SettingsIC,
+    text: tabText.GENERAL,
+    disabled: false,
+  },
+  {
+    iconSrc: UserIC,
+    text: tabText.MEMBERS,
+    disabled: false,
+  },
+  {
+    iconSrc: EmojiIC,
+    text: tabText.EMOJIS,
+    disabled: false,
+  },
+  {
+    iconSrc: ShieldUserIC,
+    text: tabText.PERMISSIONS,
+    disabled: false,
+  },
+];
 
 function GeneralSettings({ roomId }) {
-  const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
-  const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
   const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
-  const [, forceUpdate] = useForceUpdate();
 
   return (
     <>
       <div className="room-settings__card">
         <MenuHeader>Options</MenuHeader>
-        <MenuItem
-          onClick={() => {
-            if (isCategorized) unCategorizeSpace(roomId);
-            else categorizeSpace(roomId);
-            forceUpdate();
-          }}
-          iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
-        >
-          {isCategorized ? 'Uncategorize subspaces' : 'Categorize subspaces'}
-        </MenuItem>
-        <MenuItem
-          onClick={() => {
-            if (isPinned) deleteSpaceShortcut(roomId);
-            else createSpaceShortcut(roomId);
-            forceUpdate();
-          }}
-          iconSrc={isPinned ? PinFilledIC : PinIC}
-        >
-          {isPinned ? 'Unpin from sidebar' : 'Pin to sidebar'}
-        </MenuItem>
         <MenuItem
           variant="danger"
           onClick={async () => {
@@ -103,7 +74,7 @@ function GeneralSettings({ roomId }) {
               'Leave space',
               `Are you sure that you want to leave "${roomName}" space?`,
               'Leave',
-              'danger',
+              'danger'
             );
             if (isConfirmed) leave(roomId);
           }}
@@ -165,12 +136,12 @@ function SpaceSettings() {
     <PopupWindow
       isOpen={isOpen}
       className="space-settings"
-      title={(
+      title={
         <Text variant="s1" weight="medium" primary>
           {isOpen && twemojify(room.name)}
           <span style={{ color: 'var(--tc-surface-low)' }}> â€” space settings</span>
         </Text>
-      )}
+      }
       contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
       onRequestClose={requestClose}
     >
index 62c173f91a250a632ea75b379a19e5f22ad9d81e..b16462dffd00845cdcff1799f09bdd5ead13034d 100644 (file)
@@ -1,84 +1,43 @@
 import React from 'react';
 import { Provider as JotaiProvider } from 'jotai';
-import {
-  Route,
-  RouterProvider,
-  createBrowserRouter,
-  createHashRouter,
-  createRoutesFromElements,
-  redirect,
-} from 'react-router-dom';
+import { RouterProvider } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
 
 import { ClientConfigLoader } from '../components/ClientConfigLoader';
-import { ClientConfig, ClientConfigProvider } from '../hooks/useClientConfig';
-import { AuthLayout, Login, Register, ResetPassword, authLayoutLoader } from './auth';
-import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
-import { isAuthenticated } from '../../client/state/auth';
-import Client from '../templates/client/Client';
-import { getLoginPath } from './pathUtils';
+import { ClientConfigProvider } from '../hooks/useClientConfig';
 import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
 import { FeatureCheck } from './FeatureCheck';
+import { createRouter } from './Router';
+import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
 
-const createRouter = (clientConfig: ClientConfig) => {
-  const { hashRouter } = clientConfig;
+const queryClient = new QueryClient();
 
-  const routes = createRoutesFromElements(
-    <Route>
-      <Route
-        path={ROOT_PATH}
-        loader={() => {
-          if (isAuthenticated()) return redirect('/home');
-          return redirect(getLoginPath());
-        }}
-      />
-      <Route loader={authLayoutLoader} element={<AuthLayout />}>
-        <Route path={LOGIN_PATH} element={<Login />} />
-        <Route path={REGISTER_PATH} element={<Register />} />
-        <Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
-      </Route>
-
-      <Route
-        loader={() => {
-          if (!isAuthenticated()) return redirect(getLoginPath());
-          return null;
-        }}
-      >
-        <Route path="/home" element={<Client />} />
-        <Route path="/direct" element={<p>direct</p>} />
-        <Route path="/:spaceIdOrAlias" element={<p>:spaceIdOrAlias</p>} />
-        <Route path="/explore" element={<p>explore</p>} />
-      </Route>
-      <Route path="/*" element={<p>Page not found</p>} />
-    </Route>
-  );
-
-  if (hashRouter?.enabled) {
-    return createHashRouter(routes, { basename: hashRouter.basename });
-  }
-  return createBrowserRouter(routes, {
-    basename: import.meta.env.BASE_URL,
-  });
-};
-
-// TODO: app crash boundary
 function App() {
+  const screenSize = useScreenSize();
+
   return (
-    <FeatureCheck>
-      <ClientConfigLoader
-        fallback={() => <ConfigConfigLoading />}
-        error={(err, retry, ignore) => (
-          <ConfigConfigError error={err} retry={retry} ignore={ignore} />
-        )}
-      >
-        {(clientConfig) => (
-          <ClientConfigProvider value={clientConfig}>
-            <JotaiProvider>
-              <RouterProvider router={createRouter(clientConfig)} />
-            </JotaiProvider>
-          </ClientConfigProvider>
-        )}
-      </ClientConfigLoader>
-    </FeatureCheck>
+    <ScreenSizeProvider value={screenSize}>
+      <FeatureCheck>
+        <ClientConfigLoader
+          fallback={() => <ConfigConfigLoading />}
+          error={(err, retry, ignore) => (
+            <ConfigConfigError error={err} retry={retry} ignore={ignore} />
+          )}
+        >
+          {(clientConfig) => (
+            <ClientConfigProvider value={clientConfig}>
+              <QueryClientProvider client={queryClient}>
+                <JotaiProvider>
+                  <RouterProvider router={createRouter(clientConfig, screenSize)} />
+                </JotaiProvider>
+                <ReactQueryDevtools initialIsOpen={false} />
+              </QueryClientProvider>
+            </ClientConfigProvider>
+          )}
+        </ClientConfigLoader>
+      </FeatureCheck>
+    </ScreenSizeProvider>
   );
 }
 
diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx
new file mode 100644 (file)
index 0000000..ca947ac
--- /dev/null
@@ -0,0 +1,44 @@
+import { ReactNode } from 'react';
+import { useMatch } from 'react-router-dom';
+import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
+import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
+
+type MobileFriendlyClientNavProps = {
+  children: ReactNode;
+};
+export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
+  const screenSize = useScreenSizeContext();
+  const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
+  const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
+  const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
+  const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
+  const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
+
+  if (
+    screenSize === ScreenSize.Mobile &&
+    !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
+  ) {
+    return null;
+  }
+
+  return children;
+}
+
+type MobileFriendlyPageNavProps = {
+  path: string;
+  children: ReactNode;
+};
+export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) {
+  const screenSize = useScreenSizeContext();
+  const exactPath = useMatch({
+    path,
+    caseSensitive: true,
+    end: true,
+  });
+
+  if (screenSize === ScreenSize.Mobile && !exactPath) {
+    return null;
+  }
+
+  return children;
+}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
new file mode 100644 (file)
index 0000000..ffa20d4
--- /dev/null
@@ -0,0 +1,269 @@
+import React from 'react';
+import {
+  Outlet,
+  Route,
+  createBrowserRouter,
+  createHashRouter,
+  createRoutesFromElements,
+  redirect,
+} from 'react-router-dom';
+
+import { ClientConfig } from '../hooks/useClientConfig';
+import { AuthLayout, Login, Register, ResetPassword } from './auth';
+import {
+  DIRECT_PATH,
+  EXPLORE_PATH,
+  HOME_PATH,
+  LOGIN_PATH,
+  INBOX_PATH,
+  REGISTER_PATH,
+  RESET_PASSWORD_PATH,
+  SPACE_PATH,
+  _CREATE_PATH,
+  _FEATURED_PATH,
+  _INVITES_PATH,
+  _JOIN_PATH,
+  _LOBBY_PATH,
+  _NOTIFICATIONS_PATH,
+  _ROOM_PATH,
+  _SEARCH_PATH,
+  _SERVER_PATH,
+} from './paths';
+import { isAuthenticated } from '../../client/state/auth';
+import {
+  getAppPathFromHref,
+  getExploreFeaturedPath,
+  getHomePath,
+  getInboxNotificationsPath,
+  getLoginPath,
+  getOriginBaseUrl,
+  getSpaceLobbyPath,
+} from './pathUtils';
+import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
+import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
+import { Direct, DirectRouteRoomProvider } from './client/direct';
+import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
+import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
+import { Notifications, Inbox, Invites } from './client/inbox';
+import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
+import { Room } from '../features/room';
+import { Lobby } from '../features/lobby';
+import { WelcomePage } from './client/WelcomePage';
+import { SidebarNav } from './client/SidebarNav';
+import { PageRoot } from '../components/page';
+import { ScreenSize } from '../hooks/useScreenSize';
+import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
+import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
+
+export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
+  const { hashRouter } = clientConfig;
+  const mobile = screenSize === ScreenSize.Mobile;
+
+  const routes = createRoutesFromElements(
+    <Route>
+      <Route
+        index
+        loader={() => {
+          if (isAuthenticated()) return redirect(getHomePath());
+          const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
+          if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
+          return redirect(getLoginPath());
+        }}
+      />
+      <Route
+        loader={() => {
+          if (isAuthenticated()) {
+            return redirect(getHomePath());
+          }
+
+          return null;
+        }}
+        element={<AuthLayout />}
+      >
+        <Route path={LOGIN_PATH} element={<Login />} />
+        <Route path={REGISTER_PATH} element={<Register />} />
+        <Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
+      </Route>
+
+      <Route
+        loader={() => {
+          if (!isAuthenticated()) {
+            const afterLoginPath = getAppPathFromHref(
+              getOriginBaseUrl(hashRouter),
+              window.location.href
+            );
+            if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
+            return redirect(getLoginPath());
+          }
+          return null;
+        }}
+        element={
+          <ClientRoot>
+            <ClientInitStorageAtom>
+              <ClientBindAtoms>
+                <ClientLayout
+                  nav={
+                    <MobileFriendlyClientNav>
+                      <SidebarNav />
+                    </MobileFriendlyClientNav>
+                  }
+                >
+                  <Outlet />
+                </ClientLayout>
+              </ClientBindAtoms>
+            </ClientInitStorageAtom>
+          </ClientRoot>
+        }
+      >
+        <Route
+          path={HOME_PATH}
+          element={
+            <PageRoot
+              nav={
+                <MobileFriendlyPageNav path={HOME_PATH}>
+                  <Home />
+                </MobileFriendlyPageNav>
+              }
+            >
+              <Outlet />
+            </PageRoot>
+          }
+        >
+          {mobile ? null : <Route index element={<WelcomePage />} />}
+          <Route path={_CREATE_PATH} element={<p>create</p>} />
+          <Route path={_JOIN_PATH} element={<p>join</p>} />
+          <Route path={_SEARCH_PATH} element={<HomeSearch />} />
+          <Route
+            path={_ROOM_PATH}
+            element={
+              <HomeRouteRoomProvider>
+                <Room />
+              </HomeRouteRoomProvider>
+            }
+          />
+        </Route>
+        <Route
+          path={DIRECT_PATH}
+          element={
+            <PageRoot
+              nav={
+                <MobileFriendlyPageNav path={DIRECT_PATH}>
+                  <Direct />
+                </MobileFriendlyPageNav>
+              }
+            >
+              <Outlet />
+            </PageRoot>
+          }
+        >
+          {mobile ? null : <Route index element={<WelcomePage />} />}
+          <Route path={_CREATE_PATH} element={<p>create</p>} />
+          <Route
+            path={_ROOM_PATH}
+            element={
+              <DirectRouteRoomProvider>
+                <Room />
+              </DirectRouteRoomProvider>
+            }
+          />
+        </Route>
+        <Route
+          path={SPACE_PATH}
+          element={
+            <RouteSpaceProvider>
+              <PageRoot
+                nav={
+                  <MobileFriendlyPageNav path={SPACE_PATH}>
+                    <Space />
+                  </MobileFriendlyPageNav>
+                }
+              >
+                <Outlet />
+              </PageRoot>
+            </RouteSpaceProvider>
+          }
+        >
+          {mobile ? null : (
+            <Route
+              index
+              loader={({ params }) => {
+                const { spaceIdOrAlias } = params;
+                if (spaceIdOrAlias) {
+                  return redirect(getSpaceLobbyPath(spaceIdOrAlias));
+                }
+                return null;
+              }}
+              element={<WelcomePage />}
+            />
+          )}
+          <Route path={_LOBBY_PATH} element={<Lobby />} />
+          <Route path={_SEARCH_PATH} element={<SpaceSearch />} />
+          <Route
+            path={_ROOM_PATH}
+            element={
+              <SpaceRouteRoomProvider>
+                <Room />
+              </SpaceRouteRoomProvider>
+            }
+          />
+        </Route>
+        <Route
+          path={EXPLORE_PATH}
+          element={
+            <PageRoot
+              nav={
+                <MobileFriendlyPageNav path={EXPLORE_PATH}>
+                  <Explore />
+                </MobileFriendlyPageNav>
+              }
+            >
+              <Outlet />
+            </PageRoot>
+          }
+        >
+          {mobile ? null : (
+            <Route
+              index
+              loader={() => redirect(getExploreFeaturedPath())}
+              element={<WelcomePage />}
+            />
+          )}
+          <Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
+          <Route path={_SERVER_PATH} element={<PublicRooms />} />
+        </Route>
+        <Route
+          path={INBOX_PATH}
+          element={
+            <PageRoot
+              nav={
+                <MobileFriendlyPageNav path={INBOX_PATH}>
+                  <Inbox />
+                </MobileFriendlyPageNav>
+              }
+            >
+              <Outlet />
+            </PageRoot>
+          }
+        >
+          {mobile ? null : (
+            <Route
+              index
+              loader={() => redirect(getInboxNotificationsPath())}
+              element={<WelcomePage />}
+            />
+          )}
+          <Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
+          <Route path={_INVITES_PATH} element={<Invites />} />
+        </Route>
+      </Route>
+      <Route path="/*" element={<p>Page not found</p>} />
+    </Route>
+  );
+
+  if (hashRouter?.enabled) {
+    return createHashRouter(routes, { basename: hashRouter.basename });
+  }
+  return createBrowserRouter(routes, {
+    basename: import.meta.env.BASE_URL,
+  });
+};
diff --git a/src/app/pages/afterLoginRedirectPath.ts b/src/app/pages/afterLoginRedirectPath.ts
new file mode 100644 (file)
index 0000000..60e09da
--- /dev/null
@@ -0,0 +1,12 @@
+const AFTER_LOGIN_REDIRECT_PATH_KEY = 'after_login_redirect_url';
+
+export const setAfterLoginRedirectPath = (url: string): void => {
+  localStorage.setItem(AFTER_LOGIN_REDIRECT_PATH_KEY, url);
+};
+export const getAfterLoginRedirectPath = (): string | undefined => {
+  const url = localStorage.getItem(AFTER_LOGIN_REDIRECT_PATH_KEY);
+  return url ?? undefined;
+};
+export const deleteAfterLoginRedirectPath = (): void => {
+  localStorage.removeItem(AFTER_LOGIN_REDIRECT_PATH_KEY);
+};
index c58ecdd5f550daba5110328fb6bdf52f6f04a15b..2ea9414200800146c59d64f4a8d308b21de71f39 100644 (file)
@@ -1,11 +1,9 @@
 import React, { useCallback, useEffect } from 'react';
 import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
 import {
-  LoaderFunction,
   Outlet,
   generatePath,
   matchPath,
-  redirect,
   useLocation,
   useNavigate,
   useParams,
@@ -15,14 +13,13 @@ import classNames from 'classnames';
 import { AuthFooter } from './AuthFooter';
 import * as css from './styles.css';
 import * as PatternsCss from '../../styles/Patterns.css';
-import { isAuthenticated } from '../../../client/state/auth';
 import {
   clientAllowedServer,
   clientDefaultServer,
   useClientConfig,
 } from '../../hooks/useClientConfig';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { LOGIN_PATH, REGISTER_PATH } from '../paths';
+import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
 import CinnySVG from '../../../../public/res/svg/cinny.svg';
 import { ServerPicker } from './ServerPicker';
 import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
@@ -33,18 +30,13 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
 import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
 import { AuthServerProvider } from '../../hooks/useAuthServer';
 
-export const authLayoutLoader: LoaderFunction = () => {
-  if (isAuthenticated()) {
-    return redirect('/');
-  }
-
-  return null;
-};
-
 const currentAuthPath = (pathname: string): string => {
   if (matchPath(LOGIN_PATH, pathname)) {
     return LOGIN_PATH;
   }
+  if (matchPath(RESET_PASSWORD_PATH, pathname)) {
+    return RESET_PASSWORD_PATH;
+  }
   if (matchPath(REGISTER_PATH, pathname)) {
     return REGISTER_PATH;
   }
@@ -175,6 +167,7 @@ export function AuthLayout() {
               <AuthServerProvider value={discoveryState.data.serverName}>
                 <AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
                   <SpecVersionsLoader
+                    baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
                     fallback={() => (
                       <AuthLayoutLoading
                         message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
index a9c1c54bc104da4bca58f9f90e03bf0edc1bc4be..0d3d917f17266f0f6721460531cfaaf8cff10478 100644 (file)
@@ -15,6 +15,10 @@ export function SSOLogin({ providers, redirectUrl, asIcons }: SSOLoginProps) {
 
   const getSSOIdUrl = (ssoId: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
 
+  const anyAsBtn = providers.find(
+    (provider) => !provider.icon || !mx.mxcUrlToHttp(provider.icon, 96, 96, 'crop', false)
+  );
+
   return (
     <Box justifyContent="Center" gap="600" wrap="Wrap">
       {providers.map((provider) => {
@@ -23,7 +27,7 @@ export function SSOLogin({ providers, redirectUrl, asIcons }: SSOLoginProps) {
 
         const buttonTitle = `Continue with ${name}`;
 
-        if (iconUrl && asIcons) {
+        if (!anyAsBtn && iconUrl && asIcons) {
           return (
             <Avatar
               style={{ cursor: 'pointer' }}
index 5f5dcf65c77f45c91e018a9d02ba497b02adf684..18201c98cbd281e6411326615f4a9ae193c4e052 100644 (file)
@@ -15,6 +15,7 @@ import {
   Menu,
   MenuItem,
   PopOut,
+  RectCords,
   Text,
   config,
 } from 'folds';
@@ -33,7 +34,7 @@ export function ServerPicker({
   allowCustomServer?: boolean;
   onServerChange: (server: string) => void;
 }) {
-  const [serverMenu, setServerMenu] = useState(false);
+  const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
   const serverInputRef = useRef<HTMLInputElement>(null);
 
   useEffect(() => {
@@ -53,7 +54,7 @@ export function ServerPicker({
   const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
     if (evt.key === 'ArrowDown') {
       evt.preventDefault();
-      setServerMenu(true);
+      setServerMenuAnchor(undefined);
     }
     if (evt.key === 'Enter') {
       evt.preventDefault();
@@ -67,7 +68,12 @@ export function ServerPicker({
     if (selectedServer) {
       onServerChange(selectedServer);
     }
-    setServerMenu(false);
+    setServerMenuAnchor(undefined);
+  };
+
+  const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
+    const target = evt.currentTarget.parentElement ?? evt.currentTarget;
+    setServerMenuAnchor(target.getBoundingClientRect());
   };
 
   return (
@@ -81,11 +87,11 @@ export function ServerPicker({
       onKeyDown={handleKeyDown}
       size="500"
       readOnly={!allowCustomServer}
-      onClick={allowCustomServer ? undefined : () => setServerMenu(true)}
+      onClick={allowCustomServer ? undefined : handleOpenServerMenu}
       after={
         serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
           <PopOut
-            open={serverMenu}
+            anchor={serverMenuAnchor}
             position="Bottom"
             align="End"
             offset={4}
@@ -93,7 +99,7 @@ export function ServerPicker({
               <FocusTrap
                 focusTrapOptions={{
                   initialFocus: false,
-                  onDeactivate: () => setServerMenu(false),
+                  onDeactivate: () => setServerMenuAnchor(undefined),
                   clickOutsideDeactivates: true,
                   isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
                   isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
@@ -120,18 +126,15 @@ export function ServerPicker({
               </FocusTrap>
             }
           >
-            {(anchorRef) => (
-              <IconButton
-                ref={anchorRef}
-                onClick={() => setServerMenu(true)}
-                variant={allowCustomServer ? 'Background' : 'Surface'}
-                size="300"
-                aria-pressed={serverMenu}
-                radii="300"
-              >
-                <Icon src={Icons.ChevronBottom} />
-              </IconButton>
-            )}
+            <IconButton
+              onClick={handleOpenServerMenu}
+              variant={allowCustomServer ? 'Background' : 'Surface'}
+              size="300"
+              aria-pressed={!!serverMenuAnchor}
+              radii="300"
+            >
+              <Icon src={Icons.ChevronBottom} />
+            </IconButton>
           </PopOut>
         )
       }
index 901b19cbe3ee5401e16ba6a42b63b80f7eb2c7e7..e1689d1e4a842a0ce3ff689bf03348bba4450d65 100644 (file)
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import { Box, Text, color } from 'folds';
 import { Link, useSearchParams } from 'react-router-dom';
 import { useAuthFlows } from '../../../hooks/useAuthFlows';
@@ -23,18 +23,22 @@ const getLoginTokenSearchParam = () => {
   return loginToken ?? undefined;
 };
 
-const getLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams => ({
-  username: searchParams.get('username') ?? undefined,
-  email: searchParams.get('email') ?? undefined,
-  loginToken: searchParams.get('loginToken') ?? undefined,
-});
+const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams =>
+  useMemo(
+    () => ({
+      username: searchParams.get('username') ?? undefined,
+      email: searchParams.get('email') ?? undefined,
+      loginToken: searchParams.get('loginToken') ?? undefined,
+    }),
+    [searchParams]
+  );
 
 export function Login() {
   const server = useAuthServer();
   const { hashRouter } = useClientConfig();
   const { loginFlows } = useAuthFlows();
   const [searchParams] = useSearchParams();
-  const loginSearchParams = getLoginSearchParams(searchParams);
+  const loginSearchParams = useLoginSearchParams(searchParams);
   const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
   const loginTokenForHashRouter = getLoginTokenSearchParam();
   const absoluteLoginPath = usePathWithOrigin(getLoginPath(server));
index ea52aad8cab2efea806ce0ccefb2db50c568fab2..b9dd14b7ad79fc4bb3ad7dc2085f8f154a6ad927 100644 (file)
@@ -1,4 +1,4 @@
-import React, { FormEventHandler, useCallback, useState } from 'react';
+import React, { FormEventHandler, MouseEventHandler, useCallback, useState } from 'react';
 import {
   Box,
   Button,
@@ -12,6 +12,7 @@ import {
   OverlayBackdrop,
   OverlayCenter,
   PopOut,
+  RectCords,
   Spinner,
   Text,
   config,
@@ -37,17 +38,21 @@ import { FieldError } from '../FiledError';
 import { getResetPasswordPath } from '../../pathUtils';
 
 function UsernameHint({ server }: { server: string }) {
-  const [open, setOpen] = useState(false);
+  const [anchor, setAnchor] = useState<RectCords>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
+    setAnchor(evt.currentTarget.getBoundingClientRect());
+  };
   return (
     <PopOut
-      open={open}
+      anchor={anchor}
       position="Top"
       align="End"
       content={
         <FocusTrap
           focusTrapOptions={{
             initialFocus: false,
-            onDeactivate: () => setOpen(false),
+            onDeactivate: () => setAnchor(undefined),
             clickOutsideDeactivates: true,
           }}
         >
@@ -84,20 +89,17 @@ function UsernameHint({ server }: { server: string }) {
         </FocusTrap>
       }
     >
-      {(targetRef) => (
-        <IconButton
-          tabIndex={-1}
-          onClick={() => setOpen(true)}
-          ref={targetRef}
-          type="button"
-          variant="Background"
-          size="300"
-          radii="300"
-          aria-pressed={open}
-        >
-          <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
-        </IconButton>
-      )}
+      <IconButton
+        tabIndex={-1}
+        onClick={handleOpenMenu}
+        type="button"
+        variant="Background"
+        size="300"
+        radii="300"
+        aria-pressed={!!anchor}
+      >
+        <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
+      </IconButton>
     </PopOut>
   );
 }
index b2fd3870949fb73f2ba7f6943fc02ed99ece865b..1e2248d9041bf2b6bd765d71cc305bd5c6dc317a 100644 (file)
@@ -5,8 +5,12 @@ import { useNavigate } from 'react-router-dom';
 import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
 import { autoDiscovery, specVersions } from '../../../cs-api';
 import { updateLocalStore } from '../../../../client/action/auth';
-import { ROOT_PATH } from '../../paths';
 import { ErrorCode } from '../../../cs-errorcode';
+import {
+  deleteAfterLoginRedirectPath,
+  getAfterLoginRedirectPath,
+} from '../../afterLoginRedirectPath';
+import { getHomePath } from '../../pathUtils';
 
 export enum GetBaseUrlError {
   NotAllow = 'NotAllow',
@@ -111,8 +115,9 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
     if (data) {
       const { response: loginRes, baseUrl: loginBaseUrl } = data;
       updateLocalStore(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
-      // TODO: add after login redirect url
-      navigate(ROOT_PATH, { replace: true });
+      const afterLoginRedirectUrl = getAfterLoginRedirectPath();
+      deleteAfterLoginRedirectPath();
+      navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
     }
   }, [data, navigate]);
 };
index 756b13b307354264e8513d38f773b9721787f53e..c859d0e540ca2fd8760b1330426cc0ded97278ff 100644 (file)
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import { Box, Text, color } from 'folds';
 import { Link, useSearchParams } from 'react-router-dom';
 import { useAuthServer } from '../../../hooks/useAuthServer';
@@ -12,17 +12,21 @@ import { getLoginPath } from '../../pathUtils';
 import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
 import { RegisterPathSearchParams } from '../../paths';
 
-const getRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => ({
-  username: searchParams.get('username') ?? undefined,
-  email: searchParams.get('email') ?? undefined,
-  token: searchParams.get('token') ?? undefined,
-});
+const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
+  useMemo(
+    () => ({
+      username: searchParams.get('username') ?? undefined,
+      email: searchParams.get('email') ?? undefined,
+      token: searchParams.get('token') ?? undefined,
+    }),
+    [searchParams]
+  );
 
 export function Register() {
   const server = useAuthServer();
   const { loginFlows, registerFlows } = useAuthFlows();
   const [searchParams] = useSearchParams();
-  const registerSearchParams = getRegisterSearchParams(searchParams);
+  const registerSearchParams = useRegisterSearchParams(searchParams);
   const { sso } = useParsedLoginFlows(loginFlows.flows);
 
   // redirect to /login because only that path handle m.login.token
index 23c3d6a1d5a8bd328a5ed76eb2834c140ab08baa..e8145780a27d2c1ccbee4a16ab9a3f6229b80257 100644 (file)
@@ -9,8 +9,14 @@ import {
 import { useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { updateLocalStore } from '../../../../client/action/auth';
-import { ROOT_PATH } from '../../paths';
+import { LoginPathSearchParams } from '../../paths';
 import { ErrorCode } from '../../../cs-errorcode';
+import {
+  deleteAfterLoginRedirectPath,
+  getAfterLoginRedirectPath,
+} from '../../afterLoginRedirectPath';
+import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
+import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
 
 export enum RegisterError {
   UserTaken = 'UserTaken',
@@ -114,11 +120,18 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => {
 
       if (accessToken && deviceId) {
         updateLocalStore(accessToken, deviceId, userId, baseUrl);
-        // TODO: add after register redirect url
-        navigate(ROOT_PATH, { replace: true });
+        const afterLoginRedirectPath = getAfterLoginRedirectPath();
+        deleteAfterLoginRedirectPath();
+        navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true });
       } else {
-        // TODO: navigate to login with userId
-        navigate(ROOT_PATH, { replace: true });
+        const username = getMxIdLocalPart(userId);
+        const userServer = getMxIdServer(userId);
+        navigate(
+          withSearchParam<LoginPathSearchParams>(getLoginPath(userServer), {
+            username,
+          }),
+          { replace: true }
+        );
       }
     }
   }, [data, navigate]);
index 1ada9afd725af97e2d1765cf459845266afd56a4..c5e1d2ad35e141dac90ad18c3c3a47543d21a591 100644 (file)
@@ -1,24 +1,25 @@
 import { Box, Text } from 'folds';
-import React from 'react';
+import React, { useMemo } from 'react';
 import { Link, useSearchParams } from 'react-router-dom';
 import { getLoginPath } from '../../pathUtils';
 import { useAuthServer } from '../../../hooks/useAuthServer';
 import { PasswordResetForm } from './PasswordResetForm';
+import { ResetPasswordPathSearchParams } from '../../paths';
 
-export type ResetPasswordSearchParams = {
-  email?: string;
-};
-
-const getResetPasswordSearchParams = (
+const useResetPasswordSearchParams = (
   searchParams: URLSearchParams
-): ResetPasswordSearchParams => ({
-  email: searchParams.get('email') ?? undefined,
-});
+): ResetPasswordPathSearchParams =>
+  useMemo(
+    () => ({
+      email: searchParams.get('email') ?? undefined,
+    }),
+    [searchParams]
+  );
 
 export function ResetPassword() {
   const server = useAuthServer();
   const [searchParams] = useSearchParams();
-  const resetPasswordSearchParams = getResetPasswordSearchParams(searchParams);
+  const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams);
 
   return (
     <Box direction="Column" gap="500">
diff --git a/src/app/pages/client/ClientBindAtoms.ts b/src/app/pages/client/ClientBindAtoms.ts
new file mode 100644 (file)
index 0000000..5d57321
--- /dev/null
@@ -0,0 +1,14 @@
+import { ReactNode } from 'react';
+
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useBindAtoms } from '../../state/hooks/useBindAtoms';
+
+type ClientBindAtomsProps = {
+  children: ReactNode;
+};
+export function ClientBindAtoms({ children }: ClientBindAtomsProps) {
+  const mx = useMatrixClient();
+  useBindAtoms(mx);
+
+  return children;
+}
diff --git a/src/app/pages/client/ClientInitStorageAtom.tsx b/src/app/pages/client/ClientInitStorageAtom.tsx
new file mode 100644 (file)
index 0000000..1abee70
--- /dev/null
@@ -0,0 +1,38 @@
+import React, { ReactNode, useMemo } from 'react';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { makeClosedNavCategoriesAtom } from '../../state/closedNavCategories';
+import { ClosedNavCategoriesProvider } from '../../state/hooks/closedNavCategories';
+import { makeClosedLobbyCategoriesAtom } from '../../state/closedLobbyCategories';
+import { ClosedLobbyCategoriesProvider } from '../../state/hooks/closedLobbyCategories';
+import { makeNavToActivePathAtom } from '../../state/navToActivePath';
+import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
+import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
+import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
+
+type ClientInitStorageAtomProps = {
+  children: ReactNode;
+};
+export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) {
+  const mx = useMatrixClient();
+  const userId = mx.getUserId()!;
+
+  const closedNavCategoriesAtom = useMemo(() => makeClosedNavCategoriesAtom(userId), [userId]);
+
+  const closedLobbyCategoriesAtom = useMemo(() => makeClosedLobbyCategoriesAtom(userId), [userId]);
+
+  const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]);
+
+  const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
+
+  return (
+    <ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
+      <ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
+        <NavToActivePathProvider value={navToActivePathAtom}>
+          <OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
+            {children}
+          </OpenedSidebarFolderProvider>
+        </NavToActivePathProvider>
+      </ClosedLobbyCategoriesProvider>
+    </ClosedNavCategoriesProvider>
+  );
+}
diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx
new file mode 100644 (file)
index 0000000..208d12e
--- /dev/null
@@ -0,0 +1,15 @@
+import React, { ReactNode } from 'react';
+import { Box } from 'folds';
+
+type ClientLayoutProps = {
+  nav: ReactNode;
+  children: ReactNode;
+};
+export function ClientLayout({ nav, children }: ClientLayoutProps) {
+  return (
+    <Box style={{ height: '100%' }}>
+      <Box shrink="No">{nav}</Box>
+      <Box grow="Yes">{children}</Box>
+    </Box>
+  );
+}
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
new file mode 100644 (file)
index 0000000..1bb7855
--- /dev/null
@@ -0,0 +1,87 @@
+import { Box, Spinner, Text } from 'folds';
+import React, { ReactNode, useEffect, useState } from 'react';
+import initMatrix from '../../../client/initMatrix';
+import { initHotkeys } from '../../../client/event/hotkeys';
+import { initRoomListListener } from '../../../client/event/roomList';
+import { getSecret } from '../../../client/state/auth';
+import { SplashScreen } from '../../components/splash-screen';
+import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
+import { CapabilitiesProvider } from '../../hooks/useCapabilities';
+import { MediaConfigProvider } from '../../hooks/useMediaConfig';
+import { MatrixClientProvider } from '../../hooks/useMatrixClient';
+import { SpecVersions } from './SpecVersions';
+import Windows from '../../organisms/pw/Windows';
+import Dialogs from '../../organisms/pw/Dialogs';
+import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+
+function SystemEmojiFeature() {
+  const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
+
+  if (twitterEmoji) {
+    document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
+  } else {
+    document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
+  }
+
+  return null;
+}
+
+function ClientRootLoading() {
+  return (
+    <SplashScreen>
+      <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
+        <Spinner variant="Secondary" size="600" />
+        <Text>Heating up</Text>
+      </Box>
+    </SplashScreen>
+  );
+}
+
+type ClientRootProps = {
+  children: ReactNode;
+};
+export function ClientRoot({ children }: ClientRootProps) {
+  const [loading, setLoading] = useState(true);
+  const { baseUrl } = getSecret();
+
+  useEffect(() => {
+    const handleStart = () => {
+      initHotkeys();
+      initRoomListListener(initMatrix.roomList);
+      setLoading(false);
+    };
+    initMatrix.once('init_loading_finished', handleStart);
+    if (!initMatrix.matrixClient) initMatrix.init();
+    return () => {
+      initMatrix.removeListener('init_loading_finished', handleStart);
+    };
+  }, []);
+
+  return (
+    <SpecVersions baseUrl={baseUrl!}>
+      {loading ? (
+        <ClientRootLoading />
+      ) : (
+        <MatrixClientProvider value={initMatrix.matrixClient!}>
+          <CapabilitiesAndMediaConfigLoader>
+            {(capabilities, mediaConfig) => (
+              <CapabilitiesProvider value={capabilities ?? {}}>
+                <MediaConfigProvider value={mediaConfig ?? {}}>
+                  {children}
+
+                  {/* TODO: remove these components after navigation refactor */}
+                  <Windows />
+                  <Dialogs />
+                  <ReusableContextMenu />
+                  <SystemEmojiFeature />
+                </MediaConfigProvider>
+              </CapabilitiesProvider>
+            )}
+          </CapabilitiesAndMediaConfigLoader>
+        </MatrixClientProvider>
+      )}
+    </SpecVersions>
+  );
+}
diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx
new file mode 100644 (file)
index 0000000..fb6bd74
--- /dev/null
@@ -0,0 +1,76 @@
+import React, { useRef } from 'react';
+import { Icon, Icons, Scroll } from 'folds';
+
+import {
+  Sidebar,
+  SidebarContent,
+  SidebarStackSeparator,
+  SidebarStack,
+  SidebarAvatar,
+  SidebarItemTooltip,
+  SidebarItem,
+} from '../../components/sidebar';
+import { DirectTab, HomeTab, SpaceTabs, InboxTab, ExploreTab, UserTab } from './sidebar';
+import { openCreateRoom, openSearch } from '../../../client/action/navigation';
+
+export function SidebarNav() {
+  const scrollRef = useRef<HTMLDivElement>(null);
+
+  return (
+    <Sidebar>
+      <SidebarContent
+        scrollable={
+          <Scroll ref={scrollRef} variant="Background" size="0">
+            <SidebarStack>
+              <HomeTab />
+              <DirectTab />
+            </SidebarStack>
+            <SpaceTabs scrollRef={scrollRef} />
+            <SidebarStackSeparator />
+            <SidebarStack>
+              <ExploreTab />
+              <SidebarItem>
+                <SidebarItemTooltip tooltip="Create Space">
+                  {(triggerRef) => (
+                    <SidebarAvatar
+                      as="button"
+                      ref={triggerRef}
+                      outlined
+                      onClick={() => openCreateRoom(true)}
+                    >
+                      <Icon src={Icons.Plus} />
+                    </SidebarAvatar>
+                  )}
+                </SidebarItemTooltip>
+              </SidebarItem>
+            </SidebarStack>
+          </Scroll>
+        }
+        sticky={
+          <>
+            <SidebarStackSeparator />
+            <SidebarStack>
+              <SidebarItem>
+                <SidebarItemTooltip tooltip="Search">
+                  {(triggerRef) => (
+                    <SidebarAvatar
+                      as="button"
+                      ref={triggerRef}
+                      outlined
+                      onClick={() => openSearch()}
+                    >
+                      <Icon src={Icons.Search} />
+                    </SidebarAvatar>
+                  )}
+                </SidebarItemTooltip>
+              </SidebarItem>
+
+              <InboxTab />
+              <UserTab />
+            </SidebarStack>
+          </>
+        }
+      />
+    </Sidebar>
+  );
+}
diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx
new file mode 100644 (file)
index 0000000..952885c
--- /dev/null
@@ -0,0 +1,46 @@
+import React, { ReactNode } from 'react';
+import { Box, Dialog, config, Text, Button, Spinner } from 'folds';
+import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
+import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
+import { SplashScreen } from '../../components/splash-screen';
+
+export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
+  return (
+    <SpecVersionsLoader
+      baseUrl={baseUrl}
+      fallback={() => (
+        <SplashScreen>
+          <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
+            <Spinner variant="Secondary" size="600" />
+            <Text>Connecting to server</Text>
+          </Box>
+        </SplashScreen>
+      )}
+      error={(err, retry, ignore) => (
+        <SplashScreen>
+          <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
+            <Dialog>
+              <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
+                <Text>
+                  Failed to connect to homeserver. Either homeserver is down or your internet.
+                </Text>
+                <Button variant="Critical" onClick={retry}>
+                  <Text as="span" size="B400">
+                    Retry
+                  </Text>
+                </Button>
+                <Button variant="Critical" onClick={ignore} fill="Soft">
+                  <Text as="span" size="B400">
+                    Continue
+                  </Text>
+                </Button>
+              </Box>
+            </Dialog>
+          </Box>
+        </SplashScreen>
+      )}
+    >
+      {(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>}
+    </SpecVersionsLoader>
+  );
+}
diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx
new file mode 100644 (file)
index 0000000..2486625
--- /dev/null
@@ -0,0 +1,64 @@
+import React from 'react';
+import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
+import { Page, PageHero, PageHeroSection } from '../../components/page';
+import CinnySVG from '../../../../public/res/svg/cinny.svg';
+
+export function WelcomePage() {
+  return (
+    <Page>
+      <Box
+        grow="Yes"
+        style={{ padding: config.space.S400, paddingBottom: config.space.S700 }}
+        alignItems="Center"
+        justifyContent="Center"
+      >
+        <PageHeroSection>
+          <PageHero
+            icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
+            title="Welcome to Cinny"
+            subTitle={
+              <span>
+                Yet anothor matrix client.{' '}
+                <a
+                  href="https://github.com/cinnyapp/cinny/releases"
+                  target="_blank"
+                  rel="noreferrer noopener"
+                >
+                  v3.2.0
+                </a>
+              </span>
+            }
+          >
+            <Box justifyContent="Center">
+              <Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
+                <Button
+                  as="a"
+                  href="https://github.com/cinnyapp/cinny"
+                  target="_blank"
+                  rel="noreferrer noopener"
+                  before={<Icon size="200" src={Icons.Code} />}
+                >
+                  <Text as="span" size="B400" truncate>
+                    Source Code
+                  </Text>
+                </Button>
+                <Button
+                  as="a"
+                  href="https://cinny.in/#sponsor"
+                  target="_blank"
+                  rel="noreferrer noopener"
+                  fill="Soft"
+                  before={<Icon size="200" src={Icons.Heart} />}
+                >
+                  <Text as="span" size="B400" truncate>
+                    Support
+                  </Text>
+                </Button>
+              </Box>
+            </Box>
+          </PageHero>
+        </PageHeroSection>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
new file mode 100644 (file)
index 0000000..673a5d9
--- /dev/null
@@ -0,0 +1,263 @@
+import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
+import { useAtom, useAtomValue } from 'jotai';
+import {
+  Avatar,
+  Box,
+  Button,
+  Icon,
+  IconButton,
+  Icons,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { factoryRoomIdByActivity } from '../../../utils/sort';
+import {
+  NavButton,
+  NavCategory,
+  NavCategoryHeader,
+  NavEmptyCenter,
+  NavEmptyLayout,
+  NavItem,
+  NavItemContent,
+} from '../../../components/nav';
+import { getDirectRoomPath } from '../../pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { VirtualTile } from '../../../components/virtualizer';
+import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
+import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
+import { makeNavCategoryId } from '../../../state/closedNavCategories';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { useDirectRooms } from './useDirectRooms';
+import { openInviteUser } from '../../../../client/action/navigation';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { markAsRead } from '../../../../client/action/notifications';
+
+type DirectMenuProps = {
+  requestClose: () => void;
+};
+const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
+  const orphanRooms = useDirectRooms();
+  const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+  const handleMarkAsRead = () => {
+    if (!unread) return;
+    orphanRooms.forEach((rId) => markAsRead(rId));
+    requestClose();
+  };
+
+  return (
+    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleMarkAsRead}
+          size="300"
+          after={<Icon size="100" src={Icons.CheckTwice} />}
+          radii="300"
+          aria-disabled={!unread}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Mark as Read
+          </Text>
+        </MenuItem>
+      </Box>
+    </Menu>
+  );
+});
+
+function DirectHeader() {
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const cords = evt.currentTarget.getBoundingClientRect();
+    setMenuAnchor((currentState) => {
+      if (currentState) return undefined;
+      return cords;
+    });
+  };
+
+  return (
+    <>
+      <PageNavHeader>
+        <Box alignItems="Center" grow="Yes" gap="300">
+          <Box grow="Yes">
+            <Text size="H4" truncate>
+              Direct Messages
+            </Text>
+          </Box>
+          <Box>
+            <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
+              <Icon src={Icons.VerticalDots} size="200" />
+            </IconButton>
+          </Box>
+        </Box>
+      </PageNavHeader>
+      <PopOut
+        anchor={menuAnchor}
+        position="Bottom"
+        align="End"
+        offset={6}
+        content={
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              returnFocusOnDeactivate: false,
+              onDeactivate: () => setMenuAnchor(undefined),
+              clickOutsideDeactivates: true,
+              isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+              isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+            }}
+          >
+            <DirectMenu requestClose={() => setMenuAnchor(undefined)} />
+          </FocusTrap>
+        }
+      />
+    </>
+  );
+}
+
+function DirectEmpty() {
+  return (
+    <NavEmptyCenter>
+      <NavEmptyLayout
+        icon={<Icon size="600" src={Icons.Mention} />}
+        title={
+          <Text size="H5" align="Center">
+            No Direct Messages
+          </Text>
+        }
+        content={
+          <Text size="T300" align="Center">
+            You do not have any direct messages yet.
+          </Text>
+        }
+        options={
+          <Button variant="Secondary" size="300" onClick={() => openInviteUser()}>
+            <Text size="B300" truncate>
+              Direct Message
+            </Text>
+          </Button>
+        }
+      />
+    </NavEmptyCenter>
+  );
+}
+
+const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
+export function Direct() {
+  const mx = useMatrixClient();
+  useNavToActivePathMapper('direct');
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const directs = useDirectRooms();
+  const muteChanges = useAtomValue(muteChangesAtom);
+  const mutedRooms = muteChanges.added;
+  const roomToUnread = useAtomValue(roomToUnreadAtom);
+
+  const selectedRoomId = useSelectedRoom();
+  const noRoomToDisplay = directs.length === 0;
+  const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+  const sortedDirects = useMemo(() => {
+    const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
+    if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
+      return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
+    }
+    return items;
+  }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
+
+  const virtualizer = useVirtualizer({
+    count: sortedDirects.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 38,
+    overscan: 10,
+  });
+
+  const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+    closedCategories.has(categoryId)
+  );
+
+  return (
+    <PageNav>
+      <DirectHeader />
+      {noRoomToDisplay ? (
+        <DirectEmpty />
+      ) : (
+        <PageNavContent scrollRef={scrollRef}>
+          <Box direction="Column" gap="300">
+            <NavCategory>
+              <NavItem variant="Background" radii="400">
+                <NavButton onClick={() => openInviteUser()}>
+                  <NavItemContent>
+                    <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                      <Avatar size="200" radii="400">
+                        <Icon src={Icons.Plus} size="100" />
+                      </Avatar>
+                      <Box as="span" grow="Yes">
+                        <Text as="span" size="Inherit" truncate>
+                          Create Chat
+                        </Text>
+                      </Box>
+                    </Box>
+                  </NavItemContent>
+                </NavButton>
+              </NavItem>
+            </NavCategory>
+            <NavCategory>
+              <NavCategoryHeader>
+                <RoomNavCategoryButton
+                  closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
+                  data-category-id={DEFAULT_CATEGORY_ID}
+                  onClick={handleCategoryClick}
+                >
+                  Chats
+                </RoomNavCategoryButton>
+              </NavCategoryHeader>
+              <div
+                style={{
+                  position: 'relative',
+                  height: virtualizer.getTotalSize(),
+                }}
+              >
+                {virtualizer.getVirtualItems().map((vItem) => {
+                  const roomId = sortedDirects[vItem.index];
+                  const room = mx.getRoom(roomId);
+                  if (!room) return null;
+                  const selected = selectedRoomId === roomId;
+
+                  return (
+                    <VirtualTile
+                      virtualItem={vItem}
+                      key={vItem.index}
+                      ref={virtualizer.measureElement}
+                    >
+                      <RoomNavItem
+                        room={room}
+                        selected={selected}
+                        showAvatar
+                        direct
+                        linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
+                        muted={mutedRooms.includes(roomId)}
+                      />
+                    </VirtualTile>
+                  );
+                })}
+              </div>
+            </NavCategory>
+          </Box>
+        </PageNavContent>
+      )}
+    </PageNav>
+  );
+}
diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx
new file mode 100644 (file)
index 0000000..c78a8f4
--- /dev/null
@@ -0,0 +1,26 @@
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { RoomProvider } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+import { useDirectRooms } from './useDirectRooms';
+
+export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
+  const mx = useMatrixClient();
+  const rooms = useDirectRooms();
+
+  const { roomIdOrAlias } = useParams();
+  const roomId = useSelectedRoom();
+  const room = mx.getRoom(roomId);
+
+  if (!room || !rooms.includes(room.roomId)) {
+    return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+  }
+
+  return (
+    <RoomProvider key={room.roomId} value={room}>
+      {children}
+    </RoomProvider>
+  );
+}
diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts
new file mode 100644 (file)
index 0000000..36f44d6
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './Direct';
+export * from './RoomProvider';
diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts
new file mode 100644 (file)
index 0000000..0000992
--- /dev/null
@@ -0,0 +1,12 @@
+import { useAtomValue } from 'jotai';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useDirects } from '../../../state/hooks/roomList';
+
+export const useDirectRooms = () => {
+  const mx = useMatrixClient();
+  const mDirects = useAtomValue(mDirectAtom);
+  const directs = useDirects(mx, allRoomsAtom, mDirects);
+  return directs;
+};
diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx
new file mode 100644 (file)
index 0000000..67f8dc3
--- /dev/null
@@ -0,0 +1,269 @@
+import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import FocusTrap from 'focus-trap-react';
+import {
+  Avatar,
+  Box,
+  Button,
+  Dialog,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Text,
+  color,
+  config,
+} from 'folds';
+import {
+  NavCategory,
+  NavCategoryHeader,
+  NavItem,
+  NavItemContent,
+  NavLink,
+} from '../../../components/nav';
+import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import {
+  useExploreFeaturedSelected,
+  useExploreServer,
+} from '../../../hooks/router/useExploreSelected';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdServer } from '../../../utils/matrix';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+
+export function AddServer() {
+  const mx = useMatrixClient();
+  const navigate = useNavigate();
+  const [dialog, setDialog] = useState(false);
+  const serverInputRef = useRef<HTMLInputElement>(null);
+
+  const [exploreState] = useAsyncCallback(
+    useCallback((server: string) => mx.publicRooms({ server, limit: 1 }), [mx])
+  );
+
+  const getInputServer = (): string | undefined => {
+    const serverInput = serverInputRef.current;
+    if (!serverInput) return undefined;
+    const server = serverInput.value.trim();
+    return server || undefined;
+  };
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const server = getInputServer();
+    if (!server) return;
+    // explore(server);
+
+    navigate(getExploreServerPath(server));
+    setDialog(false);
+  };
+
+  const handleView = () => {
+    const server = getInputServer();
+    if (!server) return;
+    navigate(getExploreServerPath(server));
+    setDialog(false);
+  };
+
+  return (
+    <>
+      <Overlay open={dialog} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              clickOutsideDeactivates: true,
+              onDeactivate: () => setDialog(false),
+            }}
+          >
+            <Dialog variant="Surface">
+              <Header
+                style={{
+                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                  borderBottomWidth: config.borderWidth.B300,
+                }}
+                variant="Surface"
+                size="500"
+              >
+                <Box grow="Yes">
+                  <Text size="H4">Add Server</Text>
+                </Box>
+                <IconButton size="300" onClick={() => setDialog(false)} radii="300">
+                  <Icon src={Icons.Cross} />
+                </IconButton>
+              </Header>
+              <Box
+                as="form"
+                onSubmit={handleSubmit}
+                style={{ padding: config.space.S400 }}
+                direction="Column"
+                gap="400"
+              >
+                <Text priority="400">Add server name to explore public communities.</Text>
+                <Box direction="Column" gap="100">
+                  <Text size="L400">Server Name</Text>
+                  <Input ref={serverInputRef} name="serverInput" variant="Background" required />
+                  {exploreState.status === AsyncStatus.Error && (
+                    <Text style={{ color: color.Critical.Main }} size="T300">
+                      Failed to load public rooms. Please try again.
+                    </Text>
+                  )}
+                </Box>
+                <Box direction="Column" gap="200">
+                  {/* <Button
+                    type="submit"
+                    variant="Secondary"
+                    before={
+                      exploreState.status === AsyncStatus.Loading ? (
+                        <Spinner fill="Solid" variant="Secondary" size="200" />
+                      ) : undefined
+                    }
+                    aria-disabled={exploreState.status === AsyncStatus.Loading}
+                  >
+                    <Text size="B400">Save</Text>
+                  </Button> */}
+
+                  <Button type="submit" onClick={handleView} variant="Secondary" fill="Soft">
+                    <Text size="B400">View</Text>
+                  </Button>
+                </Box>
+              </Box>
+            </Dialog>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <Button
+        variant="Secondary"
+        fill="Soft"
+        size="300"
+        before={<Icon size="100" src={Icons.Plus} />}
+        onClick={() => setDialog(true)}
+      >
+        <Text size="B300" truncate>
+          Add Server
+        </Text>
+      </Button>
+    </>
+  );
+}
+
+export function Explore() {
+  const mx = useMatrixClient();
+  useNavToActivePathMapper('explore');
+  const userId = mx.getUserId();
+  const clientConfig = useClientConfig();
+  const userServer = userId ? getMxIdServer(userId) : undefined;
+  const servers =
+    clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [];
+
+  const featuredSelected = useExploreFeaturedSelected();
+  const selectedServer = useExploreServer();
+
+  return (
+    <PageNav>
+      <PageNavHeader>
+        <Box grow="Yes" gap="300">
+          <Box grow="Yes">
+            <Text size="H4" truncate>
+              Explore Community
+            </Text>
+          </Box>
+        </Box>
+      </PageNavHeader>
+
+      <PageNavContent>
+        <Box direction="Column" gap="300">
+          <NavCategory>
+            <NavItem variant="Background" radii="400" aria-selected={featuredSelected}>
+              <NavLink to={getExploreFeaturedPath()}>
+                <NavItemContent>
+                  <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                    <Avatar size="200" radii="400">
+                      <Icon src={Icons.Bulb} size="100" filled={featuredSelected} />
+                    </Avatar>
+                    <Box as="span" grow="Yes">
+                      <Text as="span" size="Inherit" truncate>
+                        Featured
+                      </Text>
+                    </Box>
+                  </Box>
+                </NavItemContent>
+              </NavLink>
+            </NavItem>
+            {userServer && (
+              <NavItem
+                variant="Background"
+                radii="400"
+                aria-selected={selectedServer === userServer}
+              >
+                <NavLink to={getExploreServerPath(userServer)}>
+                  <NavItemContent>
+                    <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                      <Avatar size="200" radii="400">
+                        <Icon
+                          src={Icons.Category}
+                          size="100"
+                          filled={selectedServer === userServer}
+                        />
+                      </Avatar>
+                      <Box as="span" grow="Yes">
+                        <Text as="span" size="Inherit" truncate>
+                          {userServer}
+                        </Text>
+                      </Box>
+                    </Box>
+                  </NavItemContent>
+                </NavLink>
+              </NavItem>
+            )}
+          </NavCategory>
+          {servers.length > 0 && (
+            <NavCategory>
+              <NavCategoryHeader>
+                <Text size="O400" style={{ paddingLeft: config.space.S200 }}>
+                  Servers
+                </Text>
+              </NavCategoryHeader>
+              {servers.map((server) => (
+                <NavItem
+                  key={server}
+                  variant="Background"
+                  radii="400"
+                  aria-selected={server === selectedServer}
+                >
+                  <NavLink to={getExploreServerPath(server)}>
+                    <NavItemContent>
+                      <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                        <Avatar size="200" radii="400">
+                          <Icon
+                            src={Icons.Category}
+                            size="100"
+                            filled={server === selectedServer}
+                          />
+                        </Avatar>
+                        <Box as="span" grow="Yes">
+                          <Text as="span" size="Inherit" truncate>
+                            {server}
+                          </Text>
+                        </Box>
+                      </Box>
+                    </NavItemContent>
+                  </NavLink>
+                </NavItem>
+              ))}
+            </NavCategory>
+          )}
+          <Box direction="Column">
+            <AddServer />
+          </Box>
+        </Box>
+      </PageNavContent>
+    </PageNav>
+  );
+}
diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx
new file mode 100644 (file)
index 0000000..4838127
--- /dev/null
@@ -0,0 +1,121 @@
+import React from 'react';
+import { Box, Icon, Icons, Scroll, Text } from 'folds';
+import { useAtomValue } from 'jotai';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { RoomCard, RoomCardGrid } from '../../../components/room-card';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { RoomSummaryLoader } from '../../../components/RoomSummaryLoader';
+import {
+  Page,
+  PageContent,
+  PageContentCenter,
+  PageHero,
+  PageHeroSection,
+} from '../../../components/page';
+import { RoomTopicViewer } from '../../../components/room-topic-viewer';
+import * as css from './style.css';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+
+export function FeaturedRooms() {
+  const { featuredCommunities } = useClientConfig();
+  const { rooms, spaces } = featuredCommunities ?? {};
+  const allRooms = useAtomValue(allRoomsAtom);
+  const { navigateSpace, navigateRoom } = useRoomNavigate();
+
+  return (
+    <Page>
+      <Box grow="Yes">
+        <Scroll hideTrack visibility="Hover">
+          <PageContent>
+            <PageContentCenter>
+              <Box direction="Column" gap="200">
+                <PageHeroSection>
+                  <PageHero
+                    icon={<Icon size="600" src={Icons.Bulb} />}
+                    title="Featured by Client"
+                    subTitle="Find and explore public rooms and spaces featured by client provider."
+                  />
+                </PageHeroSection>
+                <Box direction="Column" gap="700">
+                  {spaces && spaces.length > 0 && (
+                    <Box direction="Column" gap="400">
+                      <Text size="H4">Featured Spaces</Text>
+                      <RoomCardGrid>
+                        {spaces.map((roomIdOrAlias) => (
+                          <RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
+                            {(roomSummary) => (
+                              <RoomCard
+                                roomIdOrAlias={roomIdOrAlias}
+                                allRooms={allRooms}
+                                avatarUrl={roomSummary?.avatar_url}
+                                name={roomSummary?.name}
+                                topic={roomSummary?.topic}
+                                memberCount={roomSummary?.num_joined_members}
+                                onView={navigateSpace}
+                                renderTopicViewer={(name, topic, requestClose) => (
+                                  <RoomTopicViewer
+                                    name={name}
+                                    topic={topic}
+                                    requestClose={requestClose}
+                                  />
+                                )}
+                              />
+                            )}
+                          </RoomSummaryLoader>
+                        ))}
+                      </RoomCardGrid>
+                    </Box>
+                  )}
+                  {rooms && rooms.length > 0 && (
+                    <Box direction="Column" gap="400">
+                      <Text size="H4">Featured Rooms</Text>
+                      <RoomCardGrid>
+                        {rooms.map((roomIdOrAlias) => (
+                          <RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
+                            {(roomSummary) => (
+                              <RoomCard
+                                roomIdOrAlias={roomIdOrAlias}
+                                allRooms={allRooms}
+                                avatarUrl={roomSummary?.avatar_url}
+                                name={roomSummary?.name}
+                                topic={roomSummary?.topic}
+                                memberCount={roomSummary?.num_joined_members}
+                                onView={navigateRoom}
+                                renderTopicViewer={(name, topic, requestClose) => (
+                                  <RoomTopicViewer
+                                    name={name}
+                                    topic={topic}
+                                    requestClose={requestClose}
+                                  />
+                                )}
+                              />
+                            )}
+                          </RoomSummaryLoader>
+                        ))}
+                      </RoomCardGrid>
+                    </Box>
+                  )}
+                  {((spaces && spaces.length === 0 && rooms && rooms.length === 0) ||
+                    (!spaces && !rooms)) && (
+                    <Box
+                      className={css.RoomsInfoCard}
+                      direction="Column"
+                      justifyContent="Center"
+                      alignItems="Center"
+                      gap="200"
+                    >
+                      <Icon size="400" src={Icons.Info} />
+                      <Text size="T300" align="Center">
+                        No rooms or spaces featured by client provider.
+                      </Text>
+                    </Box>
+                  )}
+                </Box>
+              </Box>
+            </PageContentCenter>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx
new file mode 100644 (file)
index 0000000..9fe4e78
--- /dev/null
@@ -0,0 +1,645 @@
+import React, {
+  FormEventHandler,
+  MouseEventHandler,
+  RefObject,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Box,
+  Button,
+  Chip,
+  Icon,
+  Icons,
+  Input,
+  Line,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Scroll,
+  Spinner,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import FocusTrap from 'focus-trap-react';
+import { useAtomValue } from 'jotai';
+import { useQuery } from '@tanstack/react-query';
+import { MatrixClient, Method, RoomType } from 'matrix-js-sdk';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { RoomTopicViewer } from '../../../components/room-topic-viewer';
+import { RoomCard, RoomCardBase, RoomCardGrid } from '../../../components/room-card';
+import { ExploreServerPathSearchParams } from '../../paths';
+import { getExploreServerPath, withSearchParam } from '../../pathUtils';
+import * as css from './style.css';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { getMxIdServer } from '../../../utils/matrix';
+
+const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
+  useMemo(
+    () => ({
+      limit: searchParams.get('limit') ?? undefined,
+      since: searchParams.get('since') ?? undefined,
+      term: searchParams.get('term') ?? undefined,
+      type: searchParams.get('type') ?? undefined,
+      instance: searchParams.get('instance') ?? undefined,
+    }),
+    [searchParams]
+  );
+
+type RoomTypeFilter = {
+  title: string;
+  value: string | undefined;
+};
+const useRoomTypeFilters = (): RoomTypeFilter[] =>
+  useMemo(
+    () => [
+      {
+        title: 'All',
+        value: undefined,
+      },
+      {
+        title: 'Spaces',
+        value: RoomType.Space,
+      },
+      {
+        title: 'Rooms',
+        value: 'null',
+      },
+    ],
+    []
+  );
+
+const FALLBACK_ROOMS_LIMIT = 24;
+
+type SearchProps = {
+  active?: boolean;
+  loading?: boolean;
+  searchInputRef: RefObject<HTMLInputElement>;
+  onSearch: (term: string) => void;
+  onReset: () => void;
+};
+function Search({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
+  const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const { searchInput } = evt.target as HTMLFormElement & {
+      searchInput: HTMLInputElement;
+    };
+
+    const searchTerm = searchInput.value.trim() || undefined;
+    if (searchTerm) {
+      onSearch(searchTerm);
+    }
+  };
+
+  return (
+    <Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
+      <span data-spacing-node />
+      <Text size="L400">Search</Text>
+      <Input
+        ref={searchInputRef}
+        style={{ paddingRight: config.space.S300 }}
+        name="searchInput"
+        size="500"
+        variant="Background"
+        placeholder="Search for keyword"
+        before={
+          active && loading ? (
+            <Spinner variant="Secondary" size="200" />
+          ) : (
+            <Icon size="200" src={Icons.Search} />
+          )
+        }
+        after={
+          active ? (
+            <Chip
+              type="button"
+              variant="Secondary"
+              size="400"
+              radii="Pill"
+              outlined
+              after={<Icon size="50" src={Icons.Cross} />}
+              onClick={onReset}
+            >
+              <Text size="B300">Clear</Text>
+            </Chip>
+          ) : (
+            <Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
+              <Text size="B300">Enter</Text>
+            </Chip>
+          )
+        }
+      />
+    </Box>
+  );
+}
+
+const DEFAULT_INSTANCE_NAME = 'Matrix';
+function ThirdPartyProtocolsSelector({
+  instanceId,
+  onChange,
+}: {
+  instanceId?: string;
+  onChange: (instanceId?: string) => void;
+}) {
+  const mx = useMatrixClient();
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const { data } = useQuery({
+    queryKey: ['thirdparty', 'protocols'],
+    queryFn: () => mx.getThirdpartyProtocols(),
+  });
+
+  const handleInstanceSelect: MouseEventHandler<HTMLButtonElement> = (evt): void => {
+    const insId = evt.currentTarget.getAttribute('data-instance-id') ?? undefined;
+    onChange(insId);
+    setMenuAnchor(undefined);
+  };
+
+  const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const instances = data && Object.keys(data).flatMap((protocol) => data[protocol].instances);
+  if (!instances || instances.length === 0) return null;
+  const selectedInstance = instances.find((instance) => instanceId === instance.instance_id);
+
+  return (
+    <PopOut
+      anchor={menuAnchor}
+      align="End"
+      position="Bottom"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setMenuAnchor(undefined),
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Menu variant="Surface">
+            <Box
+              direction="Column"
+              gap="100"
+              style={{ padding: config.space.S100, minWidth: toRem(100) }}
+            >
+              <Text style={{ padding: config.space.S100 }} size="L400" truncate>
+                Protocols
+              </Text>
+              <Box direction="Column">
+                <MenuItem
+                  size="300"
+                  variant="Surface"
+                  aria-pressed={instanceId === undefined}
+                  radii="300"
+                  onClick={handleInstanceSelect}
+                >
+                  <Text size="T200" truncate>
+                    {DEFAULT_INSTANCE_NAME}
+                  </Text>
+                </MenuItem>
+                {instances.map((instance) => (
+                  <MenuItem
+                    size="300"
+                    key={instance.instance_id}
+                    data-instance-id={instance.instance_id}
+                    aria-pressed={instanceId === instance.instance_id}
+                    variant="Surface"
+                    radii="300"
+                    onClick={handleInstanceSelect}
+                  >
+                    <Text size="T200" truncate>
+                      {instance.desc}
+                    </Text>
+                  </MenuItem>
+                ))}
+              </Box>
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        onClick={handleOpenMenu}
+        aria-pressed={!!menuAnchor}
+        radii="Pill"
+        size="400"
+        variant={instanceId ? 'Success' : 'SurfaceVariant'}
+        after={<Icon size="100" src={Icons.ChevronBottom} />}
+      >
+        <Text size="T200" truncate>
+          {selectedInstance?.desc ?? DEFAULT_INSTANCE_NAME}
+        </Text>
+      </Chip>
+    </PopOut>
+  );
+}
+
+type LimitButtonProps = {
+  limit: number;
+  onLimitChange: (limit: string) => void;
+};
+function LimitButton({ limit, onLimitChange }: LimitButtonProps) {
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleLimitSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const limitInput = evt.currentTarget.limitInput as HTMLInputElement;
+    if (!limitInput) return;
+    const newLimit = limitInput.value.trim();
+    if (!newLimit) return;
+    onLimitChange(newLimit);
+  };
+
+  const setLimit = (l: string) => {
+    setMenuAnchor(undefined);
+    onLimitChange(l);
+  };
+  const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
+    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
+  return (
+    <PopOut
+      anchor={menuAnchor}
+      align="End"
+      position="Bottom"
+      content={
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            onDeactivate: () => setMenuAnchor(undefined),
+            clickOutsideDeactivates: true,
+          }}
+        >
+          <Menu variant="Surface">
+            <Box direction="Column" gap="400" style={{ padding: config.space.S300 }}>
+              <Box direction="Column" gap="100">
+                <Text size="L400">Presets</Text>
+                <Box gap="100" wrap="Wrap">
+                  <Chip variant="SurfaceVariant" onClick={() => setLimit('24')} radii="Pill">
+                    <Text size="T200">24</Text>
+                  </Chip>
+                  <Chip variant="SurfaceVariant" onClick={() => setLimit('48')} radii="Pill">
+                    <Text size="T200">48</Text>
+                  </Chip>
+                  <Chip variant="SurfaceVariant" onClick={() => setLimit('96')} radii="Pill">
+                    <Text size="T200">96</Text>
+                  </Chip>
+                </Box>
+              </Box>
+              <Box as="form" onSubmit={handleLimitSubmit} direction="Column" gap="300">
+                <Box direction="Column" gap="100">
+                  <Text size="L400">Custom Limit</Text>
+                  <Input
+                    name="limitInput"
+                    size="300"
+                    variant="Background"
+                    defaultValue={limit}
+                    min={1}
+                    step={1}
+                    outlined
+                    type="number"
+                    radii="400"
+                    aria-label="Per Page Item Limit"
+                  />
+                </Box>
+                <Button type="submit" size="300" variant="Primary" radii="400">
+                  <Text size="B300">Change Limit</Text>
+                </Button>
+              </Box>
+            </Box>
+          </Menu>
+        </FocusTrap>
+      }
+    >
+      <Chip
+        onClick={handleOpenMenu}
+        aria-pressed={!!menuAnchor}
+        radii="Pill"
+        size="400"
+        variant="SurfaceVariant"
+        after={<Icon size="100" src={Icons.ChevronBottom} />}
+      >
+        <Text size="T200" truncate>{`Page Limit: ${limit}`}</Text>
+      </Chip>
+    </PopOut>
+  );
+}
+
+export function PublicRooms() {
+  const { server } = useParams();
+  const mx = useMatrixClient();
+  const userId = mx.getUserId();
+  const userServer = userId && getMxIdServer(userId);
+  const allRooms = useAtomValue(allRoomsAtom);
+  const { navigateSpace, navigateRoom } = useRoomNavigate();
+
+  const [searchParams] = useSearchParams();
+  const serverSearchParams = useServerSearchParams(searchParams);
+  const isSearch = !!serverSearchParams.term;
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const navigate = useNavigate();
+  const roomTypeFilters = useRoomTypeFilters();
+
+  const currentLimit: number = useMemo(() => {
+    const limitParam = serverSearchParams.limit;
+    if (!limitParam) return FALLBACK_ROOMS_LIMIT;
+    return parseInt(limitParam, 10) || FALLBACK_ROOMS_LIMIT;
+  }, [serverSearchParams.limit]);
+
+  const resetScroll = useCallback(() => {
+    const scroll = scrollRef.current;
+    if (scroll) scroll.scrollTop = 0;
+  }, []);
+
+  const fetchPublicRooms = useCallback(() => {
+    const limit =
+      typeof serverSearchParams.limit === 'string'
+        ? parseInt(serverSearchParams.limit, 10)
+        : FALLBACK_ROOMS_LIMIT;
+    const roomType: string | null | undefined =
+      serverSearchParams.type === 'null' ? null : serverSearchParams.type;
+
+    return mx.http.authedRequest<Awaited<ReturnType<MatrixClient['publicRooms']>>>(
+      Method.Post,
+      '/publicRooms',
+      {
+        server,
+      },
+      {
+        limit,
+        since: serverSearchParams.since,
+        filter: {
+          generic_search_term: serverSearchParams.term,
+          room_types: roomType !== undefined ? [roomType] : undefined,
+        },
+        third_party_instance_id: serverSearchParams.instance,
+      }
+    );
+  }, [mx, server, serverSearchParams]);
+
+  const { data, isLoading, error } = useQuery({
+    queryKey: [
+      server,
+      'publicRooms',
+      serverSearchParams.limit,
+      serverSearchParams.since,
+      serverSearchParams.term,
+      serverSearchParams.type,
+      serverSearchParams.instance,
+    ],
+    queryFn: fetchPublicRooms,
+  });
+
+  useEffect(() => {
+    if (isLoading) resetScroll();
+  }, [isLoading, resetScroll]);
+
+  const explore = (newSearchParams: ExploreServerPathSearchParams) => {
+    if (!server) return;
+
+    const sParams: Record<string, string> = {
+      ...serverSearchParams,
+      ...newSearchParams,
+    };
+    Object.keys(sParams).forEach((key) => {
+      if (sParams[key] === undefined) delete sParams[key];
+    });
+    const path = withSearchParam(getExploreServerPath(server), sParams);
+    navigate(path);
+  };
+
+  const paginateBack = () => {
+    const token = data?.prev_batch;
+    explore({ since: token });
+  };
+
+  const paginateFront = () => {
+    const token = data?.next_batch;
+    explore({ since: token });
+  };
+
+  const handleSearch = (term: string) => {
+    explore({
+      term,
+      since: undefined,
+    });
+  };
+
+  const handleSearchClear = () => {
+    if (searchInputRef.current) {
+      searchInputRef.current.value = '';
+    }
+    explore({
+      term: undefined,
+      since: undefined,
+    });
+  };
+
+  const handleRoomFilterClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const filter = evt.currentTarget.getAttribute('data-room-filter');
+    explore({
+      type: filter ?? undefined,
+      since: undefined,
+    });
+  };
+
+  const handleLimitChange = (limit: string) => {
+    explore({ limit });
+  };
+
+  const handleInstanceIdChange = (instanceId?: string) => {
+    explore({ instance: instanceId, since: undefined });
+  };
+
+  return (
+    <Page>
+      <PageHeader>
+        {isSearch ? (
+          <>
+            <Box grow="Yes" basis="No">
+              <Chip
+                size="500"
+                variant="Surface"
+                radii="Pill"
+                before={<Icon size="100" src={Icons.ArrowLeft} />}
+                onClick={handleSearchClear}
+              >
+                <Text size="T300">{server}</Text>
+              </Chip>
+            </Box>
+
+            <Box grow="No" justifyContent="Center" alignItems="Center" gap="200">
+              <Icon size="400" src={Icons.Search} />
+              <Text size="H3" truncate>
+                Search
+              </Text>
+            </Box>
+            <Box grow="Yes" />
+          </>
+        ) : (
+          <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+            <Icon size="400" src={Icons.Category} />
+            <Text size="H3" truncate>
+              {server}
+            </Text>
+          </Box>
+        )}
+      </PageHeader>
+      <Box grow="Yes">
+        <Scroll ref={scrollRef} hideTrack visibility="Hover">
+          <PageContent>
+            <PageContentCenter>
+              <Box direction="Column" gap="600">
+                <Search
+                  key={server}
+                  active={isSearch}
+                  loading={isLoading}
+                  searchInputRef={searchInputRef}
+                  onSearch={handleSearch}
+                  onReset={handleSearchClear}
+                />
+                <Box direction="Column" gap="400">
+                  <Box direction="Column" gap="300">
+                    {isSearch ? (
+                      <Text size="H4">{`Results for "${serverSearchParams.term}"`}</Text>
+                    ) : (
+                      <Text size="H4">Popular Communities</Text>
+                    )}
+                    <Box gap="200">
+                      {roomTypeFilters.map((filter) => (
+                        <Chip
+                          key={filter.title}
+                          onClick={handleRoomFilterClick}
+                          data-room-filter={filter.value}
+                          variant={filter.value === serverSearchParams.type ? 'Success' : 'Surface'}
+                          aria-pressed={filter.value === serverSearchParams.type}
+                          before={
+                            filter.value === serverSearchParams.type && (
+                              <Icon size="100" src={Icons.Check} />
+                            )
+                          }
+                          outlined
+                        >
+                          <Text size="T200">{filter.title}</Text>
+                        </Chip>
+                      ))}
+                      {userServer === server && (
+                        <>
+                          <Line
+                            style={{ margin: `${config.space.S100} 0` }}
+                            direction="Vertical"
+                            variant="Surface"
+                            size="300"
+                          />
+                          <ThirdPartyProtocolsSelector
+                            instanceId={serverSearchParams.instance}
+                            onChange={handleInstanceIdChange}
+                          />
+                        </>
+                      )}
+                      <Box grow="Yes" data-spacing-node />
+                      <LimitButton limit={currentLimit} onLimitChange={handleLimitChange} />
+                    </Box>
+                  </Box>
+                  {isLoading && (
+                    <RoomCardGrid>
+                      {[...Array(currentLimit).keys()].map((item) => (
+                        <RoomCardBase key={item} style={{ minHeight: toRem(260) }} />
+                      ))}
+                    </RoomCardGrid>
+                  )}
+                  {error && (
+                    <Box direction="Column" className={css.PublicRoomsError} gap="200">
+                      <Text size="L400">{error.name}</Text>
+                      <Text size="T300">{error.message}</Text>
+                    </Box>
+                  )}
+                  {data &&
+                    (data.chunk.length > 0 ? (
+                      <>
+                        <RoomCardGrid>
+                          {data?.chunk.map((chunkRoom) => (
+                            <RoomCard
+                              key={chunkRoom.room_id}
+                              roomIdOrAlias={chunkRoom.canonical_alias ?? chunkRoom.room_id}
+                              allRooms={allRooms}
+                              avatarUrl={chunkRoom.avatar_url}
+                              name={chunkRoom.name}
+                              topic={chunkRoom.topic}
+                              memberCount={chunkRoom.num_joined_members}
+                              roomType={chunkRoom.room_type}
+                              onView={
+                                chunkRoom.room_type === RoomType.Space
+                                  ? navigateSpace
+                                  : navigateRoom
+                              }
+                              renderTopicViewer={(name, topic, requestClose) => (
+                                <RoomTopicViewer
+                                  name={name}
+                                  topic={topic}
+                                  requestClose={requestClose}
+                                />
+                              )}
+                            />
+                          ))}
+                        </RoomCardGrid>
+
+                        {(data.prev_batch || data.next_batch) && (
+                          <Box justifyContent="Center" gap="200">
+                            <Button
+                              onClick={paginateBack}
+                              size="300"
+                              fill="Soft"
+                              disabled={!data.prev_batch}
+                            >
+                              <Text size="B300" truncate>
+                                Previous Page
+                              </Text>
+                            </Button>
+                            <Box data-spacing-node grow="Yes" />
+                            <Button
+                              onClick={paginateFront}
+                              size="300"
+                              fill="Solid"
+                              disabled={!data.next_batch}
+                            >
+                              <Text size="B300" truncate>
+                                Next Page
+                              </Text>
+                            </Button>
+                          </Box>
+                        )}
+                      </>
+                    ) : (
+                      <Box
+                        className={css.RoomsInfoCard}
+                        direction="Column"
+                        justifyContent="Center"
+                        alignItems="Center"
+                        gap="200"
+                      >
+                        <Icon size="400" src={Icons.Info} />
+                        <Text size="T300" align="Center">
+                          No communities found!
+                        </Text>
+                      </Box>
+                    ))}
+                </Box>
+              </Box>
+            </PageContentCenter>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/explore/index.ts b/src/app/pages/client/explore/index.ts
new file mode 100644 (file)
index 0000000..1149a10
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './Explore';
+export * from './Server';
+export * from './Featured';
diff --git a/src/app/pages/client/explore/style.css.ts b/src/app/pages/client/explore/style.css.ts
new file mode 100644 (file)
index 0000000..9186cb2
--- /dev/null
@@ -0,0 +1,19 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+import { ContainerColor } from '../../../styles/ContainerColor.css';
+
+export const RoomsInfoCard = style([
+  ContainerColor({ variant: 'SurfaceVariant' }),
+  {
+    padding: `${config.space.S700} ${config.space.S300}`,
+    borderRadius: config.radii.R400,
+  },
+]);
+
+export const PublicRoomsError = style([
+  ContainerColor({ variant: 'Critical' }),
+  {
+    padding: config.space.S300,
+    borderRadius: config.radii.R400,
+  },
+]);
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
new file mode 100644 (file)
index 0000000..3371419
--- /dev/null
@@ -0,0 +1,315 @@
+import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+  Avatar,
+  Box,
+  Button,
+  Icon,
+  IconButton,
+  Icons,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useAtom, useAtomValue } from 'jotai';
+import FocusTrap from 'focus-trap-react';
+import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
+import {
+  NavButton,
+  NavCategory,
+  NavCategoryHeader,
+  NavEmptyCenter,
+  NavEmptyLayout,
+  NavItem,
+  NavItemContent,
+  NavLink,
+} from '../../../components/nav';
+import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected';
+import { useHomeRooms } from './useHomeRooms';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { VirtualTile } from '../../../components/virtualizer';
+import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
+import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
+import { makeNavCategoryId } from '../../../state/closedNavCategories';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation';
+import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { markAsRead } from '../../../../client/action/notifications';
+import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
+
+type HomeMenuProps = {
+  requestClose: () => void;
+};
+const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
+  const orphanRooms = useHomeRooms();
+  const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+  const handleMarkAsRead = () => {
+    if (!unread) return;
+    orphanRooms.forEach((rId) => markAsRead(rId));
+    requestClose();
+  };
+
+  return (
+    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleMarkAsRead}
+          size="300"
+          after={<Icon size="100" src={Icons.CheckTwice} />}
+          radii="300"
+          aria-disabled={!unread}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Mark as Read
+          </Text>
+        </MenuItem>
+      </Box>
+    </Menu>
+  );
+});
+
+function HomeHeader() {
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const cords = evt.currentTarget.getBoundingClientRect();
+    setMenuAnchor((currentState) => {
+      if (currentState) return undefined;
+      return cords;
+    });
+  };
+
+  return (
+    <>
+      <PageNavHeader>
+        <Box alignItems="Center" grow="Yes" gap="300">
+          <Box grow="Yes">
+            <Text size="H4" truncate>
+              Home
+            </Text>
+          </Box>
+          <Box>
+            <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
+              <Icon src={Icons.VerticalDots} size="200" />
+            </IconButton>
+          </Box>
+        </Box>
+      </PageNavHeader>
+      <PopOut
+        anchor={menuAnchor}
+        position="Bottom"
+        align="End"
+        offset={6}
+        content={
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              returnFocusOnDeactivate: false,
+              onDeactivate: () => setMenuAnchor(undefined),
+              clickOutsideDeactivates: true,
+              isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+              isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+            }}
+          >
+            <HomeMenu requestClose={() => setMenuAnchor(undefined)} />
+          </FocusTrap>
+        }
+      />
+    </>
+  );
+}
+
+function HomeEmpty() {
+  const navigate = useNavigate();
+
+  return (
+    <NavEmptyCenter>
+      <NavEmptyLayout
+        icon={<Icon size="600" src={Icons.Hash} />}
+        title={
+          <Text size="H5" align="Center">
+            No Rooms
+          </Text>
+        }
+        content={
+          <Text size="T300" align="Center">
+            You do not have any rooms yet.
+          </Text>
+        }
+        options={
+          <>
+            <Button onClick={() => openCreateRoom()} variant="Secondary" size="300">
+              <Text size="B300" truncate>
+                Create Room
+              </Text>
+            </Button>
+            <Button
+              onClick={() => navigate(getExplorePath())}
+              variant="Secondary"
+              fill="Soft"
+              size="300"
+            >
+              <Text size="B300" truncate>
+                Explore Community Rooms
+              </Text>
+            </Button>
+          </>
+        }
+      />
+    </NavEmptyCenter>
+  );
+}
+
+const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
+export function Home() {
+  const mx = useMatrixClient();
+  useNavToActivePathMapper('home');
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const rooms = useHomeRooms();
+  const muteChanges = useAtomValue(muteChangesAtom);
+  const mutedRooms = muteChanges.added;
+  const roomToUnread = useAtomValue(roomToUnreadAtom);
+
+  const selectedRoomId = useSelectedRoom();
+  const searchSelected = useHomeSearchSelected();
+  const noRoomToDisplay = rooms.length === 0;
+  const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+  const sortedRooms = useMemo(() => {
+    const items = Array.from(rooms).sort(
+      closedCategories.has(DEFAULT_CATEGORY_ID)
+        ? factoryRoomIdByActivity(mx)
+        : factoryRoomIdByAtoZ(mx)
+    );
+    if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
+      return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
+    }
+    return items;
+  }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
+
+  const virtualizer = useVirtualizer({
+    count: sortedRooms.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 38,
+    overscan: 10,
+  });
+
+  const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+    closedCategories.has(categoryId)
+  );
+
+  return (
+    <PageNav>
+      <HomeHeader />
+      {noRoomToDisplay ? (
+        <HomeEmpty />
+      ) : (
+        <PageNavContent scrollRef={scrollRef}>
+          <Box direction="Column" gap="300">
+            <NavCategory>
+              <NavItem variant="Background" radii="400">
+                <NavButton onClick={() => openCreateRoom()}>
+                  <NavItemContent>
+                    <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                      <Avatar size="200" radii="400">
+                        <Icon src={Icons.Plus} size="100" />
+                      </Avatar>
+                      <Box as="span" grow="Yes">
+                        <Text as="span" size="Inherit" truncate>
+                          Create Room
+                        </Text>
+                      </Box>
+                    </Box>
+                  </NavItemContent>
+                </NavButton>
+              </NavItem>
+              <NavItem variant="Background" radii="400">
+                <NavButton onClick={() => openJoinAlias()}>
+                  <NavItemContent>
+                    <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                      <Avatar size="200" radii="400">
+                        <Icon src={Icons.Link} size="100" />
+                      </Avatar>
+                      <Box as="span" grow="Yes">
+                        <Text as="span" size="Inherit" truncate>
+                          Join with Address
+                        </Text>
+                      </Box>
+                    </Box>
+                  </NavItemContent>
+                </NavButton>
+              </NavItem>
+              <NavItem variant="Background" radii="400" aria-selected={searchSelected}>
+                <NavLink to={getHomeSearchPath()}>
+                  <NavItemContent>
+                    <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                      <Avatar size="200" radii="400">
+                        <Icon src={Icons.Search} size="100" filled={searchSelected} />
+                      </Avatar>
+                      <Box as="span" grow="Yes">
+                        <Text as="span" size="Inherit" truncate>
+                          Message Search
+                        </Text>
+                      </Box>
+                    </Box>
+                  </NavItemContent>
+                </NavLink>
+              </NavItem>
+            </NavCategory>
+            <NavCategory>
+              <NavCategoryHeader>
+                <RoomNavCategoryButton
+                  closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
+                  data-category-id={DEFAULT_CATEGORY_ID}
+                  onClick={handleCategoryClick}
+                >
+                  Rooms
+                </RoomNavCategoryButton>
+              </NavCategoryHeader>
+              <div
+                style={{
+                  position: 'relative',
+                  height: virtualizer.getTotalSize(),
+                }}
+              >
+                {virtualizer.getVirtualItems().map((vItem) => {
+                  const roomId = sortedRooms[vItem.index];
+                  const room = mx.getRoom(roomId);
+                  if (!room) return null;
+                  const selected = selectedRoomId === roomId;
+
+                  return (
+                    <VirtualTile
+                      virtualItem={vItem}
+                      key={vItem.index}
+                      ref={virtualizer.measureElement}
+                    >
+                      <RoomNavItem
+                        room={room}
+                        selected={selected}
+                        linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
+                        muted={mutedRooms.includes(roomId)}
+                      />
+                    </VirtualTile>
+                  );
+                })}
+              </div>
+            </NavCategory>
+          </Box>
+        </PageNavContent>
+      )}
+    </PageNav>
+  );
+}
diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx
new file mode 100644 (file)
index 0000000..282cee7
--- /dev/null
@@ -0,0 +1,26 @@
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { RoomProvider } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+import { useHomeRooms } from './useHomeRooms';
+
+export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
+  const mx = useMatrixClient();
+  const rooms = useHomeRooms();
+
+  const { roomIdOrAlias } = useParams();
+  const roomId = useSelectedRoom();
+  const room = mx.getRoom(roomId);
+
+  if (!room || !rooms.includes(room.roomId)) {
+    return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+  }
+
+  return (
+    <RoomProvider key={room.roomId} value={room}>
+      {children}
+    </RoomProvider>
+  );
+}
diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx
new file mode 100644 (file)
index 0000000..af7b1eb
--- /dev/null
@@ -0,0 +1,37 @@
+import React, { useRef } from 'react';
+import { Box, Icon, Icons, Text, Scroll } from 'folds';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { MessageSearch } from '../../../features/message-search';
+import { useHomeRooms } from './useHomeRooms';
+
+export function HomeSearch() {
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const rooms = useHomeRooms();
+
+  return (
+    <Page>
+      <PageHeader>
+        <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+          <Icon size="400" src={Icons.Search} />
+          <Text size="H3" truncate>
+            Message Search
+          </Text>
+        </Box>
+      </PageHeader>
+      <Box style={{ position: 'relative' }} grow="Yes">
+        <Scroll ref={scrollRef} hideTrack visibility="Hover">
+          <PageContent>
+            <PageContentCenter>
+              <MessageSearch
+                defaultRoomsFilterName="Home"
+                allowGlobal
+                rooms={rooms}
+                scrollRef={scrollRef}
+              />
+            </PageContentCenter>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts
new file mode 100644 (file)
index 0000000..8076448
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './Home';
+export * from './Search';
+export * from './RoomProvider';
diff --git a/src/app/pages/client/home/useHomeRooms.ts b/src/app/pages/client/home/useHomeRooms.ts
new file mode 100644 (file)
index 0000000..b0181b0
--- /dev/null
@@ -0,0 +1,14 @@
+import { useAtomValue } from 'jotai';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useOrphanRooms } from '../../../state/hooks/roomList';
+
+export const useHomeRooms = () => {
+  const mx = useMatrixClient();
+  const mDirects = useAtomValue(mDirectAtom);
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
+  return rooms;
+};
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
new file mode 100644 (file)
index 0000000..686296b
--- /dev/null
@@ -0,0 +1,87 @@
+import React from 'react';
+import { Avatar, Box, Icon, Icons, Text } from 'folds';
+import { useAtomValue } from 'jotai';
+import { NavCategory, NavItem, NavItemContent, NavLink } from '../../../components/nav';
+import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
+import {
+  useInboxInvitesSelected,
+  useInboxNotificationsSelected,
+} from '../../../hooks/router/useInbox';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { allInvitesAtom } from '../../../state/room-list/inviteList';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+
+function InvitesNavItem() {
+  const invitesSelected = useInboxInvitesSelected();
+  const allInvites = useAtomValue(allInvitesAtom);
+  const inviteCount = allInvites.length;
+
+  return (
+    <NavItem
+      variant="Background"
+      radii="400"
+      highlight={inviteCount > 0}
+      aria-selected={invitesSelected}
+    >
+      <NavLink to={getInboxInvitesPath()}>
+        <NavItemContent>
+          <Box as="span" grow="Yes" alignItems="Center" gap="200">
+            <Avatar size="200" radii="400">
+              <Icon src={Icons.Mail} size="100" filled={invitesSelected} />
+            </Avatar>
+            <Box as="span" grow="Yes">
+              <Text as="span" size="Inherit" truncate>
+                Invitations
+              </Text>
+            </Box>
+            {inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
+          </Box>
+        </NavItemContent>
+      </NavLink>
+    </NavItem>
+  );
+}
+
+export function Inbox() {
+  useNavToActivePathMapper('inbox');
+  const notificationsSelected = useInboxNotificationsSelected();
+
+  return (
+    <PageNav>
+      <PageNavHeader>
+        <Box grow="Yes" gap="300">
+          <Box grow="Yes">
+            <Text size="H4" truncate>
+              Inbox
+            </Text>
+          </Box>
+        </Box>
+      </PageNavHeader>
+
+      <PageNavContent>
+        <Box direction="Column" gap="300">
+          <NavCategory>
+            <NavItem variant="Background" radii="400" aria-selected={notificationsSelected}>
+              <NavLink to={getInboxNotificationsPath()}>
+                <NavItemContent>
+                  <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                    <Avatar size="200" radii="400">
+                      <Icon src={Icons.MessageUnread} size="100" filled={notificationsSelected} />
+                    </Avatar>
+                    <Box as="span" grow="Yes">
+                      <Text as="span" size="Inherit" truncate>
+                        Notifications
+                      </Text>
+                    </Box>
+                  </Box>
+                </NavItemContent>
+              </NavLink>
+            </NavItem>
+            <InvitesNavItem />
+          </NavCategory>
+        </Box>
+      </PageNavContent>
+    </PageNav>
+  );
+}
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx
new file mode 100644 (file)
index 0000000..91dc029
--- /dev/null
@@ -0,0 +1,288 @@
+import React, { useCallback, useRef, useState } from 'react';
+import {
+  Avatar,
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Scroll,
+  Spinner,
+  Text,
+  color,
+  config,
+} from 'folds';
+import { useAtomValue } from 'jotai';
+import FocusTrap from 'focus-trap-react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { allInvitesAtom } from '../../../state/room-list/inviteList';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { SequenceCard } from '../../../components/sequence-card';
+import {
+  getDirectRoomAvatarUrl,
+  getMemberDisplayName,
+  getRoomAvatarUrl,
+  isDirectInvite,
+} from '../../../utils/room';
+import { nameInitials } from '../../../utils/common';
+import { RoomAvatar } from '../../../components/room-avatar';
+import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix';
+import { Time } from '../../../components/message';
+import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
+import { onEnterOrSpace } from '../../../utils/keyboard';
+import { RoomTopicViewer } from '../../../components/room-topic-viewer';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useRoomTopic } from '../../../hooks/useRoomMeta';
+
+const COMPACT_CARD_WIDTH = 548;
+
+type InviteCardProps = {
+  room: Room;
+  userId: string;
+  direct?: boolean;
+  compact?: boolean;
+  onNavigate: (roomId: string) => void;
+};
+function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
+  const mx = useMatrixClient();
+  const roomName = room.name || room.getCanonicalAlias() || room.roomId;
+  const member = room.getMember(userId);
+  const memberEvent = member?.events.member;
+  const memberTs = memberEvent?.getTs() ?? 0;
+  const senderId = memberEvent?.getSender();
+  const senderName = senderId
+    ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
+    : undefined;
+
+  const topic = useRoomTopic(room);
+
+  const [viewTopic, setViewTopic] = useState(false);
+  const closeTopic = () => setViewTopic(false);
+  const openTopic = () => setViewTopic(true);
+
+  const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
+    useCallback(async () => {
+      const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
+
+      await mx.joinRoom(room.roomId);
+      if (dmUserId) {
+        await addRoomIdToMDirect(mx, room.roomId, dmUserId);
+      }
+      onNavigate(room.roomId);
+    }, [mx, room, userId, onNavigate])
+  );
+  const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
+    useCallback(() => mx.leave(room.roomId), [mx, room])
+  );
+
+  const joining =
+    joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
+  const leaving =
+    leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
+
+  return (
+    <SequenceCard
+      variant="SurfaceVariant"
+      direction="Column"
+      gap="200"
+      style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
+    >
+      <Box gap="200" alignItems="Baseline">
+        <Box grow="Yes">
+          <Text size="T200" priority="300" truncate>
+            Invited by <b>{senderName}</b>
+          </Text>
+        </Box>
+        <Box shrink="No">
+          <Time size="T200" ts={memberTs} priority="300" />
+        </Box>
+      </Box>
+      <Box gap="300">
+        <Avatar size="300">
+          <RoomAvatar
+            roomId={room.roomId}
+            src={direct ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)}
+            alt={roomName}
+            renderFallback={() => (
+              <Text as="span" size="H6">
+                {nameInitials(roomName)}
+              </Text>
+            )}
+          />
+        </Avatar>
+        <Box direction={compact ? 'Column' : 'Row'} grow="Yes" gap="200">
+          <Box grow="Yes" direction="Column" gap="200">
+            <Box direction="Column">
+              <Text size="T300" truncate>
+                <b>{roomName}</b>
+              </Text>
+              {topic && (
+                <Text
+                  size="T200"
+                  onClick={openTopic}
+                  onKeyDown={onEnterOrSpace(openTopic)}
+                  tabIndex={0}
+                  truncate
+                >
+                  {topic}
+                </Text>
+              )}
+              <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
+                <OverlayCenter>
+                  <FocusTrap
+                    focusTrapOptions={{
+                      initialFocus: false,
+                      clickOutsideDeactivates: true,
+                      onDeactivate: closeTopic,
+                    }}
+                  >
+                    <RoomTopicViewer
+                      name={roomName}
+                      topic={topic ?? ''}
+                      requestClose={closeTopic}
+                    />
+                  </FocusTrap>
+                </OverlayCenter>
+              </Overlay>
+            </Box>
+            {joinState.status === AsyncStatus.Error && (
+              <Text size="T200" style={{ color: color.Critical.Main }}>
+                {joinState.error.message}
+              </Text>
+            )}
+            {leaveState.status === AsyncStatus.Error && (
+              <Text size="T200" style={{ color: color.Critical.Main }}>
+                {leaveState.error.message}
+              </Text>
+            )}
+          </Box>
+          <Box gap="200" shrink="No" alignItems="Center">
+            <Button
+              onClick={leave}
+              size="300"
+              variant="Secondary"
+              fill="Soft"
+              disabled={joining || leaving}
+              before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
+            >
+              <Text size="B300">Decline</Text>
+            </Button>
+            <Button
+              onClick={join}
+              size="300"
+              variant="Primary"
+              fill="Soft"
+              outlined
+              disabled={joining || leaving}
+              before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined}
+            >
+              <Text size="B300">Accept</Text>
+            </Button>
+          </Box>
+        </Box>
+      </Box>
+    </SequenceCard>
+  );
+}
+
+export function Invites() {
+  const mx = useMatrixClient();
+  const userId = mx.getUserId()!;
+  const mDirects = useAtomValue(mDirectAtom);
+  const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
+  const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
+  const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
+  useElementSizeObserver(
+    useCallback(() => containerRef.current, []),
+    useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
+  );
+
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+  const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
+    const room = mx.getRoom(roomId);
+    if (!room) return null;
+    return (
+      <InviteCard
+        key={roomId}
+        room={room}
+        userId={userId}
+        compact={compact}
+        direct={direct}
+        onNavigate={handleNavigate}
+      />
+    );
+  };
+
+  return (
+    <Page>
+      <PageHeader>
+        <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+          <Icon size="400" src={Icons.Mail} />
+          <Text size="H3" truncate>
+            Invitations
+          </Text>
+        </Box>
+      </PageHeader>
+      <Box grow="Yes">
+        <Scroll hideTrack visibility="Hover">
+          <PageContent>
+            <PageContentCenter>
+              <Box ref={containerRef} direction="Column" gap="600">
+                {directInvites.length > 0 && (
+                  <Box direction="Column" gap="200">
+                    <Text size="H4">Direct Messages</Text>
+                    <Box direction="Column" gap="100">
+                      {directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
+                    </Box>
+                  </Box>
+                )}
+                {spaceInvites.length > 0 && (
+                  <Box direction="Column" gap="200">
+                    <Text size="H4">Spaces</Text>
+                    <Box direction="Column" gap="100">
+                      {spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
+                    </Box>
+                  </Box>
+                )}
+                {roomInvites.length > 0 && (
+                  <Box direction="Column" gap="200">
+                    <Text size="H4">Rooms</Text>
+                    <Box direction="Column" gap="100">
+                      {roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
+                    </Box>
+                  </Box>
+                )}
+                {directInvites.length === 0 &&
+                  spaceInvites.length === 0 &&
+                  roomInvites.length === 0 && (
+                    <div>
+                      <SequenceCard
+                        variant="SurfaceVariant"
+                        style={{ padding: config.space.S400 }}
+                        direction="Column"
+                        gap="200"
+                      >
+                        <Text>No Pending Invitations</Text>
+                        <Text size="T200">
+                          You don&apos;t have any new pending invitations to display yet.
+                        </Text>
+                      </SequenceCard>
+                    </div>
+                  )}
+              </Box>
+            </PageContentCenter>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
new file mode 100644 (file)
index 0000000..1ab08f0
--- /dev/null
@@ -0,0 +1,609 @@
+/* eslint-disable react/destructuring-assignment */
+import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+  Avatar,
+  Box,
+  Chip,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Scroll,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+import { useSearchParams } from 'react-router-dom';
+import {
+  INotification,
+  INotificationsResponse,
+  IRoomEvent,
+  JoinRule,
+  Method,
+  Room,
+} from 'matrix-js-sdk';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdLocalPart, isRoomId, isUserId } from '../../../utils/matrix';
+import { InboxNotificationsPathSearchParams } from '../../paths';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { SequenceCard } from '../../../components/sequence-card';
+import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
+import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../../utils/room';
+import { ScrollTopContainer } from '../../../components/scroll-top-container';
+import { useInterval } from '../../../hooks/useInterval';
+import {
+  AvatarBase,
+  ImageContent,
+  MSticker,
+  ModernLayout,
+  RedactedContent,
+  Reply,
+  Time,
+  Username,
+} from '../../../components/message';
+import colorMXID from '../../../../util/colorMXID';
+import { getReactCustomHtmlParser } from '../../../plugins/react-custom-html-parser';
+import { openJoinAlias, openProfileViewer } from '../../../../client/action/navigation';
+import { RenderMessageContent } from '../../../components/RenderMessageContent';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { Image } from '../../../components/media';
+import { ImageViewer } from '../../../components/image-viewer';
+import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
+import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
+import * as customHtmlCss from '../../../styles/CustomHtml.css';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useRoomUnread } from '../../../state/hooks/unread';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { markAsRead } from '../../../../client/action/notifications';
+import { ContainerColor } from '../../../styles/ContainerColor.css';
+import { VirtualTile } from '../../../components/virtualizer';
+import { UserAvatar } from '../../../components/user-avatar';
+
+type RoomNotificationsGroup = {
+  roomId: string;
+  notifications: INotification[];
+};
+type NotificationTimeline = {
+  nextToken?: string;
+  groups: RoomNotificationsGroup[];
+};
+type LoadTimeline = (from?: string) => Promise<void>;
+type SilentReloadTimeline = () => Promise<void>;
+
+const groupNotifications = (notifications: INotification[]): RoomNotificationsGroup[] => {
+  const groups: RoomNotificationsGroup[] = [];
+  notifications.forEach((notification) => {
+    const groupIndex = groups.length - 1;
+    const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex];
+    if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) {
+      lastAddedGroup.notifications.push(notification);
+      return;
+    }
+    groups.push({
+      roomId: notification.room_id,
+      notifications: [notification],
+    });
+  });
+  return groups;
+};
+
+const useNotificationTimeline = (
+  paginationLimit: number,
+  onlyHighlight?: boolean
+): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => {
+  const mx = useMatrixClient();
+  const [notificationTimeline, setNotificationTimeline] = useState<NotificationTimeline>({
+    groups: [],
+  });
+
+  const fetchNotifications = useCallback(
+    (from?: string, limit?: number, only?: 'highlight') => {
+      const queryParams = { from, limit, only };
+      return mx.http.authedRequest<INotificationsResponse>(
+        Method.Get,
+        '/notifications',
+        queryParams
+      );
+    },
+    [mx]
+  );
+
+  const loadTimeline: LoadTimeline = useCallback(
+    async (from) => {
+      if (!from) {
+        setNotificationTimeline({ groups: [] });
+      }
+      const data = await fetchNotifications(
+        from,
+        paginationLimit,
+        onlyHighlight ? 'highlight' : undefined
+      );
+      const groups = groupNotifications(data.notifications);
+
+      setNotificationTimeline((currentTimeline) => {
+        if (currentTimeline.nextToken === from) {
+          return {
+            nextToken: data.next_token,
+            groups: from ? currentTimeline.groups.concat(groups) : groups,
+          };
+        }
+        return currentTimeline;
+      });
+    },
+    [paginationLimit, onlyHighlight, fetchNotifications]
+  );
+
+  /**
+   * Reload timeline silently i.e without setting to default
+   * before fetching notifications from start
+   */
+  const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => {
+    const data = await fetchNotifications(
+      undefined,
+      paginationLimit,
+      onlyHighlight ? 'highlight' : undefined
+    );
+    const groups = groupNotifications(data.notifications);
+    setNotificationTimeline({
+      nextToken: data.next_token,
+      groups,
+    });
+  }, [paginationLimit, onlyHighlight, fetchNotifications]);
+
+  return [notificationTimeline, loadTimeline, silentReloadTimeline];
+};
+
+type RoomNotificationsGroupProps = {
+  room: Room;
+  notifications: INotification[];
+  mediaAutoLoad?: boolean;
+  urlPreview?: boolean;
+  onOpen: (roomId: string, eventId: string) => void;
+};
+function RoomNotificationsGroupComp({
+  room,
+  notifications,
+  mediaAutoLoad,
+  urlPreview,
+  onOpen,
+}: RoomNotificationsGroupProps) {
+  const mx = useMatrixClient();
+  const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+
+  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+    () =>
+      getReactCustomHtmlParser(mx, room, {
+        handleSpoilerClick: (evt) => {
+          const target = evt.currentTarget;
+          if (target.getAttribute('aria-pressed') === 'true') {
+            evt.stopPropagation();
+            target.setAttribute('aria-pressed', 'false');
+            target.style.cursor = 'initial';
+          }
+        },
+        handleMentionClick: (evt) => {
+          const target = evt.currentTarget;
+          const mentionId = target.getAttribute('data-mention-id');
+          if (typeof mentionId !== 'string') return;
+          if (isUserId(mentionId)) {
+            openProfileViewer(mentionId, room.roomId);
+            return;
+          }
+          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+            if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
+            else navigateRoom(mentionId);
+            return;
+          }
+          openJoinAlias(mentionId);
+        },
+      }),
+    [mx, room, navigateRoom, navigateSpace]
+  );
+
+  const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
+    {
+      [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
+        if (event.unsigned?.redacted_because) {
+          return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+        }
+
+        return (
+          <RenderMessageContent
+            displayName={displayName}
+            msgType={event.content.msgtype ?? ''}
+            ts={event.origin_server_ts}
+            getContent={getContent}
+            mediaAutoLoad={mediaAutoLoad}
+            urlPreview={urlPreview}
+            htmlReactParserOptions={htmlReactParserOptions}
+            outlineAttachment
+          />
+        );
+      },
+      [MessageEvent.Sticker]: (event, displayName, getContent) => {
+        if (event.unsigned?.redacted_because) {
+          return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+        }
+        return (
+          <MSticker
+            content={getContent()}
+            renderImageContent={(props) => (
+              <ImageContent
+                {...props}
+                autoPlay={mediaAutoLoad}
+                renderImage={(p) => <Image {...p} loading="lazy" />}
+                renderViewer={(p) => <ImageViewer {...p} />}
+              />
+            )}
+          />
+        );
+      },
+      [StateEvent.RoomTombstone]: (event) => {
+        const { content } = event;
+        return (
+          <Box grow="Yes" direction="Column">
+            <Text size="T400" priority="300">
+              Room Tombstone. {content.body}
+            </Text>
+          </Box>
+        );
+      },
+    },
+    undefined,
+    (event) => {
+      if (event.unsigned?.redacted_because) {
+        return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
+      }
+      return (
+        <Box grow="Yes" direction="Column">
+          <Text size="T400" priority="300">
+            <code className={customHtmlCss.Code}>{event.type}</code>
+            {' event'}
+          </Text>
+        </Box>
+      );
+    }
+  );
+
+  const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const eventId = evt.currentTarget.getAttribute('data-event-id');
+    if (!eventId) return;
+    onOpen(room.roomId, eventId);
+  };
+  const handleMarkAsRead = () => {
+    markAsRead(room.roomId);
+  };
+
+  return (
+    <Box direction="Column" gap="200">
+      <Header size="300">
+        <Box gap="200" grow="Yes">
+          <Avatar size="200" radii="300">
+            <RoomAvatar
+              roomId={room.roomId}
+              src={getRoomAvatarUrl(mx, room, 96)}
+              alt={room.name}
+              renderFallback={() => (
+                <RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
+              )}
+            />
+          </Avatar>
+          <Text size="H4" truncate>
+            {room.name}
+          </Text>
+        </Box>
+        <Box shrink="No">
+          {unread && (
+            <Chip
+              variant="Primary"
+              radii="Pill"
+              onClick={handleMarkAsRead}
+              before={<Icon size="100" src={Icons.CheckTwice} />}
+            >
+              <Text size="T200">Mark as Read</Text>
+            </Chip>
+          )}
+        </Box>
+      </Header>
+      <Box direction="Column" gap="100">
+        {notifications.map((notification) => {
+          const { event } = notification;
+
+          const displayName =
+            getMemberDisplayName(room, event.sender) ??
+            getMxIdLocalPart(event.sender) ??
+            event.sender;
+          const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+          const getContent = (() => event.content) as GetContentCallback;
+
+          const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
+
+          return (
+            <SequenceCard
+              key={notification.event.event_id}
+              style={{ padding: config.space.S400 }}
+              variant="SurfaceVariant"
+              direction="Column"
+            >
+              <ModernLayout
+                before={
+                  <AvatarBase>
+                    <Avatar size="300">
+                      <UserAvatar
+                        userId={event.sender}
+                        src={
+                          senderAvatarMxc
+                            ? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
+                            : undefined
+                        }
+                        alt={displayName}
+                        renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+                      />
+                    </Avatar>
+                  </AvatarBase>
+                }
+              >
+                <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+                  <Box gap="200" alignItems="Baseline">
+                    <Username style={{ color: colorMXID(event.sender) }}>
+                      <Text as="span" truncate>
+                        <b>{displayName}</b>
+                      </Text>
+                    </Username>
+                    <Time ts={event.origin_server_ts} />
+                  </Box>
+                  <Box shrink="No" gap="200" alignItems="Center">
+                    <Chip
+                      data-event-id={event.event_id}
+                      onClick={handleOpenClick}
+                      variant="Secondary"
+                      radii="400"
+                    >
+                      <Text size="T200">Open</Text>
+                    </Chip>
+                  </Box>
+                </Box>
+                {replyEventId && (
+                  <Reply
+                    as="button"
+                    mx={mx}
+                    room={room}
+                    eventId={replyEventId}
+                    data-event-id={replyEventId}
+                    onClick={handleOpenClick}
+                  />
+                )}
+                {renderMatrixEvent(event.type, false, event, displayName, getContent)}
+              </ModernLayout>
+            </SequenceCard>
+          );
+        })}
+      </Box>
+    </Box>
+  );
+}
+
+const useNotificationsSearchParams = (
+  searchParams: URLSearchParams
+): InboxNotificationsPathSearchParams =>
+  useMemo(
+    () => ({
+      only: searchParams.get('only') ?? undefined,
+    }),
+    [searchParams]
+  );
+
+const DEFAULT_REFRESH_MS = 10000;
+
+export function Notifications() {
+  const mx = useMatrixClient();
+  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+
+  const { navigateRoom } = useRoomNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const notificationsSearchParams = useNotificationsSearchParams(searchParams);
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+  const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS);
+
+  const onlyHighlight = notificationsSearchParams.only === 'highlight';
+  const setOnlyHighlighted = (highlight: boolean) => {
+    if (highlight) {
+      setSearchParams(
+        new URLSearchParams({
+          only: 'highlight',
+        })
+      );
+      return;
+    }
+    setSearchParams();
+  };
+
+  const [notificationTimeline, _loadTimeline, silentReloadTimeline] = useNotificationTimeline(
+    24,
+    onlyHighlight
+  );
+  const [timelineState, loadTimeline] = useAsyncCallback(_loadTimeline);
+
+  const virtualizer = useVirtualizer({
+    count: notificationTimeline.groups.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 40,
+    overscan: 4,
+  });
+  const vItems = virtualizer.getVirtualItems();
+
+  useInterval(
+    useCallback(() => {
+      if (document.hasFocus()) {
+        silentReloadTimeline();
+      }
+    }, [silentReloadTimeline]),
+    refreshIntervalTime
+  );
+
+  const handleScrollTopVisibility = useCallback(
+    (onTop: boolean) => setRefreshIntervalTime(onTop ? DEFAULT_REFRESH_MS : -1),
+    []
+  );
+
+  useEffect(() => {
+    loadTimeline();
+  }, [loadTimeline]);
+
+  const lastVItem = vItems[vItems.length - 1];
+  const lastVItemIndex: number | undefined = lastVItem?.index;
+  useEffect(() => {
+    if (
+      timelineState.status === AsyncStatus.Success &&
+      notificationTimeline.groups.length - 1 === lastVItemIndex &&
+      notificationTimeline.nextToken
+    ) {
+      loadTimeline(notificationTimeline.nextToken);
+    }
+  }, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]);
+
+  return (
+    <Page>
+      <PageHeader>
+        <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+          <Icon size="400" src={Icons.Message} />
+          <Text size="H3" truncate>
+            Notification Messages
+          </Text>
+        </Box>
+      </PageHeader>
+
+      <Box style={{ position: 'relative' }} grow="Yes">
+        <Scroll ref={scrollRef} hideTrack visibility="Hover">
+          <PageContent>
+            <PageContentCenter>
+              <Box direction="Column" gap="200">
+                <Box ref={scrollTopAnchorRef} direction="Column" gap="100">
+                  <span data-spacing-node />
+                  <Text size="L400">Filter</Text>
+                  <Box gap="200">
+                    <Chip
+                      onClick={() => setOnlyHighlighted(false)}
+                      variant={!onlyHighlight ? 'Success' : 'Surface'}
+                      aria-pressed={!onlyHighlight}
+                      before={!onlyHighlight && <Icon size="100" src={Icons.Check} />}
+                      outlined
+                    >
+                      <Text size="T200">All Notifications</Text>
+                    </Chip>
+                    <Chip
+                      onClick={() => setOnlyHighlighted(true)}
+                      variant={onlyHighlight ? 'Success' : 'Surface'}
+                      aria-pressed={onlyHighlight}
+                      before={onlyHighlight && <Icon size="100" src={Icons.Check} />}
+                      outlined
+                    >
+                      <Text size="T200">Highlighted</Text>
+                    </Chip>
+                  </Box>
+                </Box>
+                <ScrollTopContainer
+                  scrollRef={scrollRef}
+                  anchorRef={scrollTopAnchorRef}
+                  onVisibilityChange={handleScrollTopVisibility}
+                >
+                  <IconButton
+                    onClick={() => virtualizer.scrollToOffset(0)}
+                    variant="SurfaceVariant"
+                    radii="Pill"
+                    outlined
+                    size="300"
+                    aria-label="Scroll to Top"
+                  >
+                    <Icon src={Icons.ChevronTop} size="300" />
+                  </IconButton>
+                </ScrollTopContainer>
+                <div
+                  style={{
+                    position: 'relative',
+                    height: virtualizer.getTotalSize(),
+                  }}
+                >
+                  {vItems.map((vItem) => {
+                    const group = notificationTimeline.groups[vItem.index];
+                    if (!group) return null;
+                    const groupRoom = mx.getRoom(group.roomId);
+                    if (!groupRoom) return null;
+
+                    return (
+                      <VirtualTile
+                        virtualItem={vItem}
+                        style={{ paddingTop: config.space.S500 }}
+                        ref={virtualizer.measureElement}
+                        key={vItem.index}
+                      >
+                        <RoomNotificationsGroupComp
+                          room={groupRoom}
+                          notifications={group.notifications}
+                          mediaAutoLoad={mediaAutoLoad}
+                          urlPreview={urlPreview}
+                          onOpen={navigateRoom}
+                        />
+                      </VirtualTile>
+                    );
+                  })}
+                </div>
+
+                {timelineState.status === AsyncStatus.Success &&
+                  notificationTimeline.groups.length === 0 && (
+                    <Box
+                      className={ContainerColor({ variant: 'SurfaceVariant' })}
+                      style={{
+                        padding: config.space.S300,
+                        borderRadius: config.radii.R400,
+                      }}
+                      direction="Column"
+                      gap="200"
+                    >
+                      <Text>No Notifications</Text>
+                      <Text size="T200">
+                        You don&apos;t have any new notifications to display yet.
+                      </Text>
+                    </Box>
+                  )}
+
+                {timelineState.status === AsyncStatus.Loading && (
+                  <Box direction="Column" gap="100">
+                    {[...Array(8).keys()].map((key) => (
+                      <SequenceCard
+                        variant="SurfaceVariant"
+                        key={key}
+                        style={{ minHeight: toRem(80) }}
+                      />
+                    ))}
+                  </Box>
+                )}
+                {timelineState.status === AsyncStatus.Error && (
+                  <Box
+                    className={ContainerColor({ variant: 'Critical' })}
+                    style={{
+                      padding: config.space.S300,
+                      borderRadius: config.radii.R400,
+                    }}
+                    direction="Column"
+                    gap="200"
+                  >
+                    <Text size="L400">{(timelineState.error as Error).name}</Text>
+                    <Text size="T300">{(timelineState.error as Error).message}</Text>
+                  </Box>
+                )}
+              </Box>
+            </PageContentCenter>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts
new file mode 100644 (file)
index 0000000..c8036b4
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './Inbox';
+export * from './Notifications';
+export * from './Invites';
diff --git a/src/app/pages/client/index.ts b/src/app/pages/client/index.ts
new file mode 100644 (file)
index 0000000..5668e81
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './ClientRoot';
+export * from './ClientBindAtoms';
+export * from './ClientLayout';
diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx
new file mode 100644 (file)
index 0000000..f25d7bd
--- /dev/null
@@ -0,0 +1,132 @@
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useAtomValue } from 'jotai';
+import { useDirects } from '../../../state/hooks/roomList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { getDirectPath, joinPathComponent } from '../../pathUtils';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import {
+  SidebarAvatar,
+  SidebarItem,
+  SidebarItemBadge,
+  SidebarItemTooltip,
+} from '../../../components/sidebar';
+import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+import { useDirectRooms } from '../direct/useDirectRooms';
+import { markAsRead } from '../../../../client/action/notifications';
+
+type DirectMenuProps = {
+  requestClose: () => void;
+};
+const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
+  const orphanRooms = useDirectRooms();
+  const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+  const handleMarkAsRead = () => {
+    if (!unread) return;
+    orphanRooms.forEach((rId) => markAsRead(rId));
+    requestClose();
+  };
+
+  return (
+    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleMarkAsRead}
+          size="300"
+          after={<Icon size="100" src={Icons.CheckTwice} />}
+          radii="300"
+          aria-disabled={!unread}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Mark as Read
+          </Text>
+        </MenuItem>
+      </Box>
+    </Menu>
+  );
+});
+
+export function DirectTab() {
+  const navigate = useNavigate();
+  const mx = useMatrixClient();
+  const screenSize = useScreenSizeContext();
+  const navToActivePath = useAtomValue(useNavToActivePathAtom());
+
+  const mDirects = useAtomValue(mDirectAtom);
+  const directs = useDirects(mx, allRoomsAtom, mDirects);
+  const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const directSelected = useDirectSelected();
+
+  const handleDirectClick = () => {
+    const activePath = navToActivePath.get('direct');
+    if (activePath && screenSize !== ScreenSize.Mobile) {
+      navigate(joinPathComponent(activePath));
+      return;
+    }
+
+    navigate(getDirectPath());
+  };
+
+  const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    evt.preventDefault();
+    const cords = evt.currentTarget.getBoundingClientRect();
+    setMenuAnchor((currentState) => {
+      if (currentState) return undefined;
+      return cords;
+    });
+  };
+  return (
+    <SidebarItem active={directSelected}>
+      <SidebarItemTooltip tooltip="Direct Messages">
+        {(triggerRef) => (
+          <SidebarAvatar
+            as="button"
+            ref={triggerRef}
+            outlined
+            onClick={handleDirectClick}
+            onContextMenu={handleContextMenu}
+          >
+            <Icon src={Icons.User} filled={directSelected} />
+          </SidebarAvatar>
+        )}
+      </SidebarItemTooltip>
+      {directUnread && (
+        <SidebarItemBadge hasCount={directUnread.total > 0}>
+          <UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
+        </SidebarItemBadge>
+      )}
+      {menuAnchor && (
+        <PopOut
+          anchor={menuAnchor}
+          position="Right"
+          align="Start"
+          content={
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                returnFocusOnDeactivate: false,
+                onDeactivate: () => setMenuAnchor(undefined),
+                clickOutsideDeactivates: true,
+                isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+              }}
+            >
+              <DirectMenu requestClose={() => setMenuAnchor(undefined)} />
+            </FocusTrap>
+          }
+        />
+      )}
+    </SidebarItem>
+  );
+}
diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx
new file mode 100644 (file)
index 0000000..a45b583
--- /dev/null
@@ -0,0 +1,64 @@
+import React from 'react';
+import { Icon, Icons } from 'folds';
+import { useNavigate } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
+import { useExploreSelected } from '../../../hooks/router/useExploreSelected';
+import {
+  getExploreFeaturedPath,
+  getExplorePath,
+  getExploreServerPath,
+  joinPathComponent,
+} from '../../pathUtils';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdServer } from '../../../utils/matrix';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+
+export function ExploreTab() {
+  const mx = useMatrixClient();
+  const screenSize = useScreenSizeContext();
+  const clientConfig = useClientConfig();
+  const navigate = useNavigate();
+  const navToActivePath = useAtomValue(useNavToActivePathAtom());
+
+  const exploreSelected = useExploreSelected();
+
+  const handleExploreClick = () => {
+    if (screenSize === ScreenSize.Mobile) {
+      navigate(getExplorePath());
+      return;
+    }
+
+    const activePath = navToActivePath.get('explore');
+    if (activePath) {
+      navigate(joinPathComponent(activePath));
+      return;
+    }
+
+    if (clientConfig.featuredCommunities?.openAsDefault) {
+      navigate(getExploreFeaturedPath());
+      return;
+    }
+    const userId = mx.getUserId();
+    const userServer = userId ? getMxIdServer(userId) : undefined;
+    if (userServer) {
+      navigate(getExploreServerPath(userServer));
+      return;
+    }
+    navigate(getExplorePath());
+  };
+
+  return (
+    <SidebarItem active={exploreSelected}>
+      <SidebarItemTooltip tooltip="Explore Community">
+        {(triggerRef) => (
+          <SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleExploreClick}>
+            <Icon src={Icons.Explore} filled={exploreSelected} />
+          </SidebarAvatar>
+        )}
+      </SidebarItemTooltip>
+    </SidebarItem>
+  );
+}
diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx
new file mode 100644 (file)
index 0000000..0b5135c
--- /dev/null
@@ -0,0 +1,134 @@
+import React, { MouseEventHandler, forwardRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { useAtomValue } from 'jotai';
+import FocusTrap from 'focus-trap-react';
+import { useOrphanRooms } from '../../../state/hooks/roomList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { getHomePath, joinPathComponent } from '../../pathUtils';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import {
+  SidebarAvatar,
+  SidebarItem,
+  SidebarItemBadge,
+  SidebarItemTooltip,
+} from '../../../components/sidebar';
+import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+import { useHomeRooms } from '../home/useHomeRooms';
+import { markAsRead } from '../../../../client/action/notifications';
+
+type HomeMenuProps = {
+  requestClose: () => void;
+};
+const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
+  const orphanRooms = useHomeRooms();
+  const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+
+  const handleMarkAsRead = () => {
+    if (!unread) return;
+    orphanRooms.forEach((rId) => markAsRead(rId));
+    requestClose();
+  };
+
+  return (
+    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleMarkAsRead}
+          size="300"
+          after={<Icon size="100" src={Icons.CheckTwice} />}
+          radii="300"
+          aria-disabled={!unread}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Mark as Read
+          </Text>
+        </MenuItem>
+      </Box>
+    </Menu>
+  );
+});
+
+export function HomeTab() {
+  const navigate = useNavigate();
+  const mx = useMatrixClient();
+  const screenSize = useScreenSizeContext();
+  const navToActivePath = useAtomValue(useNavToActivePathAtom());
+
+  const mDirects = useAtomValue(mDirectAtom);
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
+  const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
+  const homeSelected = useHomeSelected();
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleHomeClick = () => {
+    const activePath = navToActivePath.get('home');
+    if (activePath && screenSize !== ScreenSize.Mobile) {
+      navigate(joinPathComponent(activePath));
+      return;
+    }
+
+    navigate(getHomePath());
+  };
+
+  const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    evt.preventDefault();
+    const cords = evt.currentTarget.getBoundingClientRect();
+    setMenuAnchor((currentState) => {
+      if (currentState) return undefined;
+      return cords;
+    });
+  };
+
+  return (
+    <SidebarItem active={homeSelected}>
+      <SidebarItemTooltip tooltip="Home">
+        {(triggerRef) => (
+          <SidebarAvatar
+            as="button"
+            ref={triggerRef}
+            outlined
+            onClick={handleHomeClick}
+            onContextMenu={handleContextMenu}
+          >
+            <Icon src={Icons.Home} filled={homeSelected} />
+          </SidebarAvatar>
+        )}
+      </SidebarItemTooltip>
+      {homeUnread && (
+        <SidebarItemBadge hasCount={homeUnread.total > 0}>
+          <UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
+        </SidebarItemBadge>
+      )}
+      {menuAnchor && (
+        <PopOut
+          anchor={menuAnchor}
+          position="Right"
+          align="Start"
+          content={
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                returnFocusOnDeactivate: false,
+                onDeactivate: () => setMenuAnchor(undefined),
+                clickOutsideDeactivates: true,
+                isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+              }}
+            >
+              <HomeMenu requestClose={() => setMenuAnchor(undefined)} />
+            </FocusTrap>
+          }
+        />
+      )}
+    </SidebarItem>
+  );
+}
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
new file mode 100644 (file)
index 0000000..5d13a9a
--- /dev/null
@@ -0,0 +1,62 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Icon, Icons } from 'folds';
+import { useAtomValue } from 'jotai';
+import {
+  SidebarAvatar,
+  SidebarItem,
+  SidebarItemBadge,
+  SidebarItemTooltip,
+} from '../../../components/sidebar';
+import { allInvitesAtom } from '../../../state/room-list/inviteList';
+import {
+  getInboxInvitesPath,
+  getInboxNotificationsPath,
+  getInboxPath,
+  joinPathComponent,
+} from '../../pathUtils';
+import { useInboxSelected } from '../../../hooks/router/useInbox';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+
+export function InboxTab() {
+  const screenSize = useScreenSizeContext();
+  const navigate = useNavigate();
+  const navToActivePath = useAtomValue(useNavToActivePathAtom());
+  const inboxSelected = useInboxSelected();
+  const allInvites = useAtomValue(allInvitesAtom);
+  const inviteCount = allInvites.length;
+
+  const handleInboxClick = () => {
+    if (screenSize === ScreenSize.Mobile) {
+      navigate(getInboxPath());
+      return;
+    }
+    const activePath = navToActivePath.get('inbox');
+    if (activePath) {
+      navigate(joinPathComponent(activePath));
+      return;
+    }
+
+    const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath();
+    navigate(path);
+  };
+
+  return (
+    <SidebarItem active={inboxSelected}>
+      <SidebarItemTooltip tooltip="Inbox">
+        {(triggerRef) => (
+          <SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleInboxClick}>
+            <Icon src={Icons.Inbox} filled={inboxSelected} />
+          </SidebarAvatar>
+        )}
+      </SidebarItemTooltip>
+      {inviteCount > 0 && (
+        <SidebarItemBadge hasCount>
+          <UnreadBadge highlight count={inviteCount} />
+        </SidebarItemBadge>
+      )}
+    </SidebarItem>
+  );
+}
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
new file mode 100644 (file)
index 0000000..99c0496
--- /dev/null
@@ -0,0 +1,844 @@
+import React, {
+  MouseEventHandler,
+  ReactNode,
+  RefObject,
+  forwardRef,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+  Box,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+import { useAtom, useAtomValue } from 'jotai';
+import { Room } from 'matrix-js-sdk';
+import {
+  draggable,
+  dropTargetForElements,
+  monitorForElements,
+} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import {
+  attachInstruction,
+  extractInstruction,
+  Instruction,
+} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
+import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import FocusTrap from 'focus-trap-react';
+import {
+  useOrphanSpaces,
+  useRecursiveChildScopeFactory,
+  useSpaceChildren,
+} from '../../../state/hooks/roomList';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import {
+  getOriginBaseUrl,
+  getSpaceLobbyPath,
+  getSpacePath,
+  joinPathComponent,
+  withOriginBaseUrl,
+} from '../../pathUtils';
+import {
+  SidebarAvatar,
+  SidebarItem,
+  SidebarItemBadge,
+  SidebarItemTooltip,
+  SidebarStack,
+  SidebarStackSeparator,
+  SidebarFolder,
+  SidebarFolderDropTarget,
+} from '../../../components/sidebar';
+import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider';
+import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
+import { UnreadBadge } from '../../../components/unread-badge';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { RoomAvatar } from '../../../components/room-avatar';
+import { nameInitials, randomStr } from '../../../utils/common';
+import {
+  ISidebarFolder,
+  SidebarItems,
+  TSidebarItem,
+  makeCinnySpacesContent,
+  parseSidebar,
+  sidebarItemWithout,
+  useSidebarItems,
+} from '../../../hooks/useSidebarItems';
+import { AccountDataEvent } from '../../../../types/matrix/accountData';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
+import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { markAsRead } from '../../../../client/action/notifications';
+import { copyToClipboard } from '../../../utils/dom';
+import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+
+type SpaceMenuProps = {
+  room: Room;
+  requestClose: () => void;
+  onUnpin?: (roomId: string) => void;
+};
+const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
+  ({ room, requestClose, onUnpin }, ref) => {
+    const mx = useMatrixClient();
+    const { hashRouter } = useClientConfig();
+    const roomToParents = useAtomValue(roomToParentsAtom);
+    const powerLevels = usePowerLevels(room);
+    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+    const allChild = useSpaceChildren(
+      allRoomsAtom,
+      room.roomId,
+      useRecursiveChildScopeFactory(mx, roomToParents)
+    );
+    const unread = useRoomsUnread(allChild, roomToUnreadAtom);
+
+    const handleMarkAsRead = () => {
+      allChild.forEach((childRoomId) => markAsRead(childRoomId));
+      requestClose();
+    };
+
+    const handleUnpin = () => {
+      onUnpin?.(room.roomId);
+      requestClose();
+    };
+
+    const handleCopyLink = () => {
+      const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
+      copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
+      requestClose();
+    };
+
+    const handleInvite = () => {
+      openInviteUser(room.roomId);
+      requestClose();
+    };
+
+    const handleRoomSettings = () => {
+      openSpaceSettings(room.roomId);
+      requestClose();
+    };
+
+    return (
+      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleMarkAsRead}
+            size="300"
+            after={<Icon size="100" src={Icons.CheckTwice} />}
+            radii="300"
+            disabled={!unread}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Mark as Read
+            </Text>
+          </MenuItem>
+          {onUnpin && (
+            <MenuItem
+              size="300"
+              radii="300"
+              onClick={handleUnpin}
+              after={<Icon size="100" src={Icons.Pin} />}
+            >
+              <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                Unpin
+              </Text>
+            </MenuItem>
+          )}
+        </Box>
+        <Line variant="Surface" size="300" />
+        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+          <MenuItem
+            onClick={handleInvite}
+            variant="Primary"
+            fill="None"
+            size="300"
+            after={<Icon size="100" src={Icons.UserPlus} />}
+            radii="300"
+            disabled={!canInvite}
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Invite
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleCopyLink}
+            size="300"
+            after={<Icon size="100" src={Icons.Link} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Copy Link
+            </Text>
+          </MenuItem>
+          <MenuItem
+            onClick={handleRoomSettings}
+            size="300"
+            after={<Icon size="100" src={Icons.Setting} />}
+            radii="300"
+          >
+            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+              Space Settings
+            </Text>
+          </MenuItem>
+        </Box>
+      </Menu>
+    );
+  }
+);
+
+type InstructionType = Instruction['type'];
+type FolderDraggable = {
+  folder: ISidebarFolder;
+  spaceId?: string;
+  open?: boolean;
+};
+type SidebarDraggable = string | FolderDraggable;
+
+const useDraggableItem = (
+  item: SidebarDraggable,
+  targetRef: RefObject<HTMLElement>,
+  onDragging: (item?: SidebarDraggable) => void,
+  dragHandleRef?: RefObject<HTMLElement>
+): boolean => {
+  const [dragging, setDragging] = useState(false);
+
+  useEffect(() => {
+    const target = targetRef.current;
+    const dragHandle = dragHandleRef?.current ?? undefined;
+
+    return !target
+      ? undefined
+      : draggable({
+          element: target,
+          dragHandle,
+          getInitialData: () => ({ item }),
+          onDragStart: () => {
+            setDragging(true);
+            onDragging?.(item);
+          },
+          onDrop: () => {
+            setDragging(false);
+            onDragging?.(undefined);
+          },
+        });
+  }, [targetRef, dragHandleRef, item, onDragging]);
+
+  return dragging;
+};
+
+const useDropTarget = (
+  item: SidebarDraggable,
+  targetRef: RefObject<HTMLElement>
+): Instruction | undefined => {
+  const [dropState, setDropState] = useState<Instruction>();
+
+  useEffect(() => {
+    const target = targetRef.current;
+    if (!target) return undefined;
+
+    return dropTargetForElements({
+      element: target,
+      canDrop: ({ source }) => {
+        const dragItem = source.data.item as SidebarDraggable;
+        return dragItem !== item;
+      },
+      getData: ({ input, element }) => {
+        const block: Instruction['type'][] = ['reparent'];
+        if (typeof item === 'object' && item.spaceId) block.push('make-child');
+
+        const insData = attachInstruction(
+          {},
+          {
+            input,
+            element,
+            currentLevel: 0,
+            indentPerLevel: 0,
+            mode: 'standard',
+            block,
+          }
+        );
+
+        const instruction: Instruction | null = extractInstruction(insData);
+        setDropState(instruction ?? undefined);
+
+        return {
+          item,
+          instructionType: instruction ? instruction.type : undefined,
+        };
+      },
+      onDragLeave: () => setDropState(undefined),
+      onDrop: () => setDropState(undefined),
+    });
+  }, [item, targetRef]);
+
+  return dropState;
+};
+
+function useDropTargetInstruction<T extends InstructionType>(
+  item: SidebarDraggable,
+  targetRef: RefObject<HTMLElement>,
+  instructionType: T
+): T | undefined {
+  const [dropState, setDropState] = useState<T>();
+
+  useEffect(() => {
+    const target = targetRef.current;
+    if (!target) return undefined;
+
+    return dropTargetForElements({
+      element: target,
+      canDrop: ({ source }) => {
+        const dragItem = source.data.item as SidebarDraggable;
+        return dragItem !== item;
+      },
+      getData: () => {
+        setDropState(instructionType);
+
+        return {
+          item,
+          instructionType,
+        };
+      },
+      onDragLeave: () => setDropState(undefined),
+      onDrop: () => setDropState(undefined),
+    });
+  }, [item, targetRef, instructionType]);
+
+  return dropState;
+}
+
+const useDnDMonitor = (
+  scrollRef: RefObject<HTMLElement>,
+  onDragging: (dragItem?: SidebarDraggable) => void,
+  onReorder: (
+    draggable: SidebarDraggable,
+    container: SidebarDraggable,
+    instruction: InstructionType
+  ) => void
+) => {
+  useEffect(() => {
+    const scrollElement = scrollRef.current;
+    if (!scrollElement) {
+      throw Error('Scroll element ref not configured');
+    }
+
+    return combine(
+      monitorForElements({
+        onDrop: ({ source, location }) => {
+          onDragging(undefined);
+          const { dropTargets } = location.current;
+          if (dropTargets.length === 0) return;
+          const item = source.data.item as SidebarDraggable;
+          const containerItem = dropTargets[0].data.item as SidebarDraggable;
+          const instructionType = dropTargets[0].data.instructionType as
+            | InstructionType
+            | undefined;
+          if (!instructionType) return;
+          onReorder(item, containerItem, instructionType);
+        },
+      }),
+      autoScrollForElements({
+        element: scrollElement,
+      })
+    );
+  }, [scrollRef, onDragging, onReorder]);
+};
+
+type SpaceTabProps = {
+  space: Room;
+  selected: boolean;
+  onClick: MouseEventHandler<HTMLButtonElement>;
+  folder?: ISidebarFolder;
+  onDragging: (dragItem?: SidebarDraggable) => void;
+  disabled?: boolean;
+  onUnpin?: (roomId: string) => void;
+};
+function SpaceTab({
+  space,
+  selected,
+  onClick,
+  folder,
+  onDragging,
+  disabled,
+  onUnpin,
+}: SpaceTabProps) {
+  const mx = useMatrixClient();
+  const targetRef = useRef<HTMLDivElement>(null);
+
+  const spaceDraggable: SidebarDraggable = useMemo(
+    () =>
+      folder
+        ? {
+            folder,
+            spaceId: space.roomId,
+          }
+        : space.roomId,
+    [folder, space]
+  );
+
+  useDraggableItem(spaceDraggable, targetRef, onDragging);
+  const dropState = useDropTarget(spaceDraggable, targetRef);
+  const dropType = dropState?.type;
+
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    evt.preventDefault();
+    const cords = evt.currentTarget.getBoundingClientRect();
+    setMenuAnchor((currentState) => {
+      if (currentState) return undefined;
+      return cords;
+    });
+  };
+
+  return (
+    <RoomUnreadProvider roomId={space.roomId}>
+      {(unread) => (
+        <SidebarItem
+          active={selected}
+          ref={targetRef}
+          aria-disabled={disabled}
+          data-drop-child={dropType === 'make-child'}
+          data-drop-above={dropType === 'reorder-above'}
+          data-drop-below={dropType === 'reorder-below'}
+          data-inside-folder={!!folder}
+        >
+          <SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
+            {(triggerRef) => (
+              <SidebarAvatar
+                as="button"
+                data-id={space.roomId}
+                ref={triggerRef}
+                size={folder ? '300' : '400'}
+                onClick={onClick}
+                onContextMenu={handleContextMenu}
+              >
+                <RoomAvatar
+                  roomId={space.roomId}
+                  src={space.getAvatarUrl(mx.baseUrl, 96, 96, 'crop') ?? undefined}
+                  alt={space.name}
+                  renderFallback={() => (
+                    <Text size={folder ? 'H6' : 'H4'}>{nameInitials(space.name, 2)}</Text>
+                  )}
+                />
+              </SidebarAvatar>
+            )}
+          </SidebarItemTooltip>
+          {unread && (
+            <SidebarItemBadge hasCount={unread.total > 0}>
+              <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
+            </SidebarItemBadge>
+          )}
+          {menuAnchor && (
+            <PopOut
+              anchor={menuAnchor}
+              position="Right"
+              align="Start"
+              content={
+                <FocusTrap
+                  focusTrapOptions={{
+                    initialFocus: false,
+                    returnFocusOnDeactivate: false,
+                    onDeactivate: () => setMenuAnchor(undefined),
+                    clickOutsideDeactivates: true,
+                    isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                    isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                  }}
+                >
+                  <SpaceMenu
+                    room={space}
+                    requestClose={() => setMenuAnchor(undefined)}
+                    onUnpin={onUnpin}
+                  />
+                </FocusTrap>
+              }
+            />
+          )}
+        </SidebarItem>
+      )}
+    </RoomUnreadProvider>
+  );
+}
+
+type OpenedSpaceFolderProps = {
+  folder: ISidebarFolder;
+  onClose: MouseEventHandler<HTMLButtonElement>;
+  children?: ReactNode;
+};
+function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) {
+  const aboveTargetRef = useRef<HTMLDivElement>(null);
+  const belowTargetRef = useRef<HTMLDivElement>(null);
+
+  const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]);
+
+  const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above');
+  const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below');
+
+  return (
+    <SidebarFolder
+      state="Open"
+      data-drop-above={orderAbove === 'reorder-above'}
+      data-drop-below={orderBelow === 'reorder-below'}
+    >
+      <SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
+      <SidebarAvatar size="300">
+        <IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
+          <Icon size="400" src={Icons.ChevronTop} filled />
+        </IconButton>
+      </SidebarAvatar>
+      {children}
+      <SidebarFolderDropTarget ref={belowTargetRef} position="Bottom" />
+    </SidebarFolder>
+  );
+}
+
+type ClosedSpaceFolderProps = {
+  folder: ISidebarFolder;
+  selected: boolean;
+  onOpen: MouseEventHandler<HTMLButtonElement>;
+  onDragging: (dragItem?: SidebarDraggable) => void;
+  disabled?: boolean;
+};
+function ClosedSpaceFolder({
+  folder,
+  selected,
+  onOpen,
+  onDragging,
+  disabled,
+}: ClosedSpaceFolderProps) {
+  const mx = useMatrixClient();
+  const handlerRef = useRef<HTMLDivElement>(null);
+
+  const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]);
+  useDraggableItem(spaceDraggable, handlerRef, onDragging);
+  const dropState = useDropTarget(spaceDraggable, handlerRef);
+  const dropType = dropState?.type;
+
+  const tooltipName =
+    folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
+
+  return (
+    <RoomsUnreadProvider rooms={folder.content}>
+      {(unread) => (
+        <SidebarItem
+          active={selected}
+          ref={handlerRef}
+          aria-disabled={disabled}
+          data-drop-child={dropType === 'make-child'}
+          data-drop-above={dropType === 'reorder-above'}
+          data-drop-below={dropType === 'reorder-below'}
+        >
+          <SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
+            {(tooltipRef) => (
+              <SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
+                {folder.content.map((sId) => {
+                  const space = mx.getRoom(sId);
+                  if (!space) return null;
+
+                  return (
+                    <SidebarAvatar key={sId} size="200" radii="300">
+                      <RoomAvatar
+                        roomId={space.roomId}
+                        src={space.getAvatarUrl(mx.baseUrl, 96, 96, 'crop') ?? undefined}
+                        alt={space.name}
+                        renderFallback={() => (
+                          <Text size="Inherit">
+                            <b>{nameInitials(space.name, 2)}</b>
+                          </Text>
+                        )}
+                      />
+                    </SidebarAvatar>
+                  );
+                })}
+              </SidebarFolder>
+            )}
+          </SidebarItemTooltip>
+          {unread && (
+            <SidebarItemBadge hasCount={unread.total > 0}>
+              <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
+            </SidebarItemBadge>
+          )}
+        </SidebarItem>
+      )}
+    </RoomsUnreadProvider>
+  );
+}
+
+type SpaceTabsProps = {
+  scrollRef: RefObject<HTMLDivElement>;
+};
+export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
+  const navigate = useNavigate();
+  const mx = useMatrixClient();
+  const screenSize = useScreenSizeContext();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
+  const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
+  const navToActivePath = useAtomValue(useNavToActivePathAtom());
+  const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
+  const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
+
+  useDnDMonitor(
+    scrollRef,
+    setDraggingItem,
+    useCallback(
+      (item, containerItem, instructionType) => {
+        const newItems: SidebarItems = [];
+
+        const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => {
+          if (typeof sI === 'string' && typeof dI === 'string') {
+            return sI === dI;
+          }
+          if (typeof sI === 'object' && typeof dI === 'object') {
+            return sI.id === dI.folder.id;
+          }
+          return false;
+        };
+        const itemAsFolderContent = (i: SidebarDraggable): string[] => {
+          if (typeof i === 'string') {
+            return [i];
+          }
+          if (i.spaceId) {
+            return [i.spaceId];
+          }
+          return [...i.folder.content];
+        };
+
+        sidebarItems.forEach((i) => {
+          const sameFolders =
+            typeof item === 'object' &&
+            typeof containerItem === 'object' &&
+            item.folder.id === containerItem.folder.id;
+
+          // remove draggable space from current position or folder
+          if (!sameFolders && matchDest(i, item)) {
+            if (typeof item === 'object' && item.spaceId) {
+              const folderContent = item.folder.content.filter((s) => s !== item.spaceId);
+              if (folderContent.length === 0) {
+                // remove open state from local storage
+                setOpenedFolder({ type: 'DELETE', id: item.folder.id });
+                return;
+              }
+              newItems.push({
+                ...item.folder,
+                content: folderContent,
+              });
+            }
+            return;
+          }
+          if (matchDest(i, containerItem)) {
+            // we can make child only if
+            // container item is space or closed folder
+            if (instructionType === 'make-child') {
+              const child: string[] = itemAsFolderContent(item);
+              if (typeof containerItem === 'string') {
+                const folder: ISidebarFolder = {
+                  id: randomStr(),
+                  content: [containerItem].concat(child),
+                };
+                newItems.push(folder);
+                return;
+              }
+              newItems.push({
+                ...containerItem.folder,
+                content: containerItem.folder.content.concat(child),
+              });
+              return;
+            }
+
+            // drop inside opened folder
+            // or reordering inside same folder
+            if (typeof containerItem === 'object' && containerItem.spaceId) {
+              const child = itemAsFolderContent(item);
+              const newContent: string[] = [];
+              containerItem.folder.content
+                .filter((sId) => !child.includes(sId))
+                .forEach((sId) => {
+                  if (sId === containerItem.spaceId) {
+                    if (instructionType === 'reorder-below') {
+                      newContent.push(sId, ...child);
+                    }
+                    if (instructionType === 'reorder-above') {
+                      newContent.push(...child, sId);
+                    }
+                    return;
+                  }
+                  newContent.push(sId);
+                });
+              const folder = {
+                ...containerItem.folder,
+                content: newContent,
+              };
+
+              newItems.push(folder);
+              return;
+            }
+
+            // drop above or below space or closed/opened folder
+            if (typeof item === 'string') {
+              if (instructionType === 'reorder-below') newItems.push(i);
+              newItems.push(item);
+              if (instructionType === 'reorder-above') newItems.push(i);
+            } else if (item.spaceId) {
+              if (instructionType === 'reorder-above') {
+                newItems.push(item.spaceId);
+              }
+              if (sameFolders && typeof i === 'object') {
+                // remove from folder if placing around itself
+                const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) };
+                if (newI.content.length > 0) newItems.push(newI);
+              } else {
+                newItems.push(i);
+              }
+              if (instructionType === 'reorder-below') {
+                newItems.push(item.spaceId);
+              }
+            } else {
+              if (instructionType === 'reorder-below') newItems.push(i);
+              newItems.push(item.folder);
+              if (instructionType === 'reorder-above') newItems.push(i);
+            }
+            return;
+          }
+          newItems.push(i);
+        });
+
+        const newSpacesContent = makeCinnySpacesContent(mx, newItems);
+        localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
+        mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+      },
+      [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces]
+    )
+  );
+
+  const selectedSpaceId = useSelectedSpace();
+
+  const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const target = evt.currentTarget;
+    const targetSpaceId = target.getAttribute('data-id');
+    if (!targetSpaceId) return;
+
+    if (screenSize === ScreenSize.Mobile) {
+      navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
+      return;
+    }
+
+    const activePath = navToActivePath.get(targetSpaceId);
+    if (activePath) {
+      navigate(joinPathComponent(activePath));
+      return;
+    }
+
+    navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
+  };
+
+  const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const target = evt.currentTarget;
+    const targetFolderId = target.getAttribute('data-id');
+    if (!targetFolderId) return;
+
+    setOpenedFolder({
+      type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT',
+      id: targetFolderId,
+    });
+  };
+
+  const handleUnpin = useCallback(
+    (roomId: string) => {
+      if (orphanSpaces.includes(roomId)) return;
+      const newItems = sidebarItemWithout(sidebarItems, roomId);
+
+      const newSpacesContent = makeCinnySpacesContent(mx, newItems);
+      localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
+      mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
+    },
+    [mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
+  );
+
+  if (sidebarItems.length === 0) return null;
+  return (
+    <>
+      <SidebarStackSeparator />
+      <SidebarStack>
+        {sidebarItems.map((item) => {
+          if (typeof item === 'object') {
+            if (openedFolder.has(item.id)) {
+              return (
+                <OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
+                  {item.content.map((sId) => {
+                    const space = mx.getRoom(sId);
+                    if (!space) return null;
+                    return (
+                      <SpaceTab
+                        key={space.roomId}
+                        space={space}
+                        selected={space.roomId === selectedSpaceId}
+                        onClick={handleSpaceClick}
+                        folder={item}
+                        onDragging={setDraggingItem}
+                        disabled={
+                          typeof draggingItem === 'object'
+                            ? draggingItem.spaceId === space.roomId
+                            : false
+                        }
+                        onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
+                      />
+                    );
+                  })}
+                </OpenedSpaceFolder>
+              );
+            }
+
+            return (
+              <ClosedSpaceFolder
+                key={item.id}
+                folder={item}
+                selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
+                onOpen={handleFolderToggle}
+                onDragging={setDraggingItem}
+                disabled={
+                  typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
+                }
+              />
+            );
+          }
+
+          const space = mx.getRoom(item);
+          if (!space) return null;
+
+          return (
+            <SpaceTab
+              key={space.roomId}
+              space={space}
+              selected={space.roomId === selectedSpaceId}
+              onClick={handleSpaceClick}
+              onDragging={setDraggingItem}
+              disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
+              onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
+            />
+          );
+        })}
+      </SidebarStack>
+    </>
+  );
+}
diff --git a/src/app/pages/client/sidebar/UserTab.tsx b/src/app/pages/client/sidebar/UserTab.tsx
new file mode 100644 (file)
index 0000000..89c035b
--- /dev/null
@@ -0,0 +1,63 @@
+import React, { useEffect, useState } from 'react';
+import { Text } from 'folds';
+import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
+import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
+import { openSettings } from '../../../../client/action/navigation';
+import { UserAvatar } from '../../../components/user-avatar';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdLocalPart } from '../../../utils/matrix';
+import { nameInitials } from '../../../utils/common';
+
+type UserProfile = {
+  avatar_url?: string;
+  displayname?: string;
+};
+export function UserTab() {
+  const mx = useMatrixClient();
+  const userId = mx.getUserId()!;
+
+  const [profile, setProfile] = useState<UserProfile>({});
+  const displayName = profile.displayname ?? getMxIdLocalPart(userId) ?? userId;
+  const avatarUrl = profile.avatar_url
+    ? mx.mxcUrlToHttp(profile.avatar_url, 96, 96, 'crop') ?? undefined
+    : undefined;
+
+  useEffect(() => {
+    const user = mx.getUser(userId);
+    const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => {
+      setProfile((cp) => ({
+        ...cp,
+        avatar_url: myUser.avatarUrl,
+      }));
+    };
+    const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => {
+      setProfile((cp) => ({
+        ...cp,
+        avatar_url: myUser.displayName,
+      }));
+    };
+    mx.getProfileInfo(userId).then((info) => setProfile(() => ({ ...info })));
+    user?.on(UserEvent.AvatarUrl, onAvatarChange);
+    user?.on(UserEvent.DisplayName, onDisplayNameChange);
+    return () => {
+      user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
+      user?.removeListener(UserEvent.DisplayName, onDisplayNameChange);
+    };
+  }, [mx, userId]);
+
+  return (
+    <SidebarItem>
+      <SidebarItemTooltip tooltip="User Settings">
+        {(triggerRef) => (
+          <SidebarAvatar as="button" ref={triggerRef} onClick={() => openSettings()}>
+            <UserAvatar
+              userId={userId}
+              src={avatarUrl}
+              renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
+            />
+          </SidebarAvatar>
+        )}
+      </SidebarItemTooltip>
+    </SidebarItem>
+  );
+}
diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts
new file mode 100644 (file)
index 0000000..63c5d4b
--- /dev/null
@@ -0,0 +1,6 @@
+export * from './HomeTab';
+export * from './DirectTab';
+export * from './SpaceTabs';
+export * from './InboxTab';
+export * from './ExploreTab';
+export * from './UserTab';
diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx
new file mode 100644 (file)
index 0000000..1105e22
--- /dev/null
@@ -0,0 +1,37 @@
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import { RoomProvider } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+import { useSpace } from '../../../hooks/useSpace';
+import { getAllParents } from '../../../utils/room';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+
+export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
+  const mx = useMatrixClient();
+  const space = useSpace();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const allRooms = useAtomValue(allRoomsAtom);
+
+  const { roomIdOrAlias } = useParams();
+  const roomId = useSelectedRoom();
+  const room = mx.getRoom(roomId);
+
+  if (
+    !room ||
+    room.isSpaceRoom() ||
+    !allRooms.includes(room.roomId) ||
+    !getAllParents(roomToParents, room.roomId).has(space.roomId)
+  ) {
+    return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+  }
+
+  return (
+    <RoomProvider key={room.roomId} value={room}>
+      {children}
+    </RoomProvider>
+  );
+}
diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx
new file mode 100644 (file)
index 0000000..6e7ac57
--- /dev/null
@@ -0,0 +1,52 @@
+import React, { useRef } from 'react';
+import { Box, Icon, Icons, Text, Scroll } from 'folds';
+import { useAtomValue } from 'jotai';
+import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
+import { MessageSearch } from '../../../features/message-search';
+import { useSpace } from '../../../hooks/useSpace';
+import { useRecursiveChildRoomScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+
+export function SpaceSearch() {
+  const mx = useMatrixClient();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const space = useSpace();
+
+  const mDirects = useAtomValue(mDirectAtom);
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const rooms = useSpaceChildren(
+    allRoomsAtom,
+    space.roomId,
+    useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
+  );
+
+  return (
+    <Page>
+      <PageHeader>
+        <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+          <Icon size="400" src={Icons.Search} />
+          <Text size="H3" truncate>
+            Message Search
+          </Text>
+        </Box>
+      </PageHeader>
+      <Box style={{ position: 'relative' }} grow="Yes">
+        <Scroll ref={scrollRef} hideTrack visibility="Hover">
+          <PageContent>
+            <PageContentCenter>
+              <MessageSearch
+                defaultRoomsFilterName={space.name}
+                allowGlobal
+                rooms={rooms}
+                scrollRef={scrollRef}
+              />
+            </PageContentCenter>
+          </PageContent>
+        </Scroll>
+      </Box>
+    </Page>
+  );
+}
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
new file mode 100644 (file)
index 0000000..8eab6b4
--- /dev/null
@@ -0,0 +1,417 @@
+import React, {
+  MouseEventHandler,
+  forwardRef,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { useAtom, useAtomValue } from 'jotai';
+import {
+  Avatar,
+  Box,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Text,
+  config,
+  toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { IJoinRuleEventContent, JoinRule, Room } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mDirectAtom } from '../../../state/mDirectList';
+import {
+  NavCategory,
+  NavCategoryHeader,
+  NavItem,
+  NavItemContent,
+  NavLink,
+} from '../../../components/nav';
+import {
+  getOriginBaseUrl,
+  getSpaceLobbyPath,
+  getSpacePath,
+  getSpaceRoomPath,
+  getSpaceSearchPath,
+  withOriginBaseUrl,
+} from '../../pathUtils';
+import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
+import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
+import {
+  useSpaceLobbySelected,
+  useSpaceSearchSelected,
+} from '../../../hooks/router/useSelectedSpace';
+import { useSpace } from '../../../hooks/useSpace';
+import { VirtualTile } from '../../../components/virtualizer';
+import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
+import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
+import { makeNavCategoryId } from '../../../state/closedNavCategories';
+import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
+import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
+import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
+import { useRoomName } from '../../../hooks/useRoomMeta';
+import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
+import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { markAsRead } from '../../../../client/action/notifications';
+import { useRoomsUnread } from '../../../state/hooks/unread';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
+import { copyToClipboard } from '../../../utils/dom';
+import { useClientConfig } from '../../../hooks/useClientConfig';
+import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { StateEvent } from '../../../../types/matrix/room';
+
+type SpaceMenuProps = {
+  room: Room;
+  requestClose: () => void;
+};
+const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
+  const mx = useMatrixClient();
+  const { hashRouter } = useClientConfig();
+  const roomToParents = useAtomValue(roomToParentsAtom);
+  const powerLevels = usePowerLevels(room);
+  const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+  const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+
+  const allChild = useSpaceChildren(
+    allRoomsAtom,
+    room.roomId,
+    useRecursiveChildScopeFactory(mx, roomToParents)
+  );
+  const unread = useRoomsUnread(allChild, roomToUnreadAtom);
+
+  const handleMarkAsRead = () => {
+    allChild.forEach((childRoomId) => markAsRead(childRoomId));
+    requestClose();
+  };
+
+  const handleCopyLink = () => {
+    const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
+    copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
+    requestClose();
+  };
+
+  const handleInvite = () => {
+    openInviteUser(room.roomId);
+    requestClose();
+  };
+
+  const handleRoomSettings = () => {
+    openSpaceSettings(room.roomId);
+    requestClose();
+  };
+
+  return (
+    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleMarkAsRead}
+          size="300"
+          after={<Icon size="100" src={Icons.CheckTwice} />}
+          radii="300"
+          disabled={!unread}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Mark as Read
+          </Text>
+        </MenuItem>
+      </Box>
+      <Line variant="Surface" size="300" />
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleInvite}
+          variant="Primary"
+          fill="None"
+          size="300"
+          after={<Icon size="100" src={Icons.UserPlus} />}
+          radii="300"
+          disabled={!canInvite}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Invite
+          </Text>
+        </MenuItem>
+        <MenuItem
+          onClick={handleCopyLink}
+          size="300"
+          after={<Icon size="100" src={Icons.Link} />}
+          radii="300"
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Copy Link
+          </Text>
+        </MenuItem>
+        <MenuItem
+          onClick={handleRoomSettings}
+          size="300"
+          after={<Icon size="100" src={Icons.Setting} />}
+          radii="300"
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Space Settings
+          </Text>
+        </MenuItem>
+      </Box>
+      <Line variant="Surface" size="300" />
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <UseStateProvider initial={false}>
+          {(promptLeave, setPromptLeave) => (
+            <>
+              <MenuItem
+                onClick={() => setPromptLeave(true)}
+                variant="Critical"
+                fill="None"
+                size="300"
+                after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+                radii="300"
+                aria-pressed={promptLeave}
+              >
+                <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                  Leave Space
+                </Text>
+              </MenuItem>
+              {promptLeave && (
+                <LeaveSpacePrompt
+                  roomId={room.roomId}
+                  onDone={requestClose}
+                  onCancel={() => setPromptLeave(false)}
+                />
+              )}
+            </>
+          )}
+        </UseStateProvider>
+      </Box>
+    </Menu>
+  );
+});
+
+function SpaceHeader() {
+  const space = useSpace();
+  const spaceName = useRoomName(space);
+  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
+
+  const joinRules = useStateEvent(
+    space,
+    StateEvent.RoomJoinRules
+  )?.getContent<IJoinRuleEventContent>();
+
+  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const cords = evt.currentTarget.getBoundingClientRect();
+    setMenuAnchor((currentState) => {
+      if (currentState) return undefined;
+      return cords;
+    });
+  };
+
+  return (
+    <>
+      <PageNavHeader>
+        <Box alignItems="Center" grow="Yes" gap="300">
+          <Box grow="Yes" alignItems="Center" gap="100">
+            <Text size="H4" truncate>
+              {spaceName}
+            </Text>
+            {joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
+          </Box>
+          <Box>
+            <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
+              <Icon src={Icons.VerticalDots} size="200" />
+            </IconButton>
+          </Box>
+        </Box>
+      </PageNavHeader>
+      {menuAnchor && (
+        <PopOut
+          anchor={menuAnchor}
+          position="Bottom"
+          align="End"
+          offset={6}
+          content={
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                returnFocusOnDeactivate: false,
+                onDeactivate: () => setMenuAnchor(undefined),
+                clickOutsideDeactivates: true,
+                isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+              }}
+            >
+              <SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
+            </FocusTrap>
+          }
+        />
+      )}
+    </>
+  );
+}
+
+export function Space() {
+  const mx = useMatrixClient();
+  const space = useSpace();
+  useNavToActivePathMapper(space.roomId);
+  const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const mDirects = useAtomValue(mDirectAtom);
+  const roomToUnread = useAtomValue(roomToUnreadAtom);
+  const allRooms = useAtomValue(allRoomsAtom);
+  const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
+  const muteChanges = useAtomValue(muteChangesAtom);
+  const mutedRooms = muteChanges.added;
+
+  const selectedRoomId = useSelectedRoom();
+  const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
+  const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
+
+  const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
+
+  const getRoom = useCallback(
+    (rId: string) => {
+      if (allJoinedRooms.has(rId)) {
+        return mx.getRoom(rId) ?? undefined;
+      }
+      return undefined;
+    },
+    [mx, allJoinedRooms]
+  );
+
+  const hierarchy = useSpaceJoinedHierarchy(
+    space.roomId,
+    getRoom,
+    useCallback(
+      (parentId, roomId) => {
+        if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
+          return false;
+        }
+        const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
+        if (showRoom) return false;
+        return true;
+      },
+      [space.roomId, closedCategories, roomToUnread, selectedRoomId]
+    ),
+    useCallback(
+      (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
+      [closedCategories, space.roomId]
+    )
+  );
+
+  const virtualizer = useVirtualizer({
+    count: hierarchy.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 0,
+    overscan: 10,
+  });
+
+  const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
+    closedCategories.has(categoryId)
+  );
+
+  const getToLink = (roomId: string) =>
+    getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
+
+  return (
+    <PageNav>
+      <SpaceHeader />
+      <PageNavContent scrollRef={scrollRef}>
+        <Box direction="Column" gap="300">
+          <NavCategory>
+            <NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
+              <NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
+                <NavItemContent>
+                  <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                    <Avatar size="200" radii="400">
+                      <Icon src={Icons.Flag} size="100" filled={lobbySelected} />
+                    </Avatar>
+                    <Box as="span" grow="Yes">
+                      <Text as="span" size="Inherit" truncate>
+                        Lobby
+                      </Text>
+                    </Box>
+                  </Box>
+                </NavItemContent>
+              </NavLink>
+            </NavItem>
+            <NavItem variant="Background" radii="400" aria-selected={searchSelected}>
+              <NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
+                <NavItemContent>
+                  <Box as="span" grow="Yes" alignItems="Center" gap="200">
+                    <Avatar size="200" radii="400">
+                      <Icon src={Icons.Search} size="100" filled={searchSelected} />
+                    </Avatar>
+                    <Box as="span" grow="Yes">
+                      <Text as="span" size="Inherit" truncate>
+                        Message Search
+                      </Text>
+                    </Box>
+                  </Box>
+                </NavItemContent>
+              </NavLink>
+            </NavItem>
+          </NavCategory>
+          <NavCategory
+            style={{
+              height: virtualizer.getTotalSize(),
+              position: 'relative',
+            }}
+          >
+            {virtualizer.getVirtualItems().map((vItem) => {
+              const { roomId } = hierarchy[vItem.index] ?? {};
+              const room = mx.getRoom(roomId);
+              if (!room) return null;
+
+              if (room.isSpaceRoom()) {
+                const categoryId = makeNavCategoryId(space.roomId, roomId);
+
+                return (
+                  <VirtualTile
+                    virtualItem={vItem}
+                    key={vItem.index}
+                    ref={virtualizer.measureElement}
+                  >
+                    <div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
+                      <NavCategoryHeader>
+                        <RoomNavCategoryButton
+                          data-category-id={categoryId}
+                          onClick={handleCategoryClick}
+                          closed={closedCategories.has(categoryId)}
+                        >
+                          {roomId === space.roomId ? 'Rooms' : room?.name}
+                        </RoomNavCategoryButton>
+                      </NavCategoryHeader>
+                    </div>
+                  </VirtualTile>
+                );
+              }
+
+              return (
+                <VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
+                  <RoomNavItem
+                    room={room}
+                    selected={selectedRoomId === roomId}
+                    showAvatar={mDirects.has(roomId)}
+                    direct={mDirects.has(roomId)}
+                    linkPath={getToLink(roomId)}
+                    muted={mutedRooms.includes(roomId)}
+                  />
+                </VirtualTile>
+              );
+            })}
+          </NavCategory>
+        </Box>
+      </PageNavContent>
+    </PageNav>
+  );
+}
diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx
new file mode 100644 (file)
index 0000000..530fc3c
--- /dev/null
@@ -0,0 +1,30 @@
+import React, { ReactNode } from 'react';
+import { useParams } from 'react-router-dom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useSpaces } from '../../../state/hooks/roomList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
+import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
+import { SpaceProvider } from '../../../hooks/useSpace';
+import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
+
+type RouteSpaceProviderProps = {
+  children: ReactNode;
+};
+export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
+  const mx = useMatrixClient();
+  const joinedSpaces = useSpaces(mx, allRoomsAtom);
+  const { spaceIdOrAlias } = useParams();
+
+  const selectedSpaceId = useSelectedSpace();
+  const space = mx.getRoom(selectedSpaceId);
+
+  if (!space || !joinedSpaces.includes(space.roomId)) {
+    return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} />;
+  }
+
+  return (
+    <SpaceProvider key={space.roomId} value={space}>
+      {children}
+    </SpaceProvider>
+  );
+}
diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts
new file mode 100644 (file)
index 0000000..0e32d93
--- /dev/null
@@ -0,0 +1,4 @@
+export * from './SpaceProvider';
+export * from './Space';
+export * from './Search';
+export * from './RoomProvider';
index db39ce391642d6ac94957241c0126c21b4d49c9e..cbd453ae30e55e18a220b92e5d078f6db02bef17 100644 (file)
@@ -1,5 +1,32 @@
-import { generatePath } from 'react-router-dom';
-import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
+import { generatePath, Path } from 'react-router-dom';
+import {
+  DIRECT_CREATE_PATH,
+  DIRECT_PATH,
+  DIRECT_ROOM_PATH,
+  EXPLORE_FEATURED_PATH,
+  EXPLORE_PATH,
+  EXPLORE_SERVER_PATH,
+  HOME_CREATE_PATH,
+  HOME_JOIN_PATH,
+  HOME_PATH,
+  HOME_ROOM_PATH,
+  HOME_SEARCH_PATH,
+  LOGIN_PATH,
+  INBOX_INVITES_PATH,
+  INBOX_NOTIFICATIONS_PATH,
+  INBOX_PATH,
+  REGISTER_PATH,
+  RESET_PASSWORD_PATH,
+  ROOT_PATH,
+  SPACE_LOBBY_PATH,
+  SPACE_PATH,
+  SPACE_ROOM_PATH,
+  SPACE_SEARCH_PATH,
+} from './paths';
+import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
+import { HashRouterConfig } from '../hooks/useClientConfig';
+
+export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash;
 
 export const withSearchParam = <T extends Record<string, string>>(
   path: string,
@@ -9,6 +36,38 @@ export const withSearchParam = <T extends Record<string, string>>(
 
   return `${path}?${params}`;
 };
+export const encodeSearchParamValueArray = (ids: string[]): string => ids.join(',');
+export const decodeSearchParamValueArray = (idsParam: string): string[] => idsParam.split(',');
+
+export const getOriginBaseUrl = (hashRouterConfig?: HashRouterConfig): string => {
+  const baseUrl = `${trimTrailingSlash(window.location.origin)}${import.meta.env.BASE_URL}`;
+
+  if (hashRouterConfig?.enabled) {
+    return `${trimTrailingSlash(baseUrl)}/#${hashRouterConfig.basename}`;
+  }
+
+  return baseUrl;
+};
+
+export const withOriginBaseUrl = (baseUrl: string, path: string): string =>
+  `${trimTrailingSlash(baseUrl)}${path}`;
+
+export const getAppPathFromHref = (baseUrl: string, href: string): string => {
+  // if hash is in baseUrl means we are using hashRouter
+  const baseHashIndex = baseUrl.indexOf('#');
+  if (baseHashIndex > -1) {
+    const hrefHashIndex = href.indexOf('#');
+    // href may/not have "/" around "#"
+    // we need to take care of this when extracting app path
+    const trimmedBaseUrl = trimLeadingSlash(baseUrl.slice(baseHashIndex + 1));
+    const trimmedHref = trimLeadingSlash(href.slice(hrefHashIndex + 1));
+
+    const appPath = trimmedHref.slice(trimmedBaseUrl.length);
+    return `/${trimLeadingSlash(appPath)}`;
+  }
+
+  return href.slice(trimTrailingSlash(baseUrl).length);
+};
 
 export const getRootPath = (): string => ROOT_PATH;
 
@@ -26,3 +85,73 @@ export const getResetPasswordPath = (server?: string): string => {
   const params = server ? { server: encodeURIComponent(server) } : undefined;
   return generatePath(RESET_PASSWORD_PATH, params);
 };
+
+export const getHomePath = (): string => HOME_PATH;
+export const getHomeCreatePath = (): string => HOME_CREATE_PATH;
+export const getHomeJoinPath = (): string => HOME_JOIN_PATH;
+export const getHomeSearchPath = (): string => HOME_SEARCH_PATH;
+export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
+  const params = {
+    roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+    eventId: eventId ? encodeURIComponent(eventId) : null,
+  };
+
+  return generatePath(HOME_ROOM_PATH, params);
+};
+
+export const getDirectPath = (): string => DIRECT_PATH;
+export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
+export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
+  const params = {
+    roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+    eventId: eventId ? encodeURIComponent(eventId) : null,
+  };
+
+  return generatePath(DIRECT_ROOM_PATH, params);
+};
+
+export const getSpacePath = (spaceIdOrAlias: string): string => {
+  const params = {
+    spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+  };
+
+  return generatePath(SPACE_PATH, params);
+};
+export const getSpaceLobbyPath = (spaceIdOrAlias: string): string => {
+  const params = {
+    spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+  };
+  return generatePath(SPACE_LOBBY_PATH, params);
+};
+export const getSpaceSearchPath = (spaceIdOrAlias: string): string => {
+  const params = {
+    spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+  };
+  return generatePath(SPACE_SEARCH_PATH, params);
+};
+export const getSpaceRoomPath = (
+  spaceIdOrAlias: string,
+  roomIdOrAlias: string,
+  eventId?: string
+): string => {
+  const params = {
+    spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
+    roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
+    eventId: eventId ? encodeURIComponent(eventId) : null,
+  };
+
+  return generatePath(SPACE_ROOM_PATH, params);
+};
+
+export const getExplorePath = (): string => EXPLORE_PATH;
+export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
+export const getExploreServerPath = (server: string): string => {
+  const params = {
+    server: encodeURIComponent(server),
+  };
+  return generatePath(EXPLORE_SERVER_PATH, params);
+};
+
+export const getInboxPath = (): string => INBOX_PATH;
+export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
+export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
index cd6226414b8194f95db339e9e055bf884843bb2d..fd3266e936a12e8157df6e4ee369881a3b56e340 100644 (file)
@@ -14,4 +14,69 @@ export type RegisterPathSearchParams = {
 };
 export const REGISTER_PATH = '/register/:server?/';
 
+export type ResetPasswordPathSearchParams = {
+  email?: string;
+};
 export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
+
+export const _CREATE_PATH = 'create/';
+export const _JOIN_PATH = 'join/';
+export const _LOBBY_PATH = 'lobby/';
+/**
+ * array of rooms and senders mxId assigned
+ * to search param as string should be "," separated
+ * Like: url?rooms=!one:server,!two:server
+ */
+export type _SearchPathSearchParams = {
+  global?: string;
+  term?: string;
+  order?: string;
+  rooms?: string;
+  senders?: string;
+};
+export const _SEARCH_PATH = 'search/';
+export const _ROOM_PATH = ':roomIdOrAlias/:eventId?/';
+
+export const HOME_PATH = '/home/';
+export const HOME_CREATE_PATH = `/home/${_CREATE_PATH}`;
+export const HOME_JOIN_PATH = `/home/${_JOIN_PATH}`;
+export const HOME_SEARCH_PATH = `/home/${_SEARCH_PATH}`;
+export const HOME_ROOM_PATH = `/home/${_ROOM_PATH}`;
+
+export const DIRECT_PATH = '/direct/';
+export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
+export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
+
+export const SPACE_PATH = '/:spaceIdOrAlias/';
+export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${_LOBBY_PATH}`;
+export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${_SEARCH_PATH}`;
+export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${_ROOM_PATH}`;
+
+export const _FEATURED_PATH = 'featured/';
+export const _SERVER_PATH = ':server/';
+export const EXPLORE_PATH = '/explore/';
+export const EXPLORE_FEATURED_PATH = `/explore/${_FEATURED_PATH}`;
+
+export type ExploreServerPathSearchParams = {
+  limit?: string;
+  since?: string;
+  term?: string;
+  type?: string;
+  instance?: string;
+};
+export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
+
+export const _NOTIFICATIONS_PATH = 'notifications/';
+export const _INVITES_PATH = 'invites/';
+export const INBOX_PATH = '/inbox/';
+export type InboxNotificationsPathSearchParams = {
+  only?: string;
+};
+export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
+export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
+
+export const USER_SETTINGS_PATH = '/user-settings/';
+
+export const SPACE_SETTINGS_PATH = '/space-settings/';
+
+export const ROOM_SETTINGS_PATH = '/room-settings/';
diff --git a/src/app/plugins/millify.ts b/src/app/plugins/millify.ts
new file mode 100644 (file)
index 0000000..d8608de
--- /dev/null
@@ -0,0 +1,9 @@
+import millifyPlugin from 'millify';
+import { MillifyOptions } from 'millify/dist/options';
+
+export const millify = (count: number, options?: Partial<MillifyOptions>): string =>
+  millifyPlugin(count, {
+    precision: 1,
+    locales: [],
+    ...options,
+  });
index 5412ea4580d2b7bb122b95b2da44660373c4022b..1b2991f4c9cc4059cd4c6bbb90a62520ce8007d8 100644 (file)
@@ -2,12 +2,15 @@ import { useCallback } from 'react';
 import type * as PdfJsDist from 'pdfjs-dist';
 import type { GetViewportParameters } from 'pdfjs-dist/types/src/display/api';
 import { useAsyncCallback } from '../hooks/useAsyncCallback';
+import { trimTrailingSlash } from '../utils/common';
 
 export const usePdfJSLoader = () =>
   useAsyncCallback(
     useCallback(async () => {
       const pdf = await import('pdfjs-dist');
-      pdf.GlobalWorkerOptions.workerSrc = 'pdf.worker.min.js';
+      pdf.GlobalWorkerOptions.workerSrc = `${trimTrailingSlash(
+        import.meta.env.BASE_URL
+      )}/pdf.worker.min.js`;
       return pdf;
     }, [])
   );
index ee41687decf2fdfcb4571c9af53adf70630aa9f0..a8086687a48a68f31b338bd65995f4e573f65338 100644 (file)
@@ -14,7 +14,7 @@ import { Opts as LinkifyOpts } from 'linkifyjs';
 import Linkify from 'linkify-react';
 import { ErrorBoundary } from 'react-error-boundary';
 import * as css from '../styles/CustomHtml.css';
-import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
+import { getMxIdLocalPart, getCanonicalAliasRoomId } from '../utils/matrix';
 import { getMemberDisplayName } from '../utils/room';
 import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
 import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
@@ -35,12 +35,12 @@ export const LINKIFY_OPTS: LinkifyOpts = {
   ignoreTags: ['span'],
 };
 
-const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
+export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
   findAndReplace(
     text,
     EMOJI_REG_G,
     (match, pushIndex) => (
-      <span key={pushIndex} className={css.EmoticonBase}>
+      <span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
         <span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
           {match[0]}
         </span>
@@ -49,19 +49,36 @@ const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
     (txt) => txt
   );
 
-export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
-  const emojifyJSX = textToEmojifyJSX(text);
-
-  if (linkify) {
-    return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
-  }
-  return emojifyJSX;
+export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => {
+  const pattern = highlights.join('|');
+  if (!pattern) return undefined;
+  return new RegExp(pattern, 'gi');
 };
 
+export const highlightText = (
+  regex: RegExp,
+  data: (string | JSX.Element)[]
+): (string | JSX.Element)[] =>
+  data.flatMap((text) => {
+    if (typeof text !== 'string') return text;
+
+    return findAndReplace(
+      text,
+      regex,
+      (match, pushIndex) => (
+        <span key={`highlight-${pushIndex}`} className={css.highlightText}>
+          {match[0]}
+        </span>
+      ),
+      (txt) => txt
+    );
+  });
+
 export const getReactCustomHtmlParser = (
   mx: MatrixClient,
   room: Room,
   params: {
+    highlightRegex?: RegExp;
     handleSpoilerClick?: ReactEventHandler<HTMLElement>;
     handleMentionClick?: ReactEventHandler<HTMLElement>;
   }
@@ -207,10 +224,9 @@ export const getReactCustomHtmlParser = (
             const mentionId = mention[1];
             const mentionPrefix = mention[2];
             if (mentionPrefix === '#' || mentionPrefix === '!') {
-              const mentionRoom =
-                mentionPrefix === '#'
-                  ? getRoomWithCanonicalAlias(mx, mentionId)
-                  : mx.getRoom(mentionId);
+              const mentionRoom = mx.getRoom(
+                mentionPrefix === '#' ? getCanonicalAliasRoomId(mx, mentionId) : mentionId
+              );
 
               return (
                 <span
@@ -292,7 +308,17 @@ export const getReactCustomHtmlParser = (
         const linkify =
           !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
           !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
-        return emojifyAndLinkify(domNode.data, linkify);
+
+        let jsx = scaleSystemEmoji(domNode.data);
+
+        if (params.highlightRegex) {
+          jsx = highlightText(params.highlightRegex, jsx);
+        }
+
+        if (linkify) {
+          return <Linkify options={LINKIFY_OPTS}>{jsx}</Linkify>;
+        }
+        return jsx;
       }
       return undefined;
     },
diff --git a/src/app/state/closedLobbyCategories.ts b/src/app/state/closedLobbyCategories.ts
new file mode 100644 (file)
index 0000000..40ecd16
--- /dev/null
@@ -0,0 +1,68 @@
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import {
+  atomWithLocalStorage,
+  getLocalStorageItem,
+  setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const CLOSED_LOBBY_CATEGORY = 'closedLobbyCategories';
+
+type ClosedLobbyCategoriesAction =
+  | {
+      type: 'PUT';
+      categoryId: string;
+    }
+  | {
+      type: 'DELETE';
+      categoryId: string;
+    };
+
+export type ClosedLobbyCategoriesAtom = WritableAtom<
+  Set<string>,
+  [ClosedLobbyCategoriesAction],
+  undefined
+>;
+
+export const makeClosedLobbyCategoriesAtom = (userId: string): ClosedLobbyCategoriesAtom => {
+  const storeKey = `${CLOSED_LOBBY_CATEGORY}${userId}`;
+
+  const baseClosedLobbyCategoriesAtom = atomWithLocalStorage<Set<string>>(
+    storeKey,
+    (key) => {
+      const arrayValue = getLocalStorageItem<string[]>(key, []);
+      return new Set(arrayValue);
+    },
+    (key, value) => {
+      const arrayValue = Array.from(value);
+      setLocalStorageItem(key, arrayValue);
+    }
+  );
+
+  const closedLobbyCategoriesAtom = atom<Set<string>, [ClosedLobbyCategoriesAction], undefined>(
+    (get) => get(baseClosedLobbyCategoriesAtom),
+    (get, set, action) => {
+      if (action.type === 'DELETE') {
+        set(
+          baseClosedLobbyCategoriesAtom,
+          produce(get(baseClosedLobbyCategoriesAtom), (draft) => {
+            draft.delete(action.categoryId);
+          })
+        );
+        return;
+      }
+      if (action.type === 'PUT') {
+        set(
+          baseClosedLobbyCategoriesAtom,
+          produce(get(baseClosedLobbyCategoriesAtom), (draft) => {
+            draft.add(action.categoryId);
+          })
+        );
+      }
+    }
+  );
+
+  return closedLobbyCategoriesAtom;
+};
+
+export const makeLobbyCategoryId = (...args: string[]): string => args.join('|');
diff --git a/src/app/state/closedNavCategories.ts b/src/app/state/closedNavCategories.ts
new file mode 100644 (file)
index 0000000..ea61cb2
--- /dev/null
@@ -0,0 +1,68 @@
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import {
+  atomWithLocalStorage,
+  getLocalStorageItem,
+  setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const CLOSED_NAV_CATEGORY = 'closedNavCategories';
+
+type ClosedNavCategoriesAction =
+  | {
+      type: 'PUT';
+      categoryId: string;
+    }
+  | {
+      type: 'DELETE';
+      categoryId: string;
+    };
+
+export type ClosedNavCategoriesAtom = WritableAtom<
+  Set<string>,
+  [ClosedNavCategoriesAction],
+  undefined
+>;
+
+export const makeClosedNavCategoriesAtom = (userId: string): ClosedNavCategoriesAtom => {
+  const storeKey = `${CLOSED_NAV_CATEGORY}${userId}`;
+
+  const baseClosedNavCategoriesAtom = atomWithLocalStorage<Set<string>>(
+    storeKey,
+    (key) => {
+      const arrayValue = getLocalStorageItem<string[]>(key, []);
+      return new Set(arrayValue);
+    },
+    (key, value) => {
+      const arrayValue = Array.from(value);
+      setLocalStorageItem(key, arrayValue);
+    }
+  );
+
+  const closedNavCategoriesAtom = atom<Set<string>, [ClosedNavCategoriesAction], undefined>(
+    (get) => get(baseClosedNavCategoriesAtom),
+    (get, set, action) => {
+      if (action.type === 'DELETE') {
+        set(
+          baseClosedNavCategoriesAtom,
+          produce(get(baseClosedNavCategoriesAtom), (draft) => {
+            draft.delete(action.categoryId);
+          })
+        );
+        return;
+      }
+      if (action.type === 'PUT') {
+        set(
+          baseClosedNavCategoriesAtom,
+          produce(get(baseClosedNavCategoriesAtom), (draft) => {
+            draft.add(action.categoryId);
+          })
+        );
+      }
+    }
+  );
+
+  return closedNavCategoriesAtom;
+};
+
+export const makeNavCategoryId = (...args: string[]): string => args.join('|');
diff --git a/src/app/state/hooks/closedLobbyCategories.ts b/src/app/state/hooks/closedLobbyCategories.ts
new file mode 100644 (file)
index 0000000..1545254
--- /dev/null
@@ -0,0 +1,15 @@
+import { createContext, useContext } from 'react';
+import { ClosedLobbyCategoriesAtom } from '../closedLobbyCategories';
+
+const ClosedLobbyCategoriesAtomContext = createContext<ClosedLobbyCategoriesAtom | null>(null);
+export const ClosedLobbyCategoriesProvider = ClosedLobbyCategoriesAtomContext.Provider;
+
+export const useClosedLobbyCategoriesAtom = (): ClosedLobbyCategoriesAtom => {
+  const anAtom = useContext(ClosedLobbyCategoriesAtomContext);
+
+  if (!anAtom) {
+    throw new Error('ClosedLobbyCategoriesAtom is not provided!');
+  }
+
+  return anAtom;
+};
diff --git a/src/app/state/hooks/closedNavCategories.ts b/src/app/state/hooks/closedNavCategories.ts
new file mode 100644 (file)
index 0000000..ed34c1e
--- /dev/null
@@ -0,0 +1,15 @@
+import { createContext, useContext } from 'react';
+import { ClosedNavCategoriesAtom } from '../closedNavCategories';
+
+const ClosedNavCategoriesAtomContext = createContext<ClosedNavCategoriesAtom | null>(null);
+export const ClosedNavCategoriesProvider = ClosedNavCategoriesAtomContext.Provider;
+
+export const useClosedNavCategoriesAtom = (): ClosedNavCategoriesAtom => {
+  const anAtom = useContext(ClosedNavCategoriesAtomContext);
+
+  if (!anAtom) {
+    throw new Error('ClosedNavCategoriesAtom is not provided!');
+  }
+
+  return anAtom;
+};
index ffe44445d9e9f9873d40840ff28630881ea4bd86..5e003fb19b66a48246e148043a41698061373301 100644 (file)
@@ -3,9 +3,8 @@ import { selectAtom } from 'jotai/utils';
 import { MatrixClient } from 'matrix-js-sdk';
 import { useCallback } from 'react';
 import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
-import { compareRoomsEqual } from '../utils';
-import { mDirectAtom } from '../mDirectList';
-import { allInvitesAtom } from '../inviteList';
+import { compareRoomsEqual } from '../room-list/utils';
+import { allInvitesAtom } from '../room-list/inviteList';
 
 export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
   const selector = useCallback(
@@ -18,9 +17,8 @@ export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvites
 export const useRoomInvites = (
   mx: MatrixClient,
   invitesAtom: typeof allInvitesAtom,
-  directAtom: typeof mDirectAtom
+  mDirects: Set<string>
 ) => {
-  const mDirects = useAtomValue(directAtom);
   const selector = useCallback(
     (rooms: string[]) =>
       rooms.filter(
@@ -36,9 +34,8 @@ export const useRoomInvites = (
 export const useDirectInvites = (
   mx: MatrixClient,
   invitesAtom: typeof allInvitesAtom,
-  directAtom: typeof mDirectAtom
+  mDirects: Set<string>
 ) => {
-  const mDirects = useAtomValue(directAtom);
   const selector = useCallback(
     (rooms: string[]) =>
       rooms.filter(
diff --git a/src/app/state/hooks/navToActivePath.ts b/src/app/state/hooks/navToActivePath.ts
new file mode 100644 (file)
index 0000000..5f8798f
--- /dev/null
@@ -0,0 +1,15 @@
+import { createContext, useContext } from 'react';
+import { NavToActivePathAtom } from '../navToActivePath';
+
+const NavToActivePathAtomContext = createContext<NavToActivePathAtom | null>(null);
+export const NavToActivePathProvider = NavToActivePathAtomContext.Provider;
+
+export const useNavToActivePathAtom = (): NavToActivePathAtom => {
+  const anAtom = useContext(NavToActivePathAtomContext);
+
+  if (!anAtom) {
+    throw new Error('NavToActivePathAtom is not provided!');
+  }
+
+  return anAtom;
+};
diff --git a/src/app/state/hooks/openedSidebarFolder.ts b/src/app/state/hooks/openedSidebarFolder.ts
new file mode 100644 (file)
index 0000000..3a16533
--- /dev/null
@@ -0,0 +1,15 @@
+import { createContext, useContext } from 'react';
+import { OpenedSidebarFolderAtom } from '../openedSidebarFolder';
+
+const OpenedSidebarFolderAtomContext = createContext<OpenedSidebarFolderAtom | null>(null);
+export const OpenedSidebarFolderProvider = OpenedSidebarFolderAtomContext.Provider;
+
+export const useOpenedSidebarFolderAtom = (): OpenedSidebarFolderAtom => {
+  const anAtom = useContext(OpenedSidebarFolderAtomContext);
+
+  if (!anAtom) {
+    throw new Error('OpenedSidebarFolderAtom is not provided!');
+  }
+
+  return anAtom;
+};
index c0a7bfb88ef10c2967dc5445816cc4fd45c776b0..d64ecd5b1bcefaad356e699eaaaa53af399f4e0e 100644 (file)
-import { useAtomValue } from 'jotai';
+import { Atom, useAtomValue } from 'jotai';
 import { selectAtom } from 'jotai/utils';
 import { MatrixClient } from 'matrix-js-sdk';
-import { useCallback } from 'react';
-import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
-import { compareRoomsEqual } from '../utils';
-import { mDirectAtom } from '../mDirectList';
-import { allRoomsAtom } from '../roomList';
-
-export const useSpaces = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
-  const selector = useCallback(
-    (rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
-    [mx]
+import { useCallback, useMemo } from 'react';
+import { getAllParents, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
+import { compareRoomsEqual } from '../room-list/utils';
+import { RoomToParents } from '../../../types/matrix/room';
+
+export type RoomsAtom = Atom<string[]>;
+export type RoomSelector = (roomId: string) => boolean | undefined;
+
+export const selectedRoomsAtom = (
+  roomsAtom: RoomsAtom,
+  selector: (roomId: string) => boolean | undefined
+) => selectAtom(roomsAtom, (rooms) => rooms.filter(selector), compareRoomsEqual);
+
+export const useSelectedRooms = (roomsAtom: RoomsAtom, selector: RoomSelector) => {
+  const anAtom = useMemo(() => selectedRoomsAtom(roomsAtom, selector), [roomsAtom, selector]);
+
+  return useAtomValue(anAtom);
+};
+
+export type SpaceChildSelectorFactory = (parentId: string) => RoomSelector;
+
+export const useRecursiveChildScopeFactory = (
+  mx: MatrixClient,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isRoom(mx.getRoom(roomId)) &&
+      roomToParents.has(roomId) &&
+      getAllParents(roomToParents, roomId).has(parentId),
+    [mx, roomToParents]
+  );
+
+export const useChildSpaceScopeFactory = (
+  mx: MatrixClient,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isSpace(mx.getRoom(roomId)) && roomToParents.get(roomId)?.has(parentId),
+    [mx, roomToParents]
+  );
+
+export const useRecursiveChildSpaceScopeFactory = (
+  mx: MatrixClient,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isSpace(mx.getRoom(roomId)) &&
+      roomToParents.has(roomId) &&
+      getAllParents(roomToParents, roomId).has(parentId),
+    [mx, roomToParents]
+  );
+
+export const useChildRoomScopeFactory = (
+  mx: MatrixClient,
+  mDirects: Set<string>,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isRoom(mx.getRoom(roomId)) &&
+      !mDirects.has(roomId) &&
+      roomToParents.get(roomId)?.has(parentId),
+    [mx, mDirects, roomToParents]
+  );
+
+export const useRecursiveChildRoomScopeFactory = (
+  mx: MatrixClient,
+  mDirects: Set<string>,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isRoom(mx.getRoom(roomId)) &&
+      !mDirects.has(roomId) &&
+      roomToParents.has(roomId) &&
+      getAllParents(roomToParents, roomId).has(parentId),
+    [mx, mDirects, roomToParents]
+  );
+
+export const useChildDirectScopeFactory = (
+  mx: MatrixClient,
+  mDirects: Set<string>,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isRoom(mx.getRoom(roomId)) &&
+      mDirects.has(roomId) &&
+      roomToParents.get(roomId)?.has(parentId),
+    [mx, mDirects, roomToParents]
   );
-  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+
+export const useRecursiveChildDirectScopeFactory = (
+  mx: MatrixClient,
+  mDirects: Set<string>,
+  roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+  useCallback(
+    (parentId: string) => (roomId) =>
+      isRoom(mx.getRoom(roomId)) &&
+      mDirects.has(roomId) &&
+      roomToParents.has(roomId) &&
+      getAllParents(roomToParents, roomId).has(parentId),
+    [mx, mDirects, roomToParents]
+  );
+
+export const useSpaceChildren = (
+  roomsAtom: RoomsAtom,
+  spaceId: string,
+  selectorFactory: SpaceChildSelectorFactory
+) => {
+  const recursiveChildRoomSelector = useMemo(
+    () => selectorFactory(spaceId),
+    [selectorFactory, spaceId]
+  );
+  return useSelectedRooms(roomsAtom, recursiveChildRoomSelector);
+};
+
+export const useSpaces = (mx: MatrixClient, roomsAtom: RoomsAtom) => {
+  const selector: RoomSelector = useCallback((roomId) => isSpace(mx.getRoom(roomId)), [mx]);
+  return useSelectedRooms(roomsAtom, selector);
 };
 
-export const useRooms = (
+export const useOrphanSpaces = (
   mx: MatrixClient,
-  roomsAtom: typeof allRoomsAtom,
-  directAtom: typeof mDirectAtom
+  roomsAtom: RoomsAtom,
+  roomToParents: RoomToParents
 ) => {
-  const mDirects = useAtomValue(directAtom);
-  const selector = useCallback(
-    (rooms: string[]) =>
-      rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
+  const selector: RoomSelector = useCallback(
+    (roomId) => isSpace(mx.getRoom(roomId)) && !roomToParents.has(roomId),
+    [mx, roomToParents]
+  );
+  return useSelectedRooms(roomsAtom, selector);
+};
+
+export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<string>) => {
+  const selector: RoomSelector = useCallback(
+    (roomId: string) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId),
     [mx, mDirects]
   );
-  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+  return useSelectedRooms(roomsAtom, selector);
 };
 
-export const useDirects = (
+export const useOrphanRooms = (
   mx: MatrixClient,
-  roomsAtom: typeof allRoomsAtom,
-  directAtom: typeof mDirectAtom
+  roomsAtom: RoomsAtom,
+  mDirects: Set<string>,
+  roomToParents: RoomToParents
 ) => {
-  const mDirects = useAtomValue(directAtom);
-  const selector = useCallback(
-    (rooms: string[]) =>
-      rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
+  const selector: RoomSelector = useCallback(
+    (roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId) && !roomToParents.has(roomId),
+    [mx, mDirects, roomToParents]
+  );
+  return useSelectedRooms(roomsAtom, selector);
+};
+
+export const useDirects = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<string>) => {
+  const selector: RoomSelector = useCallback(
+    (roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId),
     [mx, mDirects]
   );
-  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+  return useSelectedRooms(roomsAtom, selector);
 };
 
-export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
-  const selector = useCallback(
-    (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
+export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: RoomsAtom) => {
+  const selector: RoomSelector = useCallback(
+    (roomId) => isUnsupportedRoom(mx.getRoom(roomId)),
     [mx]
   );
-  return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
+  return useSelectedRooms(roomsAtom, selector);
 };
diff --git a/src/app/state/hooks/unread.ts b/src/app/state/hooks/unread.ts
new file mode 100644 (file)
index 0000000..62386d0
--- /dev/null
@@ -0,0 +1,46 @@
+import { useCallback } from 'react';
+import { useAtomValue } from 'jotai';
+import { selectAtom } from 'jotai/utils';
+import { RoomToUnread, Unread } from '../../../types/matrix/room';
+import { roomToUnreadAtom, unreadEqual } from '../room/roomToUnread';
+
+const compareUnreadEqual = (u1?: Unread, u2?: Unread): boolean => {
+  if (!u1 || !u2) return false;
+  return unreadEqual(u1, u2);
+};
+
+const getRoomsUnread = (rooms: string[], roomToUnread: RoomToUnread): Unread | undefined => {
+  const unread = rooms.reduce<Unread | undefined>((u, roomId) => {
+    const roomUnread = roomToUnread.get(roomId);
+    if (!roomUnread) return u;
+    const newUnread: Unread = u ?? {
+      total: 0,
+      highlight: 0,
+      from: new Set(),
+    };
+    newUnread.total += roomUnread.total;
+    newUnread.highlight += roomUnread.highlight;
+    newUnread.from?.add(roomId);
+    return newUnread;
+  }, undefined);
+  return unread;
+};
+
+export const useRoomsUnread = (
+  rooms: string[],
+  roomToUnreadAtm: typeof roomToUnreadAtom
+): Unread | undefined => {
+  const selector = useCallback(
+    (roomToUnread: RoomToUnread) => getRoomsUnread(rooms, roomToUnread),
+    [rooms]
+  );
+  return useAtomValue(selectAtom(roomToUnreadAtm, selector, compareUnreadEqual));
+};
+
+export const useRoomUnread = (
+  roomId: string,
+  roomToUnreadAtm: typeof roomToUnreadAtom
+): Unread | undefined => {
+  const selector = useCallback((roomToUnread: RoomToUnread) => roomToUnread.get(roomId), [roomId]);
+  return useAtomValue(selectAtom(roomToUnreadAtm, selector, compareUnreadEqual));
+};
index 6dc2a3de9a95ea5d8c3a6132ea42f47d4dffc0ce..136c833f0972016b5d595bbe6bfbd50fdb6c0c67 100644 (file)
@@ -1,10 +1,11 @@
 import { MatrixClient } from 'matrix-js-sdk';
-import { allInvitesAtom, useBindAllInvitesAtom } from '../inviteList';
-import { allRoomsAtom, useBindAllRoomsAtom } from '../roomList';
+import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
+import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
 import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
-import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../mutedRoomList';
-import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../roomToUnread';
-import { roomToParentsAtom, useBindRoomToParentsAtom } from '../roomToParents';
+import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../room-list/mutedRoomList';
+import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
+import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
+import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
 
 export const useBindAtoms = (mx: MatrixClient) => {
   useBindMDirectAtom(mx, mDirectAtom);
@@ -13,4 +14,6 @@ export const useBindAtoms = (mx: MatrixClient) => {
   useBindRoomToParentsAtom(mx, roomToParentsAtom);
   useBindMutedRoomsAtom(mx, mutedRoomsAtom);
   useBindRoomToUnreadAtom(mx, roomToUnreadAtom, muteChangesAtom);
+
+  useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
 };
diff --git a/src/app/state/inviteList.ts b/src/app/state/inviteList.ts
deleted file mode 100644 (file)
index a6dc796..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-import { atom, WritableAtom } from 'jotai';
-import { MatrixClient } from 'matrix-js-sdk';
-import { useMemo } from 'react';
-import { Membership } from '../../types/matrix/room';
-import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
-
-const baseRoomsAtom = atom<string[]>([]);
-export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
-  (get) => get(baseRoomsAtom),
-  (get, set, action) => {
-    if (action.type === 'INITIALIZE') {
-      set(baseRoomsAtom, action.rooms);
-      return;
-    }
-    set(baseRoomsAtom, (ids) => {
-      const newIds = ids.filter((id) => id !== action.roomId);
-      if (action.type === 'PUT') newIds.push(action.roomId);
-      return newIds;
-    });
-  }
-);
-
-export const useBindAllInvitesAtom = (
-  mx: MatrixClient,
-  allRooms: WritableAtom<string[], [RoomsAction], undefined>
-) => {
-  useBindRoomsWithMembershipsAtom(
-    mx,
-    allRooms,
-    useMemo(() => [Membership.Invite], [])
-  );
-};
index 1fa8311f4a5a37c958affafb4c7985216b2169c8..95165255f9351569b7f1992706a04fb47f07397e 100644 (file)
@@ -30,10 +30,12 @@ export const useBindMDirectAtom = (mx: MatrixClient, mDirect: typeof mDirectAtom
     }
 
     const handleAccountData = (event: MatrixEvent) => {
-      setMDirect({
-        type: 'UPDATE',
-        rooms: getMDirects(event),
-      });
+      if (event.getType() === AccountDataEvent.Direct) {
+        setMDirect({
+          type: 'UPDATE',
+          rooms: getMDirects(event),
+        });
+      }
     };
 
     mx.on(ClientEvent.AccountData, handleAccountData);
diff --git a/src/app/state/mutedRoomList.ts b/src/app/state/mutedRoomList.ts
deleted file mode 100644 (file)
index f818450..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-import { atom, useSetAtom } from 'jotai';
-import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
-import { useEffect } from 'react';
-import { MuteChanges } from '../../types/matrix/room';
-import { findMutedRule, isMutedRule } from '../utils/room';
-
-export type MutedRoomsUpdate =
-  | {
-      type: 'INITIALIZE';
-      addRooms: string[];
-    }
-  | {
-      type: 'UPDATE';
-      addRooms: string[];
-      removeRooms: string[];
-    };
-
-export const muteChangesAtom = atom<MuteChanges>({
-  added: [],
-  removed: [],
-});
-
-const baseMutedRoomsAtom = atom(new Set<string>());
-export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
-  (get) => get(baseMutedRoomsAtom),
-  (get, set, action) => {
-    const mutedRooms = new Set([...get(mutedRoomsAtom)]);
-    if (action.type === 'INITIALIZE') {
-      set(baseMutedRoomsAtom, new Set([...action.addRooms]));
-      set(muteChangesAtom, {
-        added: [...action.addRooms],
-        removed: [],
-      });
-      return;
-    }
-    if (action.type === 'UPDATE') {
-      action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
-      action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
-      set(baseMutedRoomsAtom, mutedRooms);
-      set(muteChangesAtom, {
-        added: [...action.addRooms],
-        removed: [...action.removeRooms],
-      });
-    }
-  }
-);
-
-export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
-  const setMuted = useSetAtom(mutedAtom);
-
-  useEffect(() => {
-    const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
-      ?.global?.override;
-    if (overrideRules) {
-      const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
-        if (isMutedRule(rule)) rooms.push(rule.rule_id);
-        return rooms;
-      }, []);
-      setMuted({
-        type: 'INITIALIZE',
-        addRooms: mutedRooms,
-      });
-    }
-  }, [mx, setMuted]);
-
-  useEffect(() => {
-    const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
-      if (mEvent.getType() === 'm.push_rules') {
-        const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
-        const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
-        if (!override || !oldOverride) return;
-
-        const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
-          const roomId = rule.rule_id;
-
-          const isMuted = isMutedRule(rule);
-          if (!isMuted) return false;
-          const isOtherMuted = findMutedRule(otherOverride, roomId);
-          if (isOtherMuted) return false;
-          return true;
-        };
-
-        const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
-        const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
-
-        setMuted({
-          type: 'UPDATE',
-          addRooms: mutedRules.map((rule) => rule.rule_id),
-          removeRooms: unMutedRules.map((rule) => rule.rule_id),
-        });
-      }
-    };
-    mx.on(ClientEvent.AccountData, handlePushRules);
-    return () => {
-      mx.removeListener(ClientEvent.AccountData, handlePushRules);
-    };
-  }, [mx, setMuted]);
-};
diff --git a/src/app/state/navToActivePath.ts b/src/app/state/navToActivePath.ts
new file mode 100644 (file)
index 0000000..8086914
--- /dev/null
@@ -0,0 +1,66 @@
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import { Path } from 'react-router-dom';
+import {
+  atomWithLocalStorage,
+  getLocalStorageItem,
+  setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const NAV_TO_ACTIVE_PATH = 'navToActivePath';
+
+type NavToActivePath = Map<string, Path>;
+
+type NavToActivePathAction =
+  | {
+      type: 'PUT';
+      navId: string;
+      path: Path;
+    }
+  | {
+      type: 'DELETE';
+      navId: string;
+    };
+
+export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>;
+
+export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => {
+  const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`;
+
+  const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>(
+    storeKey,
+    (key) => {
+      const obj: Record<string, Path> = getLocalStorageItem(key, {});
+      return new Map(Object.entries(obj));
+    },
+    (key, value) => {
+      const obj: Record<string, Path> = Object.fromEntries(value);
+      setLocalStorageItem(key, obj);
+    }
+  );
+
+  const navToActivePathAtom = atom<NavToActivePath, [NavToActivePathAction], undefined>(
+    (get) => get(baseNavToActivePathAtom),
+    (get, set, action) => {
+      if (action.type === 'DELETE') {
+        set(
+          baseNavToActivePathAtom,
+          produce(get(baseNavToActivePathAtom), (draft) => {
+            draft.delete(action.navId);
+          })
+        );
+        return;
+      }
+      if (action.type === 'PUT') {
+        set(
+          baseNavToActivePathAtom,
+          produce(get(baseNavToActivePathAtom), (draft) => {
+            draft.set(action.navId, action.path);
+          })
+        );
+      }
+    }
+  );
+
+  return navToActivePathAtom;
+};
diff --git a/src/app/state/openedSidebarFolder.ts b/src/app/state/openedSidebarFolder.ts
new file mode 100644 (file)
index 0000000..70373b3
--- /dev/null
@@ -0,0 +1,66 @@
+import { WritableAtom, atom } from 'jotai';
+import produce from 'immer';
+import {
+  atomWithLocalStorage,
+  getLocalStorageItem,
+  setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const OPENED_SIDEBAR_FOLDER = 'openedSidebarFolder';
+
+type OpenedSidebarFolderAction =
+  | {
+      type: 'PUT';
+      id: string;
+    }
+  | {
+      type: 'DELETE';
+      id: string;
+    };
+
+export type OpenedSidebarFolderAtom = WritableAtom<
+  Set<string>,
+  [OpenedSidebarFolderAction],
+  undefined
+>;
+
+export const makeOpenedSidebarFolderAtom = (userId: string): OpenedSidebarFolderAtom => {
+  const storeKey = `${OPENED_SIDEBAR_FOLDER}${userId}`;
+
+  const baseOpenedSidebarFolderAtom = atomWithLocalStorage<Set<string>>(
+    storeKey,
+    (key) => {
+      const arrayValue = getLocalStorageItem<string[]>(key, []);
+      return new Set(arrayValue);
+    },
+    (key, value) => {
+      const arrayValue = Array.from(value);
+      setLocalStorageItem(key, arrayValue);
+    }
+  );
+
+  const openedSidebarFolderAtom = atom<Set<string>, [OpenedSidebarFolderAction], undefined>(
+    (get) => get(baseOpenedSidebarFolderAtom),
+    (get, set, action) => {
+      if (action.type === 'DELETE') {
+        set(
+          baseOpenedSidebarFolderAtom,
+          produce(get(baseOpenedSidebarFolderAtom), (draft) => {
+            draft.delete(action.id);
+          })
+        );
+        return;
+      }
+      if (action.type === 'PUT') {
+        set(
+          baseOpenedSidebarFolderAtom,
+          produce(get(baseOpenedSidebarFolderAtom), (draft) => {
+            draft.add(action.id);
+          })
+        );
+      }
+    }
+  );
+
+  return openedSidebarFolderAtom;
+};
diff --git a/src/app/state/room-list/inviteList.ts b/src/app/state/room-list/inviteList.ts
new file mode 100644 (file)
index 0000000..4fa97be
--- /dev/null
@@ -0,0 +1,32 @@
+import { atom, WritableAtom } from 'jotai';
+import { MatrixClient } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { Membership } from '../../../types/matrix/room';
+import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
+
+const baseRoomsAtom = atom<string[]>([]);
+export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
+  (get) => get(baseRoomsAtom),
+  (get, set, action) => {
+    if (action.type === 'INITIALIZE') {
+      set(baseRoomsAtom, action.rooms);
+      return;
+    }
+    set(baseRoomsAtom, (ids) => {
+      const newIds = ids.filter((id) => id !== action.roomId);
+      if (action.type === 'PUT') newIds.push(action.roomId);
+      return newIds;
+    });
+  }
+);
+
+export const useBindAllInvitesAtom = (
+  mx: MatrixClient,
+  allRooms: WritableAtom<string[], [RoomsAction], undefined>
+) => {
+  useBindRoomsWithMembershipsAtom(
+    mx,
+    allRooms,
+    useMemo(() => [Membership.Invite], [])
+  );
+};
diff --git a/src/app/state/room-list/mutedRoomList.ts b/src/app/state/room-list/mutedRoomList.ts
new file mode 100644 (file)
index 0000000..cb56ec0
--- /dev/null
@@ -0,0 +1,98 @@
+import { atom, useSetAtom } from 'jotai';
+import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { MuteChanges } from '../../../types/matrix/room';
+import { findMutedRule, isMutedRule } from '../../utils/room';
+
+export type MutedRoomsUpdate =
+  | {
+      type: 'INITIALIZE';
+      addRooms: string[];
+    }
+  | {
+      type: 'UPDATE';
+      addRooms: string[];
+      removeRooms: string[];
+    };
+
+export const muteChangesAtom = atom<MuteChanges>({
+  added: [],
+  removed: [],
+});
+
+const baseMutedRoomsAtom = atom(new Set<string>());
+export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
+  (get) => get(baseMutedRoomsAtom),
+  (get, set, action) => {
+    const mutedRooms = new Set([...get(mutedRoomsAtom)]);
+    if (action.type === 'INITIALIZE') {
+      set(baseMutedRoomsAtom, new Set([...action.addRooms]));
+      set(muteChangesAtom, {
+        added: [...action.addRooms],
+        removed: [],
+      });
+      return;
+    }
+    if (action.type === 'UPDATE') {
+      action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
+      action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
+      set(baseMutedRoomsAtom, mutedRooms);
+      set(muteChangesAtom, {
+        added: [...action.addRooms],
+        removed: [...action.removeRooms],
+      });
+    }
+  }
+);
+
+export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
+  const setMuted = useSetAtom(mutedAtom);
+
+  useEffect(() => {
+    const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
+      ?.global?.override;
+    if (overrideRules) {
+      const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
+        if (isMutedRule(rule)) rooms.push(rule.rule_id);
+        return rooms;
+      }, []);
+      setMuted({
+        type: 'INITIALIZE',
+        addRooms: mutedRooms,
+      });
+    }
+  }, [mx, setMuted]);
+
+  useEffect(() => {
+    const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
+      if (mEvent.getType() === 'm.push_rules') {
+        const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
+        const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
+        if (!override || !oldOverride) return;
+
+        const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
+          const roomId = rule.rule_id;
+
+          const isMuted = isMutedRule(rule);
+          if (!isMuted) return false;
+          const isOtherMuted = findMutedRule(otherOverride, roomId);
+          if (isOtherMuted) return false;
+          return true;
+        };
+
+        const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
+        const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
+
+        setMuted({
+          type: 'UPDATE',
+          addRooms: mutedRules.map((rule) => rule.rule_id),
+          removeRooms: unMutedRules.map((rule) => rule.rule_id),
+        });
+      }
+    };
+    mx.on(ClientEvent.AccountData, handlePushRules);
+    return () => {
+      mx.removeListener(ClientEvent.AccountData, handlePushRules);
+    };
+  }, [mx, setMuted]);
+};
diff --git a/src/app/state/room-list/roomList.ts b/src/app/state/room-list/roomList.ts
new file mode 100644 (file)
index 0000000..736a0bc
--- /dev/null
@@ -0,0 +1,28 @@
+import { atom } from 'jotai';
+import { MatrixClient } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { Membership } from '../../../types/matrix/room';
+import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
+
+const baseRoomsAtom = atom<string[]>([]);
+export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
+  (get) => get(baseRoomsAtom),
+  (get, set, action) => {
+    if (action.type === 'INITIALIZE') {
+      set(baseRoomsAtom, action.rooms);
+      return;
+    }
+    set(baseRoomsAtom, (ids) => {
+      const newIds = ids.filter((id) => id !== action.roomId);
+      if (action.type === 'PUT') newIds.push(action.roomId);
+      return newIds;
+    });
+  }
+);
+export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
+  useBindRoomsWithMembershipsAtom(
+    mx,
+    allRooms,
+    useMemo(() => [Membership.Join], [])
+  );
+};
diff --git a/src/app/state/room-list/utils.ts b/src/app/state/room-list/utils.ts
new file mode 100644 (file)
index 0000000..1ca7e7d
--- /dev/null
@@ -0,0 +1,66 @@
+import { useSetAtom, WritableAtom } from 'jotai';
+import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { Membership } from '../../../types/matrix/room';
+
+export type RoomsAction =
+  | {
+      type: 'INITIALIZE';
+      rooms: string[];
+    }
+  | {
+      type: 'PUT' | 'DELETE';
+      roomId: string;
+    };
+
+export const useBindRoomsWithMembershipsAtom = (
+  mx: MatrixClient,
+  roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
+  memberships: Membership[]
+) => {
+  const setRoomsAtom = useSetAtom(roomsAtom);
+
+  useEffect(() => {
+    const satisfyMembership = (room: Room): boolean =>
+      !!memberships.find((membership) => membership === room.getMyMembership());
+    setRoomsAtom({
+      type: 'INITIALIZE',
+      rooms: mx
+        .getRooms()
+        .filter(satisfyMembership)
+        .map((room) => room.roomId),
+    });
+
+    const handleAddRoom = (room: Room) => {
+      if (satisfyMembership(room)) {
+        setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+      }
+    };
+
+    const handleMembershipChange = (room: Room) => {
+      if (satisfyMembership(room)) {
+        setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+      } else {
+        setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
+      }
+    };
+
+    const handleDeleteRoom = (roomId: string) => {
+      setRoomsAtom({ type: 'DELETE', roomId });
+    };
+
+    mx.on(ClientEvent.Room, handleAddRoom);
+    mx.on(RoomEvent.MyMembership, handleMembershipChange);
+    mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
+    return () => {
+      mx.removeListener(ClientEvent.Room, handleAddRoom);
+      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+      mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
+    };
+  }, [mx, memberships, setRoomsAtom]);
+};
+
+export const compareRoomsEqual = (a: string[], b: string[]) => {
+  if (a.length !== b.length) return false;
+  return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]);
+};
diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts
new file mode 100644 (file)
index 0000000..60b42fd
--- /dev/null
@@ -0,0 +1,48 @@
+import { atom } from 'jotai';
+import { atomFamily } from 'jotai/utils';
+import { Descendant } from 'slate';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { TListAtom, createListAtom } from '../list';
+import { createUploadAtomFamily } from '../upload';
+import { TUploadContent } from '../../utils/matrix';
+
+export const roomUploadAtomFamily = createUploadAtomFamily();
+
+export type TUploadItem = {
+  file: TUploadContent;
+  originalFile: TUploadContent;
+  encInfo: EncryptedAttachmentInfo | undefined;
+};
+
+export const roomIdToUploadItemsAtomFamily = atomFamily<string, TListAtom<TUploadItem>>(
+  createListAtom
+);
+
+export type RoomIdToMsgAction =
+  | {
+      type: 'PUT';
+      roomId: string;
+      msg: Descendant[];
+    }
+  | {
+      type: 'DELETE';
+      roomId: string;
+    };
+
+const createMsgDraftAtom = () => atom<Descendant[]>([]);
+export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
+export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
+  createMsgDraftAtom()
+);
+
+export type IReplyDraft = {
+  userId: string;
+  eventId: string;
+  body: string;
+  formattedBody?: string;
+};
+const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
+export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
+export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
+  createReplyDraftAtom()
+);
diff --git a/src/app/state/room/roomToParents.ts b/src/app/state/room/roomToParents.ts
new file mode 100644 (file)
index 0000000..b58190c
--- /dev/null
@@ -0,0 +1,124 @@
+import produce from 'immer';
+import { atom, useSetAtom } from 'jotai';
+import {
+  ClientEvent,
+  MatrixClient,
+  MatrixEvent,
+  Room,
+  RoomEvent,
+  RoomStateEvent,
+} from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { Membership, RoomToParents, StateEvent } from '../../../types/matrix/room';
+import {
+  getRoomToParents,
+  getSpaceChildren,
+  isSpace,
+  isValidChild,
+  mapParentWithChildren,
+} from '../../utils/room';
+
+export type RoomToParentsAction =
+  | {
+      type: 'INITIALIZE';
+      roomToParents: RoomToParents;
+    }
+  | {
+      type: 'PUT';
+      parent: string;
+      children: string[];
+    }
+  | {
+      type: 'DELETE';
+      roomId: string;
+    };
+
+const baseRoomToParents = atom<RoomToParents>(new Map());
+export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
+  (get) => get(baseRoomToParents),
+  (get, set, action) => {
+    if (action.type === 'INITIALIZE') {
+      set(baseRoomToParents, action.roomToParents);
+      return;
+    }
+    if (action.type === 'PUT') {
+      set(
+        baseRoomToParents,
+        produce(get(baseRoomToParents), (draftRoomToParents) => {
+          mapParentWithChildren(draftRoomToParents, action.parent, action.children);
+        })
+      );
+      return;
+    }
+    if (action.type === 'DELETE') {
+      set(
+        baseRoomToParents,
+        produce(get(baseRoomToParents), (draftRoomToParents) => {
+          const noParentRooms: string[] = [];
+          draftRoomToParents.delete(action.roomId);
+          draftRoomToParents.forEach((parents, child) => {
+            parents.delete(action.roomId);
+            if (parents.size === 0) noParentRooms.push(child);
+          });
+          noParentRooms.forEach((room) => draftRoomToParents.delete(room));
+        })
+      );
+    }
+  }
+);
+
+export const useBindRoomToParentsAtom = (
+  mx: MatrixClient,
+  roomToParents: typeof roomToParentsAtom
+) => {
+  const setRoomToParents = useSetAtom(roomToParents);
+
+  useEffect(() => {
+    setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
+
+    const handleAddRoom = (room: Room) => {
+      if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
+        setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
+      }
+    };
+
+    const handleMembershipChange = (room: Room, membership: string) => {
+      if (room.getMyMembership() === Membership.Leave) {
+        setRoomToParents({ type: 'DELETE', roomId: room.roomId });
+        return;
+      }
+      if (isSpace(room) && membership === Membership.Join) {
+        setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
+      }
+    };
+
+    const handleStateChange = (mEvent: MatrixEvent) => {
+      if (mEvent.getType() === StateEvent.SpaceChild) {
+        const childId = mEvent.getStateKey();
+        const roomId = mEvent.getRoomId();
+        if (childId && roomId) {
+          if (isValidChild(mEvent)) {
+            setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
+          } else {
+            setRoomToParents({ type: 'DELETE', roomId: childId });
+          }
+        }
+      }
+    };
+
+    const handleDeleteRoom = (roomId: string) => {
+      setRoomToParents({ type: 'DELETE', roomId });
+    };
+
+    mx.on(ClientEvent.Room, handleAddRoom);
+    mx.on(RoomEvent.MyMembership, handleMembershipChange);
+    mx.on(RoomStateEvent.Events, handleStateChange);
+    mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
+    return () => {
+      mx.removeListener(ClientEvent.Room, handleAddRoom);
+      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+      mx.removeListener(RoomStateEvent.Events, handleStateChange);
+      mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
+    };
+  }, [mx, setRoomToParents]);
+};
diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts
new file mode 100644 (file)
index 0000000..4ac391f
--- /dev/null
@@ -0,0 +1,291 @@
+import produce from 'immer';
+import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
+import {
+  IRoomTimelineData,
+  MatrixClient,
+  MatrixEvent,
+  Room,
+  RoomEvent,
+  SyncState,
+} from 'matrix-js-sdk';
+import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
+import { useCallback, useEffect } from 'react';
+import {
+  MuteChanges,
+  Membership,
+  NotificationType,
+  RoomToUnread,
+  UnreadInfo,
+  Unread,
+  StateEvent,
+} from '../../../types/matrix/room';
+import {
+  getAllParents,
+  getNotificationType,
+  getUnreadInfo,
+  getUnreadInfos,
+  isNotificationEvent,
+  roomHaveUnread,
+} from '../../utils/room';
+import { roomToParentsAtom } from './roomToParents';
+import { useStateEventCallback } from '../../hooks/useStateEventCallback';
+import { useSyncState } from '../../hooks/useSyncState';
+
+export type RoomToUnreadAction =
+  | {
+      type: 'RESET';
+      unreadInfos: UnreadInfo[];
+    }
+  | {
+      type: 'PUT';
+      unreadInfo: UnreadInfo;
+    }
+  | {
+      type: 'DELETE';
+      roomId: string;
+    };
+
+const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({
+  highlight: unreadInfo.highlight,
+  total: unreadInfo.total,
+  from: null,
+});
+
+const putUnreadInfo = (
+  roomToUnread: RoomToUnread,
+  allParents: Set<string>,
+  unreadInfo: UnreadInfo
+) => {
+  const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
+  roomToUnread.set(unreadInfo.roomId, unreadInfoToUnread(unreadInfo));
+
+  const newH = unreadInfo.highlight - oldUnread.highlight;
+  const newT = unreadInfo.total - oldUnread.total;
+
+  allParents.forEach((parentId) => {
+    const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
+    roomToUnread.set(parentId, {
+      highlight: (oldParentUnread.highlight += newH),
+      total: (oldParentUnread.total += newT),
+      from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
+    });
+  });
+};
+
+const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
+  const oldUnread = roomToUnread.get(roomId);
+  if (!oldUnread) return;
+  roomToUnread.delete(roomId);
+
+  allParents.forEach((parentId) => {
+    const oldParentUnread = roomToUnread.get(parentId);
+    if (!oldParentUnread) return;
+    const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
+    newFrom.delete(roomId);
+    if (newFrom.size === 0) {
+      roomToUnread.delete(parentId);
+      return;
+    }
+    roomToUnread.set(parentId, {
+      highlight: oldParentUnread.highlight - oldUnread.highlight,
+      total: oldParentUnread.total - oldUnread.total,
+      from: newFrom,
+    });
+  });
+};
+
+export const unreadEqual = (u1: Unread, u2: Unread): boolean => {
+  const countEqual = u1.highlight === u2.highlight && u1.total === u2.total;
+
+  if (!countEqual) return false;
+
+  const f1 = u1.from;
+  const f2 = u2.from;
+  if (f1 === null && f2 === null) return true;
+  if (f1 === null || f2 === null) return false;
+
+  if (f1.size !== f2.size) return false;
+
+  let fromEqual = true;
+  f1?.forEach((item) => {
+    if (!f2?.has(item)) {
+      fromEqual = false;
+    }
+  });
+
+  return fromEqual;
+};
+
+const baseRoomToUnread = atom<RoomToUnread>(new Map());
+export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
+  (get) => get(baseRoomToUnread),
+  (get, set, action) => {
+    if (action.type === 'RESET') {
+      const draftRoomToUnread: RoomToUnread = new Map();
+      action.unreadInfos.forEach((unreadInfo) => {
+        putUnreadInfo(
+          draftRoomToUnread,
+          getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
+          unreadInfo
+        );
+      });
+      set(baseRoomToUnread, draftRoomToUnread);
+      return;
+    }
+    if (action.type === 'PUT') {
+      const { unreadInfo } = action;
+      const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
+      if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
+        // Do not update if unread data has not changes
+        // like total & highlight
+        return;
+      }
+      set(
+        baseRoomToUnread,
+        produce(get(baseRoomToUnread), (draftRoomToUnread) =>
+          putUnreadInfo(
+            draftRoomToUnread,
+            getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
+            unreadInfo
+          )
+        )
+      );
+      return;
+    }
+    if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
+      set(
+        baseRoomToUnread,
+        produce(get(baseRoomToUnread), (draftRoomToUnread) =>
+          deleteUnreadInfo(
+            draftRoomToUnread,
+            getAllParents(get(roomToParentsAtom), action.roomId),
+            action.roomId
+          )
+        )
+      );
+    }
+  }
+);
+
+export const useBindRoomToUnreadAtom = (
+  mx: MatrixClient,
+  unreadAtom: typeof roomToUnreadAtom,
+  muteChangesAtom: PrimitiveAtom<MuteChanges>
+) => {
+  const setUnreadAtom = useSetAtom(unreadAtom);
+  const muteChanges = useAtomValue(muteChangesAtom);
+
+  useEffect(() => {
+    setUnreadAtom({
+      type: 'RESET',
+      unreadInfos: getUnreadInfos(mx),
+    });
+  }, [mx, setUnreadAtom]);
+
+  useSyncState(
+    mx,
+    useCallback(
+      (state) => {
+        if (state === SyncState.Prepared) {
+          setUnreadAtom({
+            type: 'RESET',
+            unreadInfos: getUnreadInfos(mx),
+          });
+        }
+      },
+      [mx, setUnreadAtom]
+    )
+  );
+
+  useEffect(() => {
+    const handleTimelineEvent = (
+      mEvent: MatrixEvent,
+      room: Room | undefined,
+      toStartOfTimeline: boolean | undefined,
+      removed: boolean,
+      data: IRoomTimelineData
+    ) => {
+      if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
+      if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
+        setUnreadAtom({
+          type: 'DELETE',
+          roomId: room.roomId,
+        });
+        return;
+      }
+
+      if (mEvent.getSender() === mx.getUserId()) return;
+      setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
+    };
+    mx.on(RoomEvent.Timeline, handleTimelineEvent);
+    return () => {
+      mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+    };
+  }, [mx, setUnreadAtom]);
+
+  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<ReceiptContent>();
+
+        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);
+    return () => {
+      mx.removeListener(RoomEvent.Receipt, handleReceipt);
+    };
+  }, [mx, setUnreadAtom]);
+
+  useEffect(() => {
+    muteChanges.removed.forEach((roomId) => {
+      const room = mx.getRoom(roomId);
+      if (!room) return;
+      if (!roomHaveUnread(mx, room)) return;
+      setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
+    });
+    muteChanges.added.forEach((roomId) => {
+      setUnreadAtom({ type: 'DELETE', roomId });
+    });
+  }, [mx, setUnreadAtom, muteChanges]);
+
+  useEffect(() => {
+    const handleMembershipChange = (room: Room, membership: string) => {
+      if (membership !== Membership.Join) {
+        setUnreadAtom({
+          type: 'DELETE',
+          roomId: room.roomId,
+        });
+      }
+    };
+    mx.on(RoomEvent.MyMembership, handleMembershipChange);
+    return () => {
+      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+    };
+  }, [mx, setUnreadAtom]);
+
+  useStateEventCallback(
+    mx,
+    useCallback(
+      (mEvent) => {
+        if (mEvent.getType() === StateEvent.SpaceChild) {
+          setUnreadAtom({
+            type: 'RESET',
+            unreadInfos: getUnreadInfos(mx),
+          });
+        }
+      },
+      [mx, setUnreadAtom]
+    )
+  );
+};
diff --git a/src/app/state/roomInputDrafts.ts b/src/app/state/roomInputDrafts.ts
deleted file mode 100644 (file)
index 2708b8b..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-import { atom } from 'jotai';
-import { atomFamily } from 'jotai/utils';
-import { Descendant } from 'slate';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { TListAtom, createListAtom } from './list';
-import { createUploadAtomFamily } from './upload';
-import { TUploadContent } from '../utils/matrix';
-
-export const roomUploadAtomFamily = createUploadAtomFamily();
-
-export type TUploadItem = {
-  file: TUploadContent;
-  originalFile: TUploadContent;
-  encInfo: EncryptedAttachmentInfo | undefined;
-};
-
-export const roomIdToUploadItemsAtomFamily = atomFamily<string, TListAtom<TUploadItem>>(
-  createListAtom
-);
-
-export type RoomIdToMsgAction =
-  | {
-      type: 'PUT';
-      roomId: string;
-      msg: Descendant[];
-    }
-  | {
-      type: 'DELETE';
-      roomId: string;
-    };
-
-const createMsgDraftAtom = () => atom<Descendant[]>([]);
-export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
-export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
-  createMsgDraftAtom()
-);
-
-export type IReplyDraft = {
-  userId: string;
-  eventId: string;
-  body: string;
-  formattedBody?: string;
-};
-const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
-export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
-export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
-  createReplyDraftAtom()
-);
diff --git a/src/app/state/roomList.ts b/src/app/state/roomList.ts
deleted file mode 100644 (file)
index e0fa170..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-import { atom } from 'jotai';
-import { MatrixClient } from 'matrix-js-sdk';
-import { useMemo } from 'react';
-import { Membership } from '../../types/matrix/room';
-import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
-
-const baseRoomsAtom = atom<string[]>([]);
-export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
-  (get) => get(baseRoomsAtom),
-  (get, set, action) => {
-    if (action.type === 'INITIALIZE') {
-      set(baseRoomsAtom, action.rooms);
-      return;
-    }
-    set(baseRoomsAtom, (ids) => {
-      const newIds = ids.filter((id) => id !== action.roomId);
-      if (action.type === 'PUT') newIds.push(action.roomId);
-      return newIds;
-    });
-  }
-);
-export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
-  useBindRoomsWithMembershipsAtom(
-    mx,
-    allRooms,
-    useMemo(() => [Membership.Join], [])
-  );
-};
diff --git a/src/app/state/roomToParents.ts b/src/app/state/roomToParents.ts
deleted file mode 100644 (file)
index 1e2ef18..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-import produce from 'immer';
-import { atom, useSetAtom } from 'jotai';
-import {
-  ClientEvent,
-  MatrixClient,
-  MatrixEvent,
-  Room,
-  RoomEvent,
-  RoomStateEvent,
-} from 'matrix-js-sdk';
-import { useEffect } from 'react';
-import { Membership, RoomToParents, StateEvent } from '../../types/matrix/room';
-import {
-  getRoomToParents,
-  getSpaceChildren,
-  isSpace,
-  isValidChild,
-  mapParentWithChildren,
-} from '../utils/room';
-
-export type RoomToParentsAction =
-  | {
-      type: 'INITIALIZE';
-      roomToParents: RoomToParents;
-    }
-  | {
-      type: 'PUT';
-      parent: string;
-      children: string[];
-    }
-  | {
-      type: 'DELETE';
-      roomId: string;
-    };
-
-const baseRoomToParents = atom<RoomToParents>(new Map());
-export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
-  (get) => get(baseRoomToParents),
-  (get, set, action) => {
-    if (action.type === 'INITIALIZE') {
-      set(baseRoomToParents, action.roomToParents);
-      return;
-    }
-    if (action.type === 'PUT') {
-      set(
-        baseRoomToParents,
-        produce(get(baseRoomToParents), (draftRoomToParents) => {
-          mapParentWithChildren(draftRoomToParents, action.parent, action.children);
-        })
-      );
-      return;
-    }
-    if (action.type === 'DELETE') {
-      set(
-        baseRoomToParents,
-        produce(get(baseRoomToParents), (draftRoomToParents) => {
-          const noParentRooms: string[] = [];
-          draftRoomToParents.delete(action.roomId);
-          draftRoomToParents.forEach((parents, child) => {
-            parents.delete(action.roomId);
-            if (parents.size === 0) noParentRooms.push(child);
-          });
-          noParentRooms.forEach((room) => draftRoomToParents.delete(room));
-        })
-      );
-    }
-  }
-);
-
-export const useBindRoomToParentsAtom = (
-  mx: MatrixClient,
-  roomToParents: typeof roomToParentsAtom
-) => {
-  const setRoomToParents = useSetAtom(roomToParents);
-
-  useEffect(() => {
-    setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
-
-    const handleAddRoom = (room: Room) => {
-      if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
-        setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
-      }
-    };
-
-    const handleMembershipChange = (room: Room, membership: string) => {
-      if (isSpace(room) && membership === Membership.Join) {
-        setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
-      }
-    };
-
-    const handleStateChange = (mEvent: MatrixEvent) => {
-      if (mEvent.getType() === StateEvent.SpaceChild) {
-        const childId = mEvent.getStateKey();
-        const roomId = mEvent.getRoomId();
-        if (childId && roomId) {
-          if (isValidChild(mEvent)) {
-            setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
-          } else {
-            setRoomToParents({ type: 'DELETE', roomId: childId });
-          }
-        }
-      }
-    };
-
-    const handleDeleteRoom = (roomId: string) => {
-      setRoomToParents({ type: 'DELETE', roomId });
-    };
-
-    mx.on(ClientEvent.Room, handleAddRoom);
-    mx.on(RoomEvent.MyMembership, handleMembershipChange);
-    mx.on(RoomStateEvent.Events, handleStateChange);
-    mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
-    return () => {
-      mx.removeListener(ClientEvent.Room, handleAddRoom);
-      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
-      mx.removeListener(RoomStateEvent.Events, handleStateChange);
-      mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
-    };
-  }, [mx, setRoomToParents]);
-};
diff --git a/src/app/state/roomToUnread.ts b/src/app/state/roomToUnread.ts
deleted file mode 100644 (file)
index ad38876..0000000
+++ /dev/null
@@ -1,219 +0,0 @@
-import produce from 'immer';
-import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
-import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
-import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
-import { useEffect } from 'react';
-import {
-  MuteChanges,
-  Membership,
-  NotificationType,
-  RoomToUnread,
-  UnreadInfo,
-} from '../../types/matrix/room';
-import {
-  getAllParents,
-  getNotificationType,
-  getUnreadInfo,
-  getUnreadInfos,
-  isNotificationEvent,
-  roomHaveUnread,
-} from '../utils/room';
-import { roomToParentsAtom } from './roomToParents';
-
-export type RoomToUnreadAction =
-  | {
-      type: 'RESET';
-      unreadInfos: UnreadInfo[];
-    }
-  | {
-      type: 'PUT';
-      unreadInfo: UnreadInfo;
-    }
-  | {
-      type: 'DELETE';
-      roomId: string;
-    };
-
-const putUnreadInfo = (
-  roomToUnread: RoomToUnread,
-  allParents: Set<string>,
-  unreadInfo: UnreadInfo
-) => {
-  const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
-  roomToUnread.set(unreadInfo.roomId, {
-    highlight: unreadInfo.highlight,
-    total: unreadInfo.total,
-    from: null,
-  });
-
-  const newH = unreadInfo.highlight - oldUnread.highlight;
-  const newT = unreadInfo.total - oldUnread.total;
-
-  allParents.forEach((parentId) => {
-    const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
-    roomToUnread.set(parentId, {
-      highlight: (oldParentUnread.highlight += newH),
-      total: (oldParentUnread.total += newT),
-      from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
-    });
-  });
-};
-
-const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
-  const oldUnread = roomToUnread.get(roomId);
-  if (!oldUnread) return;
-  roomToUnread.delete(roomId);
-
-  allParents.forEach((parentId) => {
-    const oldParentUnread = roomToUnread.get(parentId);
-    if (!oldParentUnread) return;
-    const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
-    newFrom.delete(roomId);
-    if (newFrom.size === 0) {
-      roomToUnread.delete(parentId);
-      return;
-    }
-    roomToUnread.set(parentId, {
-      highlight: oldParentUnread.highlight - oldUnread.highlight,
-      total: oldParentUnread.total - oldUnread.total,
-      from: newFrom,
-    });
-  });
-};
-
-const baseRoomToUnread = atom<RoomToUnread>(new Map());
-export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
-  (get) => get(baseRoomToUnread),
-  (get, set, action) => {
-    if (action.type === 'RESET') {
-      const draftRoomToUnread: RoomToUnread = new Map();
-      action.unreadInfos.forEach((unreadInfo) => {
-        putUnreadInfo(
-          draftRoomToUnread,
-          getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
-          unreadInfo
-        );
-      });
-      set(baseRoomToUnread, draftRoomToUnread);
-      return;
-    }
-    if (action.type === 'PUT') {
-      set(
-        baseRoomToUnread,
-        produce(get(baseRoomToUnread), (draftRoomToUnread) =>
-          putUnreadInfo(
-            draftRoomToUnread,
-            getAllParents(get(roomToParentsAtom), action.unreadInfo.roomId),
-            action.unreadInfo
-          )
-        )
-      );
-      return;
-    }
-    if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
-      set(
-        baseRoomToUnread,
-        produce(get(baseRoomToUnread), (draftRoomToUnread) =>
-          deleteUnreadInfo(
-            draftRoomToUnread,
-            getAllParents(get(roomToParentsAtom), action.roomId),
-            action.roomId
-          )
-        )
-      );
-    }
-  }
-);
-
-export const useBindRoomToUnreadAtom = (
-  mx: MatrixClient,
-  unreadAtom: typeof roomToUnreadAtom,
-  muteChangesAtom: PrimitiveAtom<MuteChanges>
-) => {
-  const setUnreadAtom = useSetAtom(unreadAtom);
-  const muteChanges = useAtomValue(muteChangesAtom);
-
-  useEffect(() => {
-    setUnreadAtom({
-      type: 'RESET',
-      unreadInfos: getUnreadInfos(mx),
-    });
-  }, [mx, setUnreadAtom]);
-
-  useEffect(() => {
-    const handleTimelineEvent = (
-      mEvent: MatrixEvent,
-      room: Room | undefined,
-      toStartOfTimeline: boolean | undefined,
-      removed: boolean,
-      data: IRoomTimelineData
-    ) => {
-      if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
-      if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
-        setUnreadAtom({
-          type: 'DELETE',
-          roomId: room.roomId,
-        });
-        return;
-      }
-
-      if (mEvent.getSender() === mx.getUserId()) return;
-      setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
-    };
-    mx.on(RoomEvent.Timeline, handleTimelineEvent);
-    return () => {
-      mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
-    };
-  }, [mx, setUnreadAtom]);
-
-  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<ReceiptContent>();
-
-        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);
-    return () => {
-      mx.removeListener(RoomEvent.Receipt, handleReceipt);
-    };
-  }, [mx, setUnreadAtom]);
-
-  useEffect(() => {
-    muteChanges.removed.forEach((roomId) => {
-      const room = mx.getRoom(roomId);
-      if (!room) return;
-      if (!roomHaveUnread(mx, room)) return;
-      setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
-    });
-    muteChanges.added.forEach((roomId) => {
-      setUnreadAtom({ type: 'DELETE', roomId });
-    });
-  }, [mx, setUnreadAtom, muteChanges]);
-
-  useEffect(() => {
-    const handleMembershipChange = (room: Room, membership: string) => {
-      if (membership !== Membership.Join) {
-        setUnreadAtom({
-          type: 'DELETE',
-          roomId: room.roomId,
-        });
-      }
-    };
-    mx.on(RoomEvent.MyMembership, handleMembershipChange);
-    return () => {
-      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
-    };
-  }, [mx, setUnreadAtom]);
-};
diff --git a/src/app/state/selectedRoom.ts b/src/app/state/selectedRoom.ts
deleted file mode 100644 (file)
index 1ef04de..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-import { atom } from 'jotai';
-
-export const selectedRoomAtom = atom<string | undefined>(undefined);
diff --git a/src/app/state/selectedTab.ts b/src/app/state/selectedTab.ts
deleted file mode 100644 (file)
index e680ae6..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-import { atom } from 'jotai';
-
-export enum SidebarTab {
-  Home = 'Home',
-  People = 'People',
-}
-
-export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);
diff --git a/src/app/state/spaceRooms.ts b/src/app/state/spaceRooms.ts
new file mode 100644 (file)
index 0000000..8480498
--- /dev/null
@@ -0,0 +1,54 @@
+import { atom } from 'jotai';
+import produce from 'immer';
+import {
+  atomWithLocalStorage,
+  getLocalStorageItem,
+  setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const SPACE_ROOMS = 'spaceRooms';
+
+const baseSpaceRoomsAtom = atomWithLocalStorage<Set<string>>(
+  SPACE_ROOMS,
+  (key) => {
+    const arrayValue = getLocalStorageItem<string[]>(key, []);
+    return new Set(arrayValue);
+  },
+  (key, value) => {
+    const arrayValue = Array.from(value);
+    setLocalStorageItem(key, arrayValue);
+  }
+);
+
+type SpaceRoomsAction =
+  | {
+      type: 'PUT';
+      roomId: string;
+    }
+  | {
+      type: 'DELETE';
+      roomId: string;
+    };
+
+export const spaceRoomsAtom = atom<Set<string>, [SpaceRoomsAction], undefined>(
+  (get) => get(baseSpaceRoomsAtom),
+  (get, set, action) => {
+    if (action.type === 'DELETE') {
+      set(
+        baseSpaceRoomsAtom,
+        produce(get(baseSpaceRoomsAtom), (draft) => {
+          draft.delete(action.roomId);
+        })
+      );
+      return;
+    }
+    if (action.type === 'PUT') {
+      set(
+        baseSpaceRoomsAtom,
+        produce(get(baseSpaceRoomsAtom), (draft) => {
+          draft.add(action.roomId);
+        })
+      );
+    }
+  }
+);
diff --git a/src/app/state/tabToRoom.ts b/src/app/state/tabToRoom.ts
deleted file mode 100644 (file)
index b9472d9..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import produce from 'immer';
-import { atom } from 'jotai';
-import { MatrixClient } from 'matrix-js-sdk';
-
-type RoomInfo = {
-  roomId: string;
-  timestamp: number;
-};
-type TabToRoom = Map<string, RoomInfo>;
-
-type TabToRoomAction = {
-  type: 'PUT';
-  tabInfo: { tabId: string; roomInfo: RoomInfo };
-};
-
-const baseTabToRoom = atom<TabToRoom>(new Map());
-export const tabToRoomAtom = atom<TabToRoom, [TabToRoomAction], undefined>(
-  (get) => get(baseTabToRoom),
-  (get, set, action) => {
-    if (action.type === 'PUT') {
-      set(
-        baseTabToRoom,
-        produce(get(baseTabToRoom), (draft) => {
-          draft.set(action.tabInfo.tabId, action.tabInfo.roomInfo);
-        })
-      );
-    }
-  }
-);
-
-export const useBindTabToRoomAtom = (mx: MatrixClient) => {
-  console.log(mx);
-  // TODO:
-};
index c77c91be6b923c47d4d9d07cd2b735375a528b58..88d4687cdcb4364452bf563f89f92cfc5145b041 100644 (file)
@@ -1,28 +1,76 @@
+import produce from 'immer';
 import { atom, useSetAtom } from 'jotai';
 import { selectAtom } from 'jotai/utils';
-import {
-  MatrixClient,
-  RoomMember,
-  RoomMemberEvent,
-  RoomMemberEventHandlerMap,
-} from 'matrix-js-sdk';
+import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk';
 import { useEffect } from 'react';
 
-export type IRoomIdToTypingMembers = Map<string, RoomMember[]>;
+export const TYPING_TIMEOUT_MS = 5000; // 5 seconds
 
-export type IRoomIdToTypingMembersAction =
-  | {
-      type: 'PUT';
-      roomId: string;
-      member: RoomMember;
-    }
-  | {
-      type: 'DELETE';
-      roomId: string;
-      member: RoomMember;
-    };
+export type TypingReceipt = {
+  userId: string;
+  ts: number;
+};
+export type IRoomIdToTypingMembers = Map<string, TypingReceipt[]>;
+
+type TypingMemberPutAction = {
+  type: 'PUT';
+  roomId: string;
+  userId: string;
+  ts: number;
+};
+type TypingMemberDeleteAction = {
+  type: 'DELETE';
+  roomId: string;
+  userId: string;
+};
+export type IRoomIdToTypingMembersAction = TypingMemberPutAction | TypingMemberDeleteAction;
 
 const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
+
+const putTypingMember = (
+  roomToMembers: IRoomIdToTypingMembers,
+  action: TypingMemberPutAction
+): IRoomIdToTypingMembers => {
+  let typingMembers = roomToMembers.get(action.roomId) ?? [];
+
+  typingMembers = typingMembers.filter((receipt) => receipt.userId !== action.userId);
+  typingMembers.push({
+    userId: action.userId,
+    ts: action.ts,
+  });
+  roomToMembers.set(action.roomId, typingMembers);
+  return roomToMembers;
+};
+
+const deleteTypingMember = (
+  roomToMembers: IRoomIdToTypingMembers,
+  action: TypingMemberDeleteAction
+): IRoomIdToTypingMembers => {
+  let typingMembers = roomToMembers.get(action.roomId) ?? [];
+
+  typingMembers = typingMembers.filter((receipt) => receipt.userId !== action.userId);
+  if (typingMembers.length === 0) {
+    roomToMembers.delete(action.roomId);
+  } else {
+    roomToMembers.set(action.roomId, typingMembers);
+  }
+  return roomToMembers;
+};
+
+const timeoutReceipt = (
+  roomToMembers: IRoomIdToTypingMembers,
+  roomId: string,
+  userId: string,
+  timeout: number
+): boolean | undefined => {
+  const typingMembers = roomToMembers.get(roomId) ?? [];
+
+  const target = typingMembers.find((receipt) => receipt.userId === userId);
+  if (!target) return undefined;
+
+  return Date.now() - target.ts >= timeout;
+};
+
 export const roomIdToTypingMembersAtom = atom<
   IRoomIdToTypingMembers,
   [IRoomIdToTypingMembersAction],
@@ -30,16 +78,48 @@ export const roomIdToTypingMembersAtom = atom<
 >(
   (get) => get(baseRoomIdToTypingMembersAtom),
   (get, set, action) => {
-    const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);
-    let typingMembers = roomIdToTypingMembers.get(action.roomId) ?? [];
-
-    typingMembers = typingMembers.filter((member) => member.userId !== action.member.userId);
+    const rToTyping = get(baseRoomIdToTypingMembersAtom);
 
     if (action.type === 'PUT') {
-      typingMembers = [...typingMembers, action.member];
+      set(
+        baseRoomIdToTypingMembersAtom,
+        produce(rToTyping, (draft) => putTypingMember(draft, action))
+      );
+
+      // remove typing receipt after some timeout
+      // to prevent stuck typing members
+      setTimeout(() => {
+        const { roomId, userId } = action;
+        const timeout = timeoutReceipt(
+          get(baseRoomIdToTypingMembersAtom),
+          roomId,
+          userId,
+          TYPING_TIMEOUT_MS
+        );
+        if (timeout) {
+          set(
+            baseRoomIdToTypingMembersAtom,
+            produce(get(baseRoomIdToTypingMembersAtom), (draft) =>
+              deleteTypingMember(draft, {
+                type: 'DELETE',
+                roomId,
+                userId,
+              })
+            )
+          );
+        }
+      }, TYPING_TIMEOUT_MS);
+    }
+
+    if (
+      action.type === 'DELETE' &&
+      rToTyping.get(action.roomId)?.find((receipt) => receipt.userId === action.userId)
+    ) {
+      set(
+        baseRoomIdToTypingMembersAtom,
+        produce(rToTyping, (draft) => deleteTypingMember(draft, action))
+      );
     }
-    roomIdToTypingMembers.set(action.roomId, typingMembers);
-    set(baseRoomIdToTypingMembersAtom, new Map([...roomIdToTypingMembers]));
   }
 );
 
@@ -57,7 +137,8 @@ export const useBindRoomIdToTypingMembersAtom = (
       setTypingMembers({
         type: member.typing ? 'PUT' : 'DELETE',
         roomId: member.roomId,
-        member,
+        userId: member.userId,
+        ts: Date.now(),
       });
     };
 
diff --git a/src/app/state/utils.ts b/src/app/state/utils.ts
deleted file mode 100644 (file)
index 4c4caa5..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useSetAtom, WritableAtom } from 'jotai';
-import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
-import { useEffect } from 'react';
-import { Membership } from '../../types/matrix/room';
-
-export type RoomsAction =
-  | {
-      type: 'INITIALIZE';
-      rooms: string[];
-    }
-  | {
-      type: 'PUT' | 'DELETE';
-      roomId: string;
-    };
-
-export const useBindRoomsWithMembershipsAtom = (
-  mx: MatrixClient,
-  roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
-  memberships: Membership[]
-) => {
-  const setRoomsAtom = useSetAtom(roomsAtom);
-
-  useEffect(() => {
-    const satisfyMembership = (room: Room): boolean =>
-      !!memberships.find((membership) => membership === room.getMyMembership());
-    setRoomsAtom({
-      type: 'INITIALIZE',
-      rooms: mx
-        .getRooms()
-        .filter(satisfyMembership)
-        .map((room) => room.roomId),
-    });
-
-    const handleAddRoom = (room: Room) => {
-      if (satisfyMembership(room)) {
-        setRoomsAtom({ type: 'PUT', roomId: room.roomId });
-      }
-    };
-
-    const handleMembershipChange = (room: Room) => {
-      if (!satisfyMembership(room)) {
-        setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
-      }
-    };
-
-    const handleDeleteRoom = (roomId: string) => {
-      setRoomsAtom({ type: 'DELETE', roomId });
-    };
-
-    mx.on(ClientEvent.Room, handleAddRoom);
-    mx.on(RoomEvent.MyMembership, handleMembershipChange);
-    mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
-    return () => {
-      mx.removeListener(ClientEvent.Room, handleAddRoom);
-      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
-      mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
-    };
-  }, [mx, memberships, setRoomsAtom]);
-};
-
-export const compareRoomsEqual = (a: string[], b: string[]) => {
-  if (a.length !== b.length) return false;
-  return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]);
-};
diff --git a/src/app/styles/ContainerColor.css.ts b/src/app/styles/ContainerColor.css.ts
new file mode 100644 (file)
index 0000000..cb1f933
--- /dev/null
@@ -0,0 +1,33 @@
+import { ComplexStyleRule } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { ContainerColor as TContainerColor, DefaultReset, color } from 'folds';
+
+const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
+  vars: {
+    backgroundColor: color[variant].Container,
+    borderColor: color[variant].ContainerLine,
+    outlineColor: color[variant].ContainerLine,
+    color: color[variant].OnContainer,
+  },
+});
+
+export const ContainerColor = recipe({
+  base: [DefaultReset],
+  variants: {
+    variant: {
+      Background: getVariant('Background'),
+      Surface: getVariant('Surface'),
+      SurfaceVariant: getVariant('SurfaceVariant'),
+      Primary: getVariant('Primary'),
+      Secondary: getVariant('Secondary'),
+      Success: getVariant('Success'),
+      Warning: getVariant('Warning'),
+      Critical: getVariant('Critical'),
+    },
+  },
+  defaultVariants: {
+    variant: 'Surface',
+  },
+});
+
+export type ContainerColorVariants = RecipeVariants<typeof ContainerColor>;
index 076bbb61bfc45225a7a3dcbeb1db0341bea43c72..d86a32366084405f0ff373ff806d635564e5e585 100644 (file)
@@ -211,3 +211,11 @@ export const EmoticonImg = style([
     cursor: 'default',
   },
 ]);
+
+export const highlightText = style([
+  DefaultReset,
+  {
+    backgroundColor: 'yellow',
+    color: 'black',
+  },
+]);
index e9be6b16eb20f7fbe5a4c8bac041275c46826cfe..f6ef2b9e1d8682c7e76a38fe69efa195a8770240 100644 (file)
@@ -1,24 +1,13 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useEffect, useRef } from 'react';
 import './Client.scss';
 
-import { initHotkeys } from '../../../client/event/hotkeys';
-import { initRoomListListener } from '../../../client/event/roomList';
-
-import Text from '../../atoms/text/Text';
-import Spinner from '../../atoms/spinner/Spinner';
-import Navigation from '../../organisms/navigation/Navigation';
-import ContextMenu, { MenuItem } from '../../atoms/context-menu/ContextMenu';
-import IconButton from '../../atoms/button/IconButton';
 import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
 import Windows from '../../organisms/pw/Windows';
 import Dialogs from '../../organisms/pw/Dialogs';
 
-import initMatrix from '../../../client/initMatrix';
 import navigation from '../../../client/state/navigation';
 import cons from '../../../client/state/cons';
 
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import { MatrixClientProvider } from '../../hooks/useMatrixClient';
 import { ClientContent } from './ClientContent';
 import { useSetting } from '../../state/hooks/settings';
 import { settingsAtom } from '../../state/settings';
@@ -36,8 +25,6 @@ function SystemEmojiFeature() {
 }
 
 function Client() {
-  const [isLoading, changeLoading] = useState(true);
-  const [loadingMsg, setLoadingMsg] = useState('Heating up');
   const classNameHidden = 'client__item-hidden';
 
   const navWrapperRef = useRef(null);
@@ -62,76 +49,19 @@ function Client() {
     };
   }, []);
 
-  useEffect(() => {
-    changeLoading(true);
-    let counter = 0;
-    const iId = setInterval(() => {
-      const msgList = ['Almost there...', 'Looks like you have a lot of stuff to heat up!'];
-      if (counter === msgList.length - 1) {
-        setLoadingMsg(msgList[msgList.length - 1]);
-        clearInterval(iId);
-        return;
-      }
-      setLoadingMsg(msgList[counter]);
-      counter += 1;
-    }, 15000);
-    initMatrix.once('init_loading_finished', () => {
-      clearInterval(iId);
-      initHotkeys();
-      initRoomListListener(initMatrix.roomList);
-      changeLoading(false);
-    });
-    initMatrix.init();
-  }, []);
-
-  if (isLoading) {
-    return (
-      <div className="loading-display">
-        <div className="loading__menu">
-          <ContextMenu
-            placement="bottom"
-            content={
-              <>
-                <MenuItem onClick={() => initMatrix.clearCacheAndReload()}>
-                  Clear cache & reload
-                </MenuItem>
-                <MenuItem onClick={() => initMatrix.logout()}>Logout</MenuItem>
-              </>
-            }
-            render={(toggle) => (
-              <IconButton size="extra-small" onClick={toggle} src={VerticalMenuIC} />
-            )}
-          />
-        </div>
-        <Spinner />
-        <Text className="loading__message" variant="b2">
-          {loadingMsg}
-        </Text>
-
-        <div className="loading__appname">
-          <Text variant="h2" weight="medium">
-            Cinny
-          </Text>
-        </div>
-      </div>
-    );
-  }
-
   return (
-    <MatrixClientProvider value={initMatrix.matrixClient}>
-      <div className="client-container">
-        <div className="navigation__wrapper" ref={navWrapperRef}>
-          <Navigation />
-        </div>
-        <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
-          <ClientContent />
-        </div>
-        <Windows />
-        <Dialogs />
-        <ReusableContextMenu />
-        <SystemEmojiFeature />
+    <div className="client-container">
+      {/* <div className="navigation__wrapper" ref={navWrapperRef}>
+        <Navigation />
+      </div> */}
+      <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
+        <ClientContent />
       </div>
-    </MatrixClientProvider>
+      <Windows />
+      <Dialogs />
+      <ReusableContextMenu />
+      <SystemEmojiFeature />
+    </div>
   );
 }
 
index cdb8fcc94d48907683f1a867efb82807ba3f1130..bad5fc93b0a021b1fdacf21729d30573a1602912 100644 (file)
@@ -3,11 +3,12 @@
 .client-container {
   display: flex;
   height: 100%;
+  flex-grow: 1;
 }
 
 .navigation__wrapper {
   width: var(--navigation-width);
-  
+
   @include screen.smallerThan(mobileBreakpoint) {
     width: 100%;
   }
index ada7008e5aa1cfba0d874d8489bf5e8d3dd248ea..cebe012c2e262cf81336a6e1da867bce104f1489 100644 (file)
@@ -6,7 +6,7 @@ import navigation from '../../../client/state/navigation';
 import { openNavigation } from '../../../client/action/navigation';
 
 import Welcome from '../../organisms/welcome/Welcome';
-import { RoomBaseView } from '../../organisms/room/Room';
+import { RoomBaseView } from '../../features/room/Room';
 
 export function ClientContent() {
   const [roomInfo, setRoomInfo] = useState({
diff --git a/src/app/utils/ASCIILexicalTable.ts b/src/app/utils/ASCIILexicalTable.ts
new file mode 100644 (file)
index 0000000..61b2481
--- /dev/null
@@ -0,0 +1,393 @@
+export class ASCIILexicalTable {
+  readonly startCode: number;
+
+  readonly endCode: number;
+
+  readonly maxStrWidth: number;
+
+  private readonly widthToSize: number[];
+
+  private readonly rangeCount: (i: number, j: number) => number;
+
+  constructor(startCode: number, endCode: number, maxStrWidth: number) {
+    if (startCode > endCode) {
+      throw new Error('Invalid ASCII code! startCode is greater than endCode.');
+    }
+    if (startCode < 0 || endCode > 128) {
+      throw new Error('Invalid ASCII code range!');
+    }
+
+    if (maxStrWidth <= 0) {
+      throw new Error('Invalid max string width!');
+    }
+
+    this.maxStrWidth = maxStrWidth;
+    this.startCode = startCode;
+    this.endCode = endCode;
+
+    /**
+     * @param i smaller - inclusive
+     * @param j larger - inclusive
+     * @returns number
+     */
+    this.rangeCount = (i: number, j: number): number => j - i + 1;
+
+    this.widthToSize = [];
+    this.populateWidthToSize();
+
+    if (this.size() > Number.MAX_SAFE_INTEGER) {
+      console.warn(
+        `[!] Warning: ASCIILexicalTable size is larger than the Number.MAX_SAFE_INTEGER: ${this.size()} > ${
+          Number.MAX_SAFE_INTEGER
+        }`
+      );
+    }
+  }
+
+  private populateWidthToSize() {
+    const chars = this.rangeCount(this.startCode, this.endCode);
+    for (let i = 0, count = 0; i < this.maxStrWidth; i += 1) {
+      count = count * chars + chars;
+      this.widthToSize[i] = count;
+    }
+  }
+
+  private getWidthToSize(width: number): number {
+    return this.widthToSize[width - 1];
+  }
+
+  first(): string {
+    return String.fromCharCode(this.startCode);
+  }
+
+  last(): string {
+    let str = '';
+    for (let i = 0; i < this.maxStrWidth; i += 1) {
+      str += String.fromCharCode(this.endCode);
+    }
+    return str;
+  }
+
+  hasIndex(index: number): boolean {
+    return index >= 0 && index < this.size();
+  }
+
+  has(str: string): boolean {
+    if (str.length === 0 || str.length > this.maxStrWidth) {
+      return false;
+    }
+
+    let charCode: number;
+    for (let i = 0; i < str.length; i += 1) {
+      charCode = str.charCodeAt(i);
+      if (charCode < this.startCode || charCode > this.endCode) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  size(): number {
+    return this.getWidthToSize(this.maxStrWidth);
+  }
+
+  index(str: string): number {
+    if (!this.has(str)) {
+      return -1;
+    }
+
+    let index = 0;
+    const chars = this.rangeCount(this.startCode, this.endCode);
+
+    for (let i = 0; i < this.maxStrWidth; i += 1) {
+      const code = str.charCodeAt(i);
+
+      if (Number.isNaN(code)) {
+        return index;
+      }
+
+      const opStrWidth = this.maxStrWidth - i;
+      const opStrTableSize = this.getWidthToSize(opStrWidth);
+
+      const segmentSize = opStrTableSize / chars;
+
+      const codeIndex = code - this.startCode;
+      const emptyCount = i === 0 ? 0 : 1;
+
+      index += segmentSize * codeIndex + emptyCount;
+    }
+
+    return index;
+  }
+
+  get(index: number): string | undefined {
+    if (!this.hasIndex(index)) {
+      return undefined;
+    }
+
+    let str = '';
+    const chars = this.rangeCount(this.startCode, this.endCode);
+
+    for (let toIndex = index, i = 0; i < this.maxStrWidth; i += 1) {
+      const opStrWidth = this.maxStrWidth - i;
+      const opStrTableSize = this.getWidthToSize(opStrWidth);
+
+      const segmentSize = opStrTableSize / chars;
+
+      const segmentIndex = Math.floor(toIndex / segmentSize);
+      str += String.fromCharCode(this.startCode + segmentIndex);
+
+      toIndex -= segmentIndex * segmentSize;
+      if (toIndex === 0) {
+        break;
+      }
+      toIndex -= 1;
+    }
+
+    return str;
+  }
+
+  previous(str: string): string | undefined {
+    if (!this.has(str)) return undefined;
+    let prev = str;
+    const lastCode = prev.charCodeAt(prev.length - 1);
+    prev = prev.slice(0, prev.length - 1);
+
+    if (lastCode === this.startCode) {
+      if (prev.length === 0) return undefined;
+      return prev;
+    }
+
+    prev += String.fromCharCode(lastCode - 1);
+    while (prev.length < this.maxStrWidth) {
+      prev += String.fromCharCode(this.endCode);
+    }
+    return prev;
+  }
+
+  next(str: string): string | undefined {
+    if (!this.has(str)) return undefined;
+    let next = str;
+
+    if (next.length < this.maxStrWidth) {
+      next += String.fromCharCode(this.startCode);
+      return next;
+    }
+
+    for (let i = next.length - 1; i >= 0; i -= 1) {
+      const lastCode = next.charCodeAt(i);
+      if (lastCode !== this.endCode) {
+        next = next.slice(0, i) + String.fromCharCode(lastCode + 1);
+        return next;
+      }
+      next = next.slice(0, i);
+    }
+    return undefined;
+  }
+
+  between(a: string, b: string): string | undefined {
+    if (!this.has(a) || !this.has(b)) {
+      return undefined;
+    }
+
+    const centerIndex = Math.floor((this.index(a) + this.index(b)) / 2);
+
+    const str = this.get(centerIndex);
+    if (str === a || str === b) return undefined;
+    return str;
+  }
+
+  nBetween(n: number, a: string, b: string): string[] | undefined {
+    if (n <= 0 || !this.has(a) || !this.has(b)) {
+      return undefined;
+    }
+
+    const indexA = this.index(a);
+    const indexB = this.index(b);
+
+    const nBetween = Math.max(indexA, indexB) - Math.min(indexA, indexB);
+    if (nBetween < n) {
+      return undefined;
+    }
+    const segmentSize = Math.floor(nBetween / (n + 1));
+    if (segmentSize === 0) return undefined;
+
+    const items: string[] = [];
+
+    for (
+      let segmentIndex = indexA + segmentSize;
+      segmentIndex < indexB;
+      segmentIndex += segmentSize
+    ) {
+      if (items.length === n) break;
+
+      const str = this.get(segmentIndex);
+
+      if (!str) break;
+      items.push(str);
+    }
+
+    if (items.length < n) {
+      return undefined;
+    }
+
+    return items;
+  }
+}
+
+// const printLex = (lex: ASCIILexicalTable) => {
+//   const padRight = (s: string, maxWidth: number, padding: string): string => {
+//     let ns = s;
+//     for (let i = s.length; i < maxWidth; i += 1) {
+//       ns += padding;
+//     }
+//     return ns;
+//   };
+
+//   const formattedLine = (n: number, item: string): string =>
+//     `|${padRight(n.toString(), lex.size().toString().length, ' ')}|${item}|`;
+
+//   const hr = `|${padRight('-', lex.size().toString().length, '-')}|${padRight(
+//     '-',
+//     lex.maxStrWidth,
+//     '-'
+//   )}|`;
+
+//   console.log(`All lexicographic string combination in order.`);
+//   console.log(`Start ASCII code: "${lex.startCode}"`);
+//   console.log(`End ASCII code: "${lex.endCode}"`);
+//   console.log(`Max string width: ${lex.maxStrWidth}`);
+//   console.log(`Total String Combination Count: ${lex.size()}\n`);
+//   console.log('Table:');
+//   console.log(hr);
+//   for (let i = 0; i < lex.size(); i += 1) {
+//     const str = lex.get(i);
+//     if (str) {
+//       console.log(formattedLine(i, padRight(str, lex.maxStrWidth, '_')));
+//     }
+//   }
+//   console.log(hr);
+// };
+
+// console.log('\n');
+
+// const lex = new ASCIILexicalTable('a'.charCodeAt(0), 'c'.charCodeAt(0), 3);
+// printLex(lex);
+// console.log(lex.size());
+// console.log(lex.nBetween(8, ' ', '~~~~~'));
+// console.log(lex.between('a', 'ccc'));
+// console.log(lex.get(11));
+// console.log(lex.get(11) === 'aaac');
+
+// const lex4 = new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 5);
+// console.log('Size: ', lex4.size());
+// console.log('Between: ', lex4.between('7g7g5', 'caccc'));
+// printLex(lex4);
+
+// console.log('\n');
+
+// const perf = () => {
+//   const loopLength = 99999;
+//   const lexT = new ASCIILexicalTable('a'.charCodeAt(0), 'z'.charCodeAt(0), 9);
+//   console.log(lexT.size());
+//   const str = 'bcbba';
+//   const strI = lexT.index(str);
+//   console.log('================');
+//   console.time('index');
+//   console.log(lexT.index(str));
+//   for (let i = 0; i < loopLength; i += 1) {
+//     lexT.index(str);
+//   }
+//   console.timeEnd('index');
+//   console.log('================');
+//   console.time('get');
+//   console.log(lexT.get(strI));
+//   for (let i = 0; i < loopLength; i += 1) {
+//     lexT.get(strI);
+//   }
+//   console.timeEnd('get');
+//   console.log('================');
+//   console.time('previous');
+//   console.log(lexT.previous(str));
+//   for (let i = 0; i < loopLength; i += 1) {
+//     lexT.previous(str);
+//   }
+//   console.timeEnd('previous');
+//   console.log('================');
+//   console.time('next');
+//   console.log(lexT.next(str));
+//   for (let i = 0; i < loopLength; i += 1) {
+//     lexT.next(str);
+//   }
+//   console.timeEnd('next');
+//   console.log('================');
+//   console.time('between');
+//   console.log(lexT.between(str, 'cbbca'));
+//   for (let i = 0; i < loopLength; i += 1) {
+//     lexT.between(str, 'cbbca');
+//   }
+//   console.timeEnd('between');
+// };
+
+// perf();
+
+const findNextFilledKey = (
+  fromIndex: number,
+  keys: Array<string | undefined>
+): [number, string] | [-1, undefined] => {
+  for (let j = fromIndex; j < keys.length; j += 1) {
+    const key = keys[j];
+    if (typeof key === 'string') {
+      return [j, key];
+    }
+  }
+
+  return [-1, undefined];
+};
+
+export const orderKeys = (
+  lex: ASCIILexicalTable,
+  keys: Array<string | undefined>
+): Array<string> | undefined => {
+  const newKeys: string[] = [];
+
+  for (let i = 0; i < keys.length; ) {
+    const key = keys[i];
+    const collectedKeys: string[] = [];
+    const [nextKeyIndex, nextKey] = findNextFilledKey(i + 1, keys);
+    const isKey = typeof key === 'string';
+
+    if (isKey) {
+      collectedKeys.push(key);
+    }
+
+    const keyToGenerateCount =
+      (nextKeyIndex === -1 ? keys.length : nextKeyIndex) - (key ? i + 1 : i + 0);
+
+    if (keyToGenerateCount > 0) {
+      const generatedKeys = lex.nBetween(
+        keyToGenerateCount,
+        key ?? lex.first(),
+        nextKey ?? lex.last()
+      );
+      if (generatedKeys) {
+        collectedKeys.push(...generatedKeys);
+      } else {
+        return lex.nBetween(keys?.length, lex.first(), lex.last());
+      }
+    }
+
+    newKeys.push(...collectedKeys);
+    i += collectedKeys.length;
+  }
+
+  if (newKeys.length !== keys.length) {
+    return undefined;
+  }
+
+  return newKeys;
+};
+
+// const lex = new ASCIILexicalTable('a'.charCodeAt(0), 'b'.charCodeAt(0), 2);
+// const keys = [undefined, undefined];
+// console.log(orderKeys(lex, keys));
index 5cbe3806b4b413a96cc2c71a471b7e550f179136..6d7b69c13afdec63a5aa083c6c7b5c66ace208a1 100644 (file)
@@ -95,3 +95,20 @@ export const trimLeadingSlash = (str: string): string => str.replace(START_SLASH
 export const trimTrailingSlash = (str: string): string => str.replace(END_SLASHES_REG, '');
 
 export const trimSlash = (str: string): string => trimLeadingSlash(trimTrailingSlash(str));
+
+export const nameInitials = (str: string | undefined | null, len = 1): string => {
+  if (!str) return '�';
+  return [...str].slice(0, len).join('') || '�';
+};
+
+export const randomStr = (len = 12): string => {
+  let str = '';
+  const minCode = 'A'.charCodeAt(0);
+  const maxCode = 'Z'.charCodeAt(0);
+
+  for (let i = 0; i < len; i += 1) {
+    const code = Math.floor(Math.random() * (maxCode - minCode + 1) + minCode);
+    str += String.fromCharCode(code);
+  }
+  return str;
+};
index 78aa252186ee71b640c4d73c72f1b833eb7e6f36..8ec435d3d01034b62fece87691195766356483ff 100644 (file)
@@ -23,3 +23,10 @@ export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => {
     evt.preventDefault();
   }
 };
+
+export const onEnterOrSpace = (callback: () => void) => (evt: KeyboardEventLike) => {
+  if (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt)) {
+    evt.preventDefault();
+    callback();
+  }
+};
index 9303c9ac0cd9ba81273d3b663bd842af4efe9e84..55e16a95d6027f15a7bbabfb29d550599f6ae787 100644 (file)
@@ -8,10 +8,12 @@ import {
   MatrixError,
   MatrixEvent,
   Room,
+  RoomMember,
   UploadProgress,
   UploadResponse,
 } from 'matrix-js-sdk';
 import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
+import { AccountDataEvent } from '../../types/matrix/accountData';
 
 export const matchMxId = (id: string): RegExpMatchArray | null =>
   id.match(/^([@!$+#])(\S+):(\S+)$/);
@@ -37,8 +39,11 @@ export const parseMatrixToUrl = (url: string): [string | undefined, string | und
   return [g1AsMxId, g3AsVia];
 };
 
-export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
-  mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
+export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined =>
+  mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias)?.roomId;
+
+export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): string =>
+  mx.getRoom(roomId)?.getCanonicalAlias() || roomId;
 
 export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
   const info: IImageInfo = {};
@@ -162,10 +167,86 @@ export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
 export const eventWithShortcode = (ev: MatrixEvent) =>
   typeof ev.getContent().shortcode === 'string';
 
-export function hasDMWith(mx: MatrixClient, userId: string) {
+export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => {
   const dmLikeRooms = mx
     .getRooms()
     .filter((room) => mx.isRoomEncrypted(room.roomId) && room.getMembers().length <= 2);
 
   return dmLikeRooms.find((room) => room.getMember(userId));
-}
+};
+
+export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
+  const getOldestMember = (members: RoomMember[]): RoomMember | undefined => {
+    let oldestMemberTs: number | undefined;
+    let oldestMember: RoomMember | undefined;
+
+    const pickOldestMember = (member: RoomMember) => {
+      if (member.userId === myUserId) return;
+
+      if (
+        oldestMemberTs === undefined ||
+        (member.events.member && member.events.member.getTs() < oldestMemberTs)
+      ) {
+        oldestMember = member;
+        oldestMemberTs = member.events.member?.getTs();
+      }
+    };
+
+    members.forEach(pickOldestMember);
+
+    return oldestMember;
+  };
+
+  // Pick the joined user who's been here longest (and isn't us),
+  const member = getOldestMember(room.getJoinedMembers());
+  if (member) return member.userId;
+
+  // if there are no joined members other than us, use the oldest member
+  const member1 = getOldestMember(room.currentState.getMembers());
+  return member1?.userId ?? myUserId;
+};
+
+export const addRoomIdToMDirect = async (
+  mx: MatrixClient,
+  roomId: string,
+  userId: string
+): Promise<void> => {
+  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct);
+  const userIdToRoomIds: Record<string, string[]> = mDirectsEvent?.getContent() ?? {};
+
+  // remove it from the lists of any others users
+  // (it can only be a DM room for one person)
+  Object.keys(userIdToRoomIds).forEach((targetUserId) => {
+    const roomIds = userIdToRoomIds[targetUserId];
+
+    if (targetUserId !== userId) {
+      const indexOfRoomId = roomIds.indexOf(roomId);
+      if (indexOfRoomId > -1) {
+        roomIds.splice(indexOfRoomId, 1);
+      }
+    }
+  });
+
+  const roomIds = userIdToRoomIds[userId] || [];
+  if (roomIds.indexOf(roomId) === -1) {
+    roomIds.push(roomId);
+  }
+  userIdToRoomIds[userId] = roomIds;
+
+  await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+};
+
+export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
+  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct);
+  const userIdToRoomIds: Record<string, string[]> = mDirectsEvent?.getContent() ?? {};
+
+  Object.keys(userIdToRoomIds).forEach((targetUserId) => {
+    const roomIds = userIdToRoomIds[targetUserId];
+    const indexOfRoomId = roomIds.indexOf(roomId);
+    if (indexOfRoomId > -1) {
+      roomIds.splice(indexOfRoomId, 1);
+    }
+  });
+
+  await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+};
index 281f12006e3fec2d7c20deb187b129d6c2df9d6d..d7169062dd4281af9bfba0bef96bb24b1650ba5a 100644 (file)
@@ -1,5 +1,7 @@
 export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?<![.,:;!/?()[\\]\\s]+)`;
 
+export const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
+
 export const EMAIL_REGEX =
   /^(([^<>()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
 
@@ -10,3 +12,9 @@ export const VARIATION_SELECTOR_PATTERN = '[\uFE00-\uFE0F]';
 
 // https://github.com/mathiasbynens/emoji-regex
 export const EMOJI_PATTERN = `[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26D3\uFE0F?(?:\u200D\uD83D\uDCA5)?|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF43\uDF45-\uDF4A\uDF4C-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDF44(?:\u200D\uD83D\uDFEB)?|\uDF4B(?:\u200D\uD83D\uDFE9)?|\uDFC3(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4\uDEB5](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE41\uDE43\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC08(?:\u200D\u2B1B)?|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC26(?:\u200D(?:\u2B1B|\uD83D\uDD25))?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?|\uDE42(?:\u200D[\u2194\u2195]\uFE0F?)?|\uDEB6(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDCE(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1|\uDDD1\u200D\uD83E\uDDD2(?:\u200D\uD83E\uDDD2)?|\uDDD2(?:\u200D\uD83E\uDDD2)?))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)`;
+
+// Thumbs up emoji found to have Variation Selector 16 at the end
+// so included variation selector pattern in regex
+export const JUMBO_EMOJI_REG = new RegExp(
+  `^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
+);
index a2cb3a9f17fd10ae038a944893f4a9ff27e7f5b1..750dd6ca7ba81bb063658d9a9597fbd0f9fc92db 100644 (file)
@@ -76,8 +76,8 @@ export const isSpace = (room: Room | null): boolean => {
 export const isRoom = (room: Room | null): boolean => {
   if (!room) return false;
   const event = getStateEvent(room, StateEvent.RoomCreate);
-  if (!event) return false;
-  return event.getContent().type === undefined;
+  if (!event) return true;
+  return event.getContent().type !== RoomType.Space;
 };
 
 export const isUnsupportedRoom = (room: Room | null): boolean => {
@@ -88,7 +88,10 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
 };
 
 export function isValidChild(mEvent: MatrixEvent): boolean {
-  return mEvent.getType() === StateEvent.SpaceChild && Object.keys(mEvent.getContent()).length > 0;
+  return (
+    mEvent.getType() === StateEvent.SpaceChild &&
+    Array.isArray(mEvent.getContent<{ via: string[] }>().via)
+  );
 }
 
 export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => {
@@ -141,6 +144,15 @@ export const getRoomToParents = (mx: MatrixClient): RoomToParents => {
   return map;
 };
 
+export const getOrphanParents = (roomToParents: RoomToParents, roomId: string): string[] => {
+  const parents = getAllParents(roomToParents, roomId);
+  const orphanParents = Array.from(parents).filter(
+    (parentRoomId) => !roomToParents.has(parentRoomId)
+  );
+
+  return orphanParents;
+};
+
 export const isMutedRule = (rule: IPushRule) =>
   rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
 
@@ -167,14 +179,18 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
   return NotificationType.MentionsAndKeywords;
 };
 
+const NOTIFICATION_EVENT_TYPES = [
+  'm.room.create',
+  'm.room.message',
+  'm.room.encrypted',
+  'm.room.member',
+  'm.sticker',
+];
 export const isNotificationEvent = (mEvent: MatrixEvent) => {
   const eType = mEvent.getType();
-  if (
-    ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'].find(
-      (type) => type === eType
-    )
-  )
+  if (!NOTIFICATION_EVENT_TYPES.includes(eType)) {
     return false;
+  }
   if (eType === 'm.room.member') return false;
 
   if (mEvent.isRedacted()) return false;
@@ -183,6 +199,13 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
   return true;
 };
 
+export const roomHaveNotification = (room: Room): boolean => {
+  const total = room.getUnreadNotificationCount(NotificationCountType.Total);
+  const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
+
+  return total > 0 || highlight > 0;
+};
+
 export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
   const userId = mx.getUserId();
   if (!userId) return false;
@@ -218,7 +241,7 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
     if (room.getMyMembership() !== 'join') return unread;
     if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
 
-    if (roomHaveUnread(mx, room)) {
+    if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
       unread.push(getUnreadInfo(room));
     }
 
@@ -247,13 +270,19 @@ export const joinRuleToIconSrc = (
   return undefined;
 };
 
-export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefined => {
-  const url =
-    room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false) ??
-    undefined;
-  if (url) return url;
-  return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
-};
+export const getRoomAvatarUrl = (
+  mx: MatrixClient,
+  room: Room,
+  size: 32 | 96 = 32
+): string | undefined => room.getAvatarUrl(mx.baseUrl, size, size, 'crop') ?? undefined;
+
+export const getDirectRoomAvatarUrl = (
+  mx: MatrixClient,
+  room: Room,
+  size: 32 | 96 = 32
+): string | undefined =>
+  room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, size, size, 'crop', undefined, false) ??
+  undefined;
 
 export const trimReplyFromBody = (body: string): string => {
   const match = body.match(/^> <.+?> .+\n(>.*\n)*?\n/m);
diff --git a/src/app/utils/sort.ts b/src/app/utils/sort.ts
new file mode 100644 (file)
index 0000000..512c199
--- /dev/null
@@ -0,0 +1,59 @@
+import { MatrixClient } from 'matrix-js-sdk';
+
+export type SortFunc<T> = (a: T, b: T) => number;
+
+export const factoryRoomIdByActivity =
+  (mx: MatrixClient): SortFunc<string> =>
+  (a, b) => {
+    const room1 = mx.getRoom(a);
+    const room2 = mx.getRoom(b);
+
+    return (
+      (room2?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) -
+      (room1?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER)
+    );
+  };
+
+export const factoryRoomIdByAtoZ =
+  (mx: MatrixClient): SortFunc<string> =>
+  (a, b) => {
+    let aName = mx.getRoom(a)?.name ?? '';
+    let bName = mx.getRoom(b)?.name ?? '';
+
+    // remove "#" from the room name
+    // To ignore it in sorting
+    aName = aName.replace(/#/g, '');
+    bName = bName.replace(/#/g, '');
+
+    if (aName.toLowerCase() < bName.toLowerCase()) {
+      return -1;
+    }
+    if (aName.toLowerCase() > bName.toLowerCase()) {
+      return 1;
+    }
+    return 0;
+  };
+
+export const factoryRoomIdByUnreadCount =
+  (getUnreadCount: (roomId: string) => number): SortFunc<string> =>
+  (a, b) => {
+    const aT = getUnreadCount(a) ?? 0;
+    const bT = getUnreadCount(b) ?? 0;
+    return bT - aT;
+  };
+
+export const byTsOldToNew: SortFunc<number> = (a, b) => a - b;
+
+export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
+  if (!a && !b) {
+    return 0;
+  }
+
+  if (!b) return -1;
+  if (!a) return 1;
+
+  if (a < b) {
+    return -1;
+  }
+  return 1;
+};
index 4ee78a638cdf40a75584b040ec0aea8e89231134..e48e839b33da0833cfe60715aa1f66d9a583a49c 100644 (file)
@@ -45,17 +45,19 @@ export function openSpaceManage(roomId) {
   });
 }
 
-export function openSpaceAddExisting(roomId) {
+export function openSpaceAddExisting(roomId, spaces = false) {
   appDispatcher.dispatch({
     type: cons.actions.navigation.OPEN_SPACE_ADDEXISTING,
     roomId,
+    spaces,
   });
 }
 
-export function toggleRoomSettings(tabText) {
+export function toggleRoomSettings(roomId, tabText) {
   appDispatcher.dispatch({
     type: cons.actions.navigation.TOGGLE_ROOM_SETTINGS,
-    tabText,
+    roomId,
+    tabText
   });
 }
 
index e59ce3d7555783501199d919a774a6d7e3e1cf93..076d3794100832ce4aa345eec17961943239ff4c 100644 (file)
@@ -1,4 +1,4 @@
-import { openSearch, toggleRoomSettings } from '../action/navigation';
+import { openSearch } from '../action/navigation';
 import navigation from '../state/navigation';
 import { markAsRead } from '../action/notifications';
 
@@ -53,10 +53,6 @@ function listenKeyboard(event) {
     if (navigation.isRawModalVisible) return;
 
     if (event.key === 'Escape') {
-      if (navigation.isRoomSettings) {
-        toggleRoomSettings();
-        return;
-      }
       if (navigation.selectedRoomId) {
         markAsRead(navigation.selectedRoomId);
         return;
index 211cf114250e1f0c410a41cec4990d5b3b3a4f28..835982f81be091e548b505ed0fd4368b41520f30 100644 (file)
@@ -1,7 +1,7 @@
 import EventEmitter from 'events';
 import * as sdk from 'matrix-js-sdk';
 import Olm from '@matrix-org/olm';
-// import { logger } from 'matrix-js-sdk/lib/logger';
+import { logger } from 'matrix-js-sdk/lib/logger';
 
 import { getSecret } from './state/auth';
 import RoomList from './state/RoomList';
@@ -13,7 +13,9 @@ import navigation from './state/navigation';
 
 global.Olm = Olm;
 
-// logger.disableAll();
+if (import.meta.env.PROD) {
+  logger.disableAll();
+}
 
 class InitMatrix extends EventEmitter {
   constructor() {
@@ -23,14 +25,20 @@ class InitMatrix extends EventEmitter {
   }
 
   async init() {
-    if (this.matrixClient) {
+    if (this.matrixClient || this.initializing) {
       console.warn('Client is already initialized!')
       return;
     }
+    this.initializing = true;
 
-    await this.startClient();
-    this.setupSync();
-    this.listenEvents();
+    try {
+      await this.startClient();
+      this.setupSync();
+      this.listenEvents();
+      this.initializing = false;
+    } catch {
+      this.initializing = false;
+    }
   }
 
   async startClient() {
@@ -62,6 +70,7 @@ class InitMatrix extends EventEmitter {
       lazyLoadMembers: true,
     });
     this.matrixClient.setGlobalErrorOnUnknownDevices(false);
+    this.matrixClient.setMaxListeners(50);
   }
 
   setupSync() {
index 07231cd4be43e223b2a1d631e4ed70f1accb6b9a..ddac4dda860ae00330472e7f95f2460d3f29ea9e 100644 (file)
@@ -13,7 +13,6 @@ class Navigation extends EventEmitter {
     this.selectedSpacePath = [cons.tabs.HOME];
 
     this.selectedRoomId = null;
-    this.isRoomSettings = false;
     this.recentRooms = [];
 
     this.spaceToRoom = new Map();
@@ -85,10 +84,6 @@ class Navigation extends EventEmitter {
     this.removeRecentRoom(prevSelectedRoomId);
     this.addRecentRoom(prevSelectedRoomId);
     this.removeRecentRoom(this.selectedRoomId);
-    if (this.isRoomSettings && typeof this.selectedRoomId === 'string') {
-      this.isRoomSettings = !this.isRoomSettings;
-      this.emit(cons.events.navigation.ROOM_SETTINGS_TOGGLED, this.isRoomSettings);
-    }
     this.emit(
       cons.events.navigation.ROOM_SELECTED,
       this.selectedRoomId,
@@ -305,14 +300,13 @@ class Navigation extends EventEmitter {
         this.emit(cons.events.navigation.SPACE_MANAGE_OPENED, action.roomId);
       },
       [cons.actions.navigation.OPEN_SPACE_ADDEXISTING]: () => {
-        this.emit(cons.events.navigation.SPACE_ADDEXISTING_OPENED, action.roomId);
+        this.emit(cons.events.navigation.SPACE_ADDEXISTING_OPENED, action.roomId, action.spaces);
       },
       [cons.actions.navigation.TOGGLE_ROOM_SETTINGS]: () => {
-        this.isRoomSettings = !this.isRoomSettings;
         this.emit(
           cons.events.navigation.ROOM_SETTINGS_TOGGLED,
-          this.isRoomSettings,
-          action.tabText,
+          action.roomId,
+          action.tabText
         );
       },
       [cons.actions.navigation.OPEN_SHORTCUT_SPACES]: () => {
index 9dc5b4c30a58ae202da6971d63b31dd631965a2e..5290035bd932be40cda13dc8ba5dcfef280dac95 100644 (file)
@@ -68,7 +68,7 @@
   --tc-danger-low: rgba(240, 71, 71, 60%);
 
   --tc-code: #e62498;
-  --tc-link: hsl(213deg 76% 56%);
+  --tc-link: hsl(213deg 100% 45%);
   --tc-tooltip: white;
   --tc-badge: white;
 
   --ic-danger-normal: rgba(240, 71, 71, 0.7);
 
   /* user mxid colors */
-  --mx-uc-1: hsl(208, 66%, 53%);
-  --mx-uc-2: hsl(302, 49%, 45%);
-  --mx-uc-3: hsl(163, 97%, 36%);
-  --mx-uc-4: hsl(343, 75%, 61%);
-  --mx-uc-5: hsl(24, 100%, 59%);
-  --mx-uc-6: hsl(181, 63%, 47%);
-  --mx-uc-7: hsl(242, 89%, 65%);
-  --mx-uc-8: hsl(94, 65%, 50%);
+  --mx-uc-1: hsl(208, 100%, 45%);
+  --mx-uc-2: hsl(302, 100%, 30%);
+  --mx-uc-3: hsl(163, 100%, 30%);
+  --mx-uc-4: hsl(343, 100%, 45%);
+  --mx-uc-5: hsl(24, 100%, 45%);
+  --mx-uc-6: hsl(181, 100%, 30%);
+  --mx-uc-7: hsl(242, 100%, 45%);
+  --mx-uc-8: hsl(94, 100%, 35%);
 
   /* system icon size | -ic-[size]: value */
   --ic-large: 38px;
   --tc-primary-low: rgba(255, 255, 255, 0.4);
 
   --tc-code: #e565b1;
-  --tc-link: hsl(213deg 94% 73%);
+  --tc-link: hsl(213deg 100% 80%);
   --tc-badge: black;
 
   /* system icons | --ic-[background type]-[priority]: value */
   --ic-surface-low: rgba(255, 255, 255, 64%);
   --ic-primary-normal: #ffffff;
 
-  & .text {
-    /* override user mxid colors for texts */
-    --mx-uc-1: hsl(208, 100%, 58%);
-    --mx-uc-2: hsl(301, 80%, 70%);
-    --mx-uc-3: hsl(163, 93%, 41%);
-    --mx-uc-4: hsl(343, 91%, 66%);
-    --mx-uc-5: hsl(24, 90%, 67%);
-    --mx-uc-6: hsl(181, 90%, 50%);
-    --mx-uc-7: hsl(243, 100%, 74%);
-    --mx-uc-8: hsl(94, 66%, 50%);
-  }
+  --mx-uc-1: hsl(208, 100%, 75%);
+  --mx-uc-2: hsl(301, 100%, 80%);
+  --mx-uc-3: hsl(163, 100%, 70%);
+  --mx-uc-4: hsl(343, 100%, 75%);
+  --mx-uc-5: hsl(24, 100%, 70%);
+  --mx-uc-6: hsl(181, 100%, 60%);
+  --mx-uc-7: hsl(243, 100%, 80%);
+  --mx-uc-8: hsl(94, 100%, 80%);
 
   /* shadow and overlay */
   --bg-overlay: rgba(0, 0, 0, 60%);
@@ -402,6 +399,7 @@ textarea {
   margin: 0;
   padding: 0;
   background-color: transparent;
+  color: inherit;
   font-family: inherit;
   font-size: inherit;
   font-weight: inherit;
index 33419ce5df919b035f053f54b13294a41210aa20..c468309d60d5390ec5379fc847526d19531abfcf 100644 (file)
@@ -49,6 +49,12 @@ export enum RoomType {
   Space = 'm.space',
 }
 
+export type MSpaceChildContent = {
+  via: string[];
+  suggested?: boolean;
+  order?: string;
+};
+
 export enum NotificationType {
   Default = 'default',
   AllMessages = 'all_messages',
@@ -67,15 +73,15 @@ export type IRoomCreateContent = {
   };
 };
 
+export type GetContentCallback = <T>() => T;
+
 export type RoomToParents = Map<string, Set<string>>;
-export type RoomToUnread = Map<
-  string,
-  {
-    total: number;
-    highlight: number;
-    from: Set<string> | null;
-  }
->;
+export type Unread = {
+  total: number;
+  highlight: number;
+  from: Set<string> | null;
+};
+export type RoomToUnread = Map<string, Unread>;
 export type UnreadInfo = {
   roomId: string;
   total: number;
index 97f40500729a6ed4cf6da5febcb270a3a8ed2643..1255f81c496920a9873baf90be1c09a6ce95aa49 100644 (file)
@@ -45,6 +45,12 @@ export default defineConfig({
   server: {
     port: 8080,
     host: true,
+    proxy: {
+      "^\\/.*?\\/olm\\.wasm$": {
+        target: 'http://localhost:8080',
+        rewrite: () => '/olm.wasm'
+      }
+    }
   },
   plugins: [
     topLevelAwait({