From c27f45161eabcac3162fd909bd6a6f99bbed107c Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:58:32 +0530 Subject: [PATCH 001/158] add neurone - wip --- .eslintrc.cjs | 9 +- .zed/settings.json | 7 + package-lock.json | 47 +- package.json | 4 +- src/app/components/AuthFlowsLoader.tsx | 39 +- src/app/components/CapabilitiesLoader.tsx | 16 +- src/app/components/LogoutDialog.tsx | 26 +- src/app/components/MediaConfigLoader.tsx | 16 +- src/app/components/RenderMessageContent.tsx | 235 +-- src/app/components/RoomSummaryLoader.tsx | 71 +- src/app/components/RoomUnreadProvider.tsx | 24 - src/app/components/ServerConfigsLoader.tsx | 42 +- src/app/components/SpecVersionsLoader.tsx | 44 +- .../components/SupportedUIAFlowsLoader.tsx | 2 +- .../create-room/AdditionalCreatorInput.tsx | 12 +- .../create-room/CreateRoomAliasInput.tsx | 31 +- src/app/components/create-room/utils.ts | 107 +- src/app/components/editor/Elements.tsx | 8 +- .../autocomplete/EmoticonAutocomplete.tsx | 20 +- .../autocomplete/RoomMentionAutocomplete.tsx | 103 +- .../autocomplete/UserMentionAutocomplete.tsx | 96 +- src/app/components/editor/output.ts | 10 +- src/app/components/emoji-board/EmojiBoard.tsx | 170 +- .../emoji-board/components/Item.tsx | 16 +- .../emoji-board/components/Preview.tsx | 8 +- .../encrypted-event/EncryptedEvent.tsx | 109 + src/app/components/encrypted-event/index.ts | 1 + .../components/event-readers/EventReaders.tsx | 67 +- .../image-pack-view/ImagePackContent.tsx | 4 +- .../components/image-pack-view/ImageTile.tsx | 4 +- .../invite-user-prompt/InviteUserPrompt.tsx | 34 +- .../leave-room-prompt/LeaveRoomPrompt.tsx | 35 +- src/app/components/message/FileHeader.tsx | 22 +- .../components/message/MsgTypeRenderers.tsx | 456 ++-- src/app/components/message/Reaction.tsx | 35 +- src/app/components/message/RenderBody.tsx | 51 +- src/app/components/message/Reply.css.ts | 50 +- src/app/components/message/Reply.tsx | 265 ++- .../message/content/AudioContent.tsx | 220 +- .../message/content/EventContent.tsx | 2 +- .../message/content/FileContent.tsx | 122 +- .../message/content/ImageContent.tsx | 66 +- .../message/content/ThumbnailContent.tsx | 39 +- .../message/content/VideoContent.tsx | 78 +- src/app/components/message/layout/Base.tsx | 10 +- .../components/message/layout/layout.css.ts | 25 +- .../components/room-avatar/RoomAvatar.css.ts | 4 + src/app/components/room-avatar/RoomAvatar.tsx | 12 +- src/app/components/room-card/RoomCard.tsx | 73 +- src/app/components/room-intro/RoomIntro.tsx | 112 +- .../splash-screen/SplashScreen.css.ts | 7 +- .../components/splash-screen/SplashScreen.tsx | 19 +- src/app/components/uia-stages/DummyStage.tsx | 2 +- src/app/components/uia-stages/EmailStage.tsx | 36 +- .../components/uia-stages/PasswordStage.tsx | 2 +- .../components/uia-stages/ReCaptchaStage.tsx | 2 +- .../uia-stages/RegistrationTokenStage.tsx | 2 +- src/app/components/uia-stages/TermsStage.tsx | 2 +- src/app/components/uia-stages/types.ts | 3 +- .../components/upload-board/UploadBoard.tsx | 14 +- .../upload-card/CompactUploadCardRenderer.tsx | 19 +- .../upload-card/UploadCardRenderer.tsx | 33 +- .../components/url-preview/UrlPreviewCard.tsx | 54 +- .../components/user-avatar/UserAvatar.css.ts | 4 + src/app/components/user-avatar/UserAvatar.tsx | 2 +- src/app/features/create-chat/CreateChat.tsx | 74 +- src/app/features/create-room/CreateRoom.tsx | 45 +- src/app/features/create-space/CreateSpace.tsx | 47 +- .../JoinBeforeNavigate.tsx | 6 +- src/app/features/lobby/DnD.css.ts | 2 +- src/app/features/lobby/HierarchyItemMenu.tsx | 4 +- src/app/features/lobby/Lobby.tsx | 4 - src/app/features/room-nav/RoomNavItem.tsx | 117 +- src/app/features/room/CommandAutocomplete.tsx | 10 +- src/app/features/room/FileDropDialog.tsx | 36 + src/app/features/room/MembersDrawer.tsx | 115 +- src/app/features/room/OutboxView.tsx | 339 +++ src/app/features/room/Room.tsx | 84 +- src/app/features/room/RoomInput.tsx | 417 +--- src/app/features/room/RoomTimeline.tsx | 1863 ++++------------- src/app/features/room/RoomTombstone.tsx | 58 +- src/app/features/room/RoomView.tsx | 60 +- src/app/features/room/RoomViewFollowing.tsx | 247 +-- src/app/features/room/RoomViewHeader.tsx | 112 +- src/app/features/room/RoomViewTyping.css.ts | 5 +- src/app/features/room/RoomViewTyping.tsx | 210 +- .../room/components/JumpToLatestButton.tsx | 39 + .../room/components/TimelineDivider.tsx | 45 + .../room/components/TimelineFloat.tsx | 17 + src/app/features/room/components/index.ts | 3 + .../styles.css.ts} | 0 .../hooks/useEnsureRoomEncryptionSession.ts | 75 + src/app/features/room/hooks/useSendMessage.ts | 276 +++ src/app/features/room/hooks/useTimeline.ts | 284 +++ .../room/hooks/useUploadItemPicker.ts | 62 + src/app/features/room/hooks/useUploadItems.ts | 69 + src/app/features/room/hooks/useUploadsAtom.ts | 16 + src/app/features/room/message/Message.tsx | 701 ++++--- .../features/room/message/MessageEditor.tsx | 39 +- src/app/features/room/message/Reactions.tsx | 59 +- .../thread-selector/ThreadSelector.tsx | 75 + .../room/message/thread-selector/index.ts | 1 + .../message/thread-selector/styles.css.ts | 38 + src/app/features/room/msgContent.ts | 42 +- .../room/reaction-viewer/ReactionViewer.tsx | 183 +- .../room/room-pin-menu/RoomPinMenu.tsx | 520 +++-- .../features/room/thread-view/ThreadView.tsx | 626 ++++++ src/app/features/room/thread-view/index.ts | 1 + .../features/room/thread-view/styles.css.ts | 22 + src/app/features/settings/Settings.tsx | 19 +- .../features/settings/account/ContactInfo.tsx | 13 +- .../settings/account/IgnoredUserList.tsx | 32 +- .../features/settings/account/MatrixId.tsx | 6 +- src/app/features/settings/account/Profile.tsx | 40 +- .../settings/notifications/AllMessages.tsx | 77 +- .../notifications/KeywordMessages.tsx | 72 +- .../NotificationModeSwitcher.tsx | 4 +- .../notifications/SpecialMessages.tsx | 122 +- .../notifications/SystemNotification.tsx | 51 +- src/app/hooks/router/useSelectedRoom.ts | 16 +- src/app/hooks/router/useSelectedSpace.ts | 22 +- src/app/hooks/storage/useAccountData.ts | 25 + src/app/hooks/storage/useGetRoomAvatar.ts | 36 + src/app/hooks/storage/useGetRoomName.ts | 45 + src/app/hooks/storage/useRoomHeros.ts | 57 + src/app/hooks/storage/useRoomStateEvent.ts | 45 + src/app/hooks/storage/useRoomTopic.ts | 11 + src/app/hooks/types.ts | 11 +- src/app/hooks/useAuthFlows.ts | 17 +- src/app/hooks/useAuthMetadata.ts | 6 +- src/app/hooks/useCapabilities.ts | 27 +- src/app/hooks/useClient.ts | 16 + src/app/hooks/useCommands.ts | 353 +++- src/app/hooks/useDirectUsers.ts | 27 +- src/app/hooks/useEmailNotifications.ts | 25 +- src/app/hooks/useIgnoredUsers.ts | 37 +- src/app/hooks/useImagePackRooms.ts | 22 +- src/app/hooks/useImagePacks.ts | 267 +-- src/app/hooks/useJoinedRoomId.ts | 21 +- src/app/hooks/useLocalRoomSummary.ts | 58 +- src/app/hooks/useMediaAuthentication.ts | 8 +- src/app/hooks/useMemberFilter.ts | 47 +- src/app/hooks/useMemberPowerTag.ts | 27 +- src/app/hooks/useMemberSort.ts | 52 +- src/app/hooks/useMentionClickHandler.ts | 14 +- src/app/hooks/useMutualRooms.ts | 10 +- src/app/hooks/useNetwork.ts | 23 + src/app/hooks/useNotificationMode.ts | 15 +- src/app/hooks/useObservableValue.ts | 85 + src/app/hooks/useParsedLoginFlows.ts | 21 +- src/app/hooks/usePasswordEmail.ts | 52 +- src/app/hooks/usePendingMembers.ts | 74 + src/app/hooks/usePowerLevelTags.ts | 45 +- src/app/hooks/usePowerLevels.ts | 35 +- src/app/hooks/usePushRule.ts | 30 +- src/app/hooks/useRecentEmoji.ts | 41 +- src/app/hooks/useRecoveryAttempt.ts | 26 + src/app/hooks/useRegisterEmail.ts | 52 +- src/app/hooks/useReportRoomSupported.ts | 7 +- src/app/hooks/useRequest.ts | 22 + src/app/hooks/useRoom.ts | 5 +- src/app/hooks/useRoomCreators.ts | 20 +- src/app/hooks/useRoomEvent.ts | 44 +- src/app/hooks/useRoomMembers.ts | 141 +- src/app/hooks/useRoomNavigate.ts | 36 +- src/app/hooks/useRoomPermissions.ts | 12 +- src/app/hooks/useRoomPinnedEvents.ts | 18 +- src/app/hooks/useRoomTypingMembers.ts | 13 +- .../hooks/useRoomsNotificationPreferences.ts | 91 +- src/app/hooks/useSecondsCountdown.ts | 21 + src/app/hooks/useSidebarItems.ts | 77 +- src/app/hooks/useSpace.ts | 9 +- src/app/hooks/useSpaceHierarchy.ts | 266 +-- src/app/hooks/useSpecVersions.ts | 19 +- src/app/hooks/useTypingStatusUpdater.ts | 37 +- src/app/hooks/useUIAFlows.ts | 21 +- src/app/hooks/useUserProfile.ts | 64 +- src/app/hooks/useVirtualPaginator.ts | 11 +- src/app/pages/App.tsx | 6 +- .../{ConfigConfig.tsx => ClientConfig.tsx} | 17 +- src/app/pages/Router.tsx | 52 +- src/app/pages/auth/SSOLogin.tsx | 47 +- src/app/pages/auth/login/Login.tsx | 5 +- .../pages/auth/login/PasswordLoginForm.tsx | 57 +- src/app/pages/auth/login/TokenLogin.tsx | 43 +- src/app/pages/auth/login/loginUtil.ts | 70 +- .../auth/register/PasswordRegisterForm.tsx | 80 +- src/app/pages/auth/register/Register.tsx | 5 +- src/app/pages/auth/register/registerUtil.ts | 141 +- .../auth/reset-password/PasswordResetForm.tsx | 63 +- .../auth/reset-password/resetPasswordUtil.ts | 38 +- src/app/pages/client/ClientBindAtoms.ts | 15 +- .../pages/client/ClientInitStorageAtom.tsx | 6 +- src/app/pages/client/ClientNonUIFeatures.tsx | 239 +-- src/app/pages/client/ClientRoot.tsx | 240 +-- .../pages/client/InitialSyncSplashScreen.tsx | 129 ++ src/app/pages/client/SidebarNav.tsx | 2 +- src/app/pages/client/SpecVersions.tsx | 11 +- src/app/pages/client/SyncStatus.tsx | 165 +- src/app/pages/client/direct/Direct.tsx | 72 +- src/app/pages/client/direct/DirectCreate.tsx | 16 +- src/app/pages/client/direct/RoomProvider.tsx | 7 +- src/app/pages/client/direct/useDirectRooms.ts | 6 +- src/app/pages/client/explore/Explore.tsx | 29 +- src/app/pages/client/home/Home.tsx | 78 +- src/app/pages/client/home/RoomProvider.tsx | 7 +- src/app/pages/client/home/useHomeRooms.ts | 6 +- src/app/pages/client/sidebar/DirectTab.tsx | 97 +- src/app/pages/client/sidebar/ExploreTab.tsx | 6 +- src/app/pages/client/sidebar/HomeTab.tsx | 99 +- src/app/pages/client/sidebar/SettingsTab.tsx | 19 +- src/app/pages/client/sidebar/SpaceTabs.tsx | 441 ++-- src/app/pages/client/space/RoomProvider.tsx | 41 +- src/app/pages/client/space/Space.tsx | 226 +- src/app/pages/client/space/SpaceProvider.tsx | 14 +- src/app/plugins/custom-emoji/ImagePack.ts | 17 +- src/app/plugins/custom-emoji/utils.ts | 75 +- src/app/plugins/react-custom-html-parser.tsx | 64 +- src/app/plugins/recent-emoji.ts | 47 +- src/app/plugins/via-servers.ts | 105 +- src/app/state/hooks/roomList.ts | 167 +- src/app/state/hooks/roomNotifications.ts | 134 ++ src/app/state/hooks/unread.ts | 42 +- src/app/state/hooks/useBindAtoms.ts | 23 +- src/app/state/list.ts | 15 +- src/app/state/mDirectList.ts | 45 +- src/app/state/room-list/inviteList.ts | 43 +- src/app/state/room-list/roomList.ts | 41 +- src/app/state/room-list/utils.ts | 62 - src/app/state/room/roomInputDrafts.ts | 46 +- src/app/state/room/roomToNotifications.ts | 84 + src/app/state/room/roomToParents.ts | 119 +- src/app/state/room/roomToUnread.ts | 300 +-- src/app/state/spaceRooms.ts | 2 +- src/app/state/typingMembers.ts | 163 +- src/app/state/upload.ts | 98 +- src/app/utils/disposable.ts | 16 - src/app/utils/dom.ts | 10 +- src/app/utils/featureCheck.ts | 23 +- src/app/utils/matrix-uia.ts | 33 +- src/app/utils/matrix.ts | 174 +- src/app/utils/randomString.ts | 84 + src/app/utils/room.ts | 202 +- src/app/utils/sort.ts | 6 +- src/app/utils/user-agent.ts | 17 + src/client/LogoutManager.ts | 50 + src/client/MachineManager.ts | 45 + src/client/SyncResponseManager.ts | 135 ++ src/client/context.ts | 773 +++++++ src/client/decryption/DecryptionManager.ts | 85 + src/client/decryption/EncryptedEvent.ts | 93 + src/client/decryption/EncryptedEventIndex.ts | 52 + src/client/decryption/index.ts | 3 + src/client/filter.ts | 70 + src/client/initialize.ts | 177 ++ src/client/mxcToHttp.ts | 39 + src/client/reader/Error.ts | 15 + src/client/reader/Event.ts | 39 + src/client/reader/index.ts | 3 + src/client/reader/message/Content.ts | 75 + src/client/reader/message/EncryptedFile.ts | 7 + src/client/reader/message/InReplyTo.ts | 7 + src/client/reader/message/Info.ts | 40 + src/client/reader/message/Mentions.ts | 13 + src/client/reader/message/RelatesTo.ts | 23 + src/client/reader/message/Relations.ts | 15 + src/client/reader/message/Thread.ts | 15 + src/client/reader/message/ThumbnailInfo.ts | 24 + src/client/reader/message/Unsigned.ts | 27 + src/client/reader/message/index.ts | 10 + src/client/request.ts | 123 ++ src/client/responseProgress.ts | 41 + src/client/room/ThreadMeta.ts | 19 + src/client/room/ThreadTimeline.ts | 303 +++ src/client/room/Timeline.ts | 349 +++ src/client/room/TimelineBatchManager.ts | 127 ++ src/client/room/TimelineUtils.ts | 57 + src/client/room/TimelineView.ts | 23 + src/client/room/index.ts | 96 + src/client/room/local-event/LocalEvent.ts | 29 + .../room/local-event/LocalEventOutbox.ts | 53 + .../room/local-event/LocalEventSender.ts | 111 + src/client/room/local-event/index.ts | 2 + src/client/room/messages/BatchChange.ts | 14 + src/client/room/messages/BatchInfo.ts | 10 + src/client/room/messages/MessageBatch.ts | 43 + src/client/room/messages/MessageRange.ts | 90 + src/client/room/messages/Messages.ts | 82 + src/client/room/messages/index.ts | 5 + src/client/room/relations/Relations.ts | 57 + .../room/relations/RelationsProcessor.ts | 98 + src/client/room/relations/index.ts | 3 + src/client/room/relations/utils.ts | 36 + src/client/storage/collections/AccountData.ts | 28 + src/client/storage/collections/InviteRoom.ts | 37 + src/client/storage/collections/JoinRoom.ts | 176 ++ src/client/storage/collections/KnockRoom.ts | 37 + src/client/storage/collections/LeaveRoom.ts | 80 + src/client/storage/collections/Presence.ts | 31 + src/client/storage/collections/ReadReceipt.ts | 44 + .../storage/collections/SessionValue.ts | 65 + .../collections/UnreadNotifications.ts | 71 + src/client/storage/collections/index.ts | 9 + src/client/storage/index.ts | 153 ++ src/client/storage/utils.ts | 2 + src/client/sync.ts | 177 ++ src/client/syncLatestEvent.ts | 27 + src/client/syncWriter/index.ts | 113 + src/client/syncWriter/utils.ts | 660 ++++++ src/client/txn.ts | 12 + src/types/matrix/accountData.ts | 55 + src/types/matrix/common.ts | 80 +- src/types/matrix/room.ts | 75 +- 313 files changed, 15726 insertions(+), 8143 deletions(-) create mode 100644 .zed/settings.json delete mode 100644 src/app/components/RoomUnreadProvider.tsx create mode 100644 src/app/components/encrypted-event/EncryptedEvent.tsx create mode 100644 src/app/components/encrypted-event/index.ts create mode 100644 src/app/features/room/FileDropDialog.tsx create mode 100644 src/app/features/room/OutboxView.tsx create mode 100644 src/app/features/room/components/JumpToLatestButton.tsx create mode 100644 src/app/features/room/components/TimelineDivider.tsx create mode 100644 src/app/features/room/components/TimelineFloat.tsx create mode 100644 src/app/features/room/components/index.ts rename src/app/features/room/{RoomTimeline.css.ts => components/styles.css.ts} (100%) create mode 100644 src/app/features/room/hooks/useEnsureRoomEncryptionSession.ts create mode 100644 src/app/features/room/hooks/useSendMessage.ts create mode 100644 src/app/features/room/hooks/useTimeline.ts create mode 100644 src/app/features/room/hooks/useUploadItemPicker.ts create mode 100644 src/app/features/room/hooks/useUploadItems.ts create mode 100644 src/app/features/room/hooks/useUploadsAtom.ts create mode 100644 src/app/features/room/message/thread-selector/ThreadSelector.tsx create mode 100644 src/app/features/room/message/thread-selector/index.ts create mode 100644 src/app/features/room/message/thread-selector/styles.css.ts create mode 100644 src/app/features/room/thread-view/ThreadView.tsx create mode 100644 src/app/features/room/thread-view/index.ts create mode 100644 src/app/features/room/thread-view/styles.css.ts create mode 100644 src/app/hooks/storage/useAccountData.ts create mode 100644 src/app/hooks/storage/useGetRoomAvatar.ts create mode 100644 src/app/hooks/storage/useGetRoomName.ts create mode 100644 src/app/hooks/storage/useRoomHeros.ts create mode 100644 src/app/hooks/storage/useRoomStateEvent.ts create mode 100644 src/app/hooks/storage/useRoomTopic.ts create mode 100644 src/app/hooks/useClient.ts create mode 100644 src/app/hooks/useNetwork.ts create mode 100644 src/app/hooks/useObservableValue.ts create mode 100644 src/app/hooks/usePendingMembers.ts create mode 100644 src/app/hooks/useRecoveryAttempt.ts create mode 100644 src/app/hooks/useRequest.ts create mode 100644 src/app/hooks/useSecondsCountdown.ts rename src/app/pages/{ConfigConfig.tsx => ClientConfig.tsx} (72%) create mode 100644 src/app/pages/client/InitialSyncSplashScreen.tsx create mode 100644 src/app/state/hooks/roomNotifications.ts create mode 100644 src/app/state/room/roomToNotifications.ts delete mode 100644 src/app/utils/disposable.ts create mode 100644 src/app/utils/randomString.ts create mode 100644 src/client/LogoutManager.ts create mode 100644 src/client/MachineManager.ts create mode 100644 src/client/SyncResponseManager.ts create mode 100644 src/client/context.ts create mode 100644 src/client/decryption/DecryptionManager.ts create mode 100644 src/client/decryption/EncryptedEvent.ts create mode 100644 src/client/decryption/EncryptedEventIndex.ts create mode 100644 src/client/decryption/index.ts create mode 100644 src/client/filter.ts create mode 100644 src/client/initialize.ts create mode 100644 src/client/mxcToHttp.ts create mode 100644 src/client/reader/Error.ts create mode 100644 src/client/reader/Event.ts create mode 100644 src/client/reader/index.ts create mode 100644 src/client/reader/message/Content.ts create mode 100644 src/client/reader/message/EncryptedFile.ts create mode 100644 src/client/reader/message/InReplyTo.ts create mode 100644 src/client/reader/message/Info.ts create mode 100644 src/client/reader/message/Mentions.ts create mode 100644 src/client/reader/message/RelatesTo.ts create mode 100644 src/client/reader/message/Relations.ts create mode 100644 src/client/reader/message/Thread.ts create mode 100644 src/client/reader/message/ThumbnailInfo.ts create mode 100644 src/client/reader/message/Unsigned.ts create mode 100644 src/client/reader/message/index.ts create mode 100644 src/client/request.ts create mode 100644 src/client/responseProgress.ts create mode 100644 src/client/room/ThreadMeta.ts create mode 100644 src/client/room/ThreadTimeline.ts create mode 100644 src/client/room/Timeline.ts create mode 100644 src/client/room/TimelineBatchManager.ts create mode 100644 src/client/room/TimelineUtils.ts create mode 100644 src/client/room/TimelineView.ts create mode 100644 src/client/room/index.ts create mode 100644 src/client/room/local-event/LocalEvent.ts create mode 100644 src/client/room/local-event/LocalEventOutbox.ts create mode 100644 src/client/room/local-event/LocalEventSender.ts create mode 100644 src/client/room/local-event/index.ts create mode 100644 src/client/room/messages/BatchChange.ts create mode 100644 src/client/room/messages/BatchInfo.ts create mode 100644 src/client/room/messages/MessageBatch.ts create mode 100644 src/client/room/messages/MessageRange.ts create mode 100644 src/client/room/messages/Messages.ts create mode 100644 src/client/room/messages/index.ts create mode 100644 src/client/room/relations/Relations.ts create mode 100644 src/client/room/relations/RelationsProcessor.ts create mode 100644 src/client/room/relations/index.ts create mode 100644 src/client/room/relations/utils.ts create mode 100644 src/client/storage/collections/AccountData.ts create mode 100644 src/client/storage/collections/InviteRoom.ts create mode 100644 src/client/storage/collections/JoinRoom.ts create mode 100644 src/client/storage/collections/KnockRoom.ts create mode 100644 src/client/storage/collections/LeaveRoom.ts create mode 100644 src/client/storage/collections/Presence.ts create mode 100644 src/client/storage/collections/ReadReceipt.ts create mode 100644 src/client/storage/collections/SessionValue.ts create mode 100644 src/client/storage/collections/UnreadNotifications.ts create mode 100644 src/client/storage/collections/index.ts create mode 100644 src/client/storage/index.ts create mode 100644 src/client/storage/utils.ts create mode 100644 src/client/sync.ts create mode 100644 src/client/syncLatestEvent.ts create mode 100644 src/client/syncWriter/index.ts create mode 100644 src/client/syncWriter/utils.ts create mode 100644 src/client/txn.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d5a27d9..dca01a3d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -31,12 +31,15 @@ module.exports = { 'linebreak-style': 0, 'no-underscore-dangle': 0, "no-shadow": "off", + "max-classes-per-file": "off", + "no-useless-constructor": "off", + "no-empty-function": "off", "import/prefer-default-export": "off", "import/extensions": "off", "import/no-unresolved": "off", "import/no-extraneous-dependencies": [ - "error", + "off", { devDependencies: true, }, @@ -58,8 +61,10 @@ module.exports = { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", + "@typescript-eslint/no-useless-constructor": "error", + "@typescript-eslint/no-empty-function": "error", "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/no-shadow": "error" + "@typescript-eslint/no-shadow": "error", }, overrides: [ { diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000..83b47ade --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,7 @@ +{ + "format_on_save": "on", + "prettier": { + "parser": "", + "allowed": true + } +} diff --git a/package-lock.json b/package-lock.json index 158a6112..5c36b489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@fontsource/inter": "4.5.14", + "@matrix-org/matrix-sdk-crypto-wasm": "18.3.1", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -32,12 +33,13 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.4.0", + "folds": "2.7.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "2.5.2", + "idb": "8.0.3", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", @@ -2256,9 +2258,9 @@ } }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz", - "integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz", + "integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==", "license": "Apache-2.0", "engines": { "node": ">= 18" @@ -7157,9 +7159,9 @@ } }, "node_modules/folds": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz", - "integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.7.0.tgz", + "integrity": "sha512-YOWx0raqLignz7Jgw7G5f90fczKSVEUiNuLwgZo9MvZcHOlRvf6+77Oa/1B6Baq1Qj5mO/cNTLBNiA5yF9pMdQ==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", @@ -7713,10 +7715,10 @@ } }, "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" }, "node_modules/ieee754": { "version": "1.2.1", @@ -8649,6 +8651,15 @@ "node": ">=22.0.0" } }, + "node_modules/matrix-js-sdk/node_modules/@matrix-org/matrix-sdk-crypto-wasm": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz", + "integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==", + "license": "Apache-2.0", + "engines": { + "node": ">= 18" + } + }, "node_modules/matrix-js-sdk/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -11789,6 +11800,13 @@ "workbox-core": "7.3.0" } }, + "node_modules/workbox-background-sync/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-broadcast-update": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz", @@ -11935,6 +11953,13 @@ "workbox-core": "7.3.0" } }, + "node_modules/workbox-expiration/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-google-analytics": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz", diff --git a/package.json b/package.json index 7316dcce..619ad78e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@fontsource/inter": "4.5.14", + "@matrix-org/matrix-sdk-crypto-wasm": "18.3.1", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -43,12 +44,13 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.4.0", + "folds": "2.7.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "2.5.2", + "idb": "8.0.3", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx index f21bad04..14ba56b2 100644 --- a/src/app/components/AuthFlowsLoader.tsx +++ b/src/app/components/AuthFlowsLoader.tsx @@ -1,14 +1,18 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react'; -import { MatrixError, createClient } from 'matrix-js-sdk'; +import { getLogin, GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login'; +import { postRegister } from 'neurone/lib/api/matrix/client/v3/register'; +import { MatrixErrorData, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo'; -import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common'; +import { promiseFulfilledResult } from '../utils/common'; import { AuthFlows, RegisterFlowStatus, RegisterFlowsResponse, parseRegisterErrResp, } from '../hooks/useAuthFlows'; +import { getJSONRequest } from '../../client/request'; +import { AuthResponse } from '../utils/matrix-uia'; type AuthFlowsLoaderProps = { fallback?: () => ReactNode; @@ -19,24 +23,33 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr const autoDiscoveryInfo = useAutoDiscoveryInfo(); const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]); const [state, load] = useAsyncCallback( useCallback(async () => { - const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]); - const loginFlows = promiseFulfilledResult(result[0]); - const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined; - let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest }; + const loginEndpoint = getLogin({}); + const registerEndpoint = postRegister({}); - if (typeof registerResp === 'object' && registerResp.httpStatus) { - registerFlows = parseRegisterErrResp(registerResp); + const [loginResponse, registerResponse] = ( + await Promise.allSettled([requestJSON(loginEndpoint), requestJSON(registerEndpoint)]) + ).map(promiseFulfilledResult); + + let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest }; + const [registerError] = + typeof registerResponse === 'object' + ? await parseMatrixResp(registerResponse) + : []; + + if (registerError) { + registerFlows = parseRegisterErrResp(registerError); } - if (!loginFlows) { + if (!loginResponse) { throw new Error('Missing auth flow!'); } - if ('errcode' in loginFlows) { - throw new Error('Failed to load auth flow!'); + const [loginError, loginFlows] = await parseMatrixResp(loginResponse); + if (loginError) { + throw new Error(`Failed to load auth flow! ${printMatrixError(loginError)}`); } const authFlows: AuthFlows = { @@ -45,7 +58,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr }; return authFlows; - }, [mx]) + }, [requestJSON]) ); useEffect(() => { diff --git a/src/app/components/CapabilitiesLoader.tsx b/src/app/components/CapabilitiesLoader.tsx index 667191e9..bfdae4e5 100644 --- a/src/app/components/CapabilitiesLoader.tsx +++ b/src/app/components/CapabilitiesLoader.tsx @@ -1,15 +1,23 @@ import { ReactNode, useCallback, useEffect } from 'react'; -import { Capabilities } from 'matrix-js-sdk'; +import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useMatrixClient } from '../hooks/useMatrixClient'; +import { useClient } from '../hooks/useClient'; +import { Capabilities } from '../hooks/useCapabilities'; type CapabilitiesLoaderProps = { children: (capabilities: Capabilities | undefined) => ReactNode; }; export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx])); + const [state, load] = useAsyncCallback( + useCallback(async () => { + const resp = await ct.requestAuthJSON(getCapabilities({})); + const [, result] = await parseMatrixResp(resp); + return result?.capabilities; + }, [ct]) + ); useEffect(() => { load(); diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index cc9bf067..9fd9cdef 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,33 +1,19 @@ import React, { forwardRef, useCallback } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { logoutClient } from '../../client/initMatrix'; -import { useMatrixClient } from '../hooks/useMatrixClient'; -import { useCrossSigningActive } from '../hooks/useCrossSigning'; -import { InfoCard } from './info-card'; -import { - useDeviceVerificationStatus, - VerificationStatus, -} from '../hooks/useDeviceVerificationStatus'; +import { useClient } from '../hooks/useClient'; type LogoutDialogProps = { handleClose: () => void; }; export const LogoutDialog = forwardRef( ({ handleClose }, ref) => { - const mx = useMatrixClient(); - const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); - const crossSigningActive = useCrossSigningActive(); - const verificationStatus = useDeviceVerificationStatus( - mx.getCrypto(), - mx.getSafeUserId(), - mx.getDeviceId() ?? undefined - ); + const ct = useClient(); const [logoutState, logout] = useAsyncCallback( useCallback(async () => { - await logoutClient(mx); - }, [mx]) + await ct.logout(); + }, [ct]) ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; @@ -47,7 +33,7 @@ export const LogoutDialog = forwardRef( - {hasEncryptedRoom && + {/* {hasEncryptedRoom && (crossSigningActive ? ( verificationStatus === VerificationStatus.Unverified && ( ( title="Alert" description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages." /> - ))} + ))} */} You’re about to log out. Are you sure? {logoutState.status === AsyncStatus.Error && ( diff --git a/src/app/components/MediaConfigLoader.tsx b/src/app/components/MediaConfigLoader.tsx index 9fd99330..634973dd 100644 --- a/src/app/components/MediaConfigLoader.tsx +++ b/src/app/components/MediaConfigLoader.tsx @@ -1,15 +1,25 @@ import { ReactNode, useCallback, useEffect } from 'react'; +import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; +import { useClient } from '../hooks/useClient'; type MediaConfigLoaderProps = { children: (mediaConfig: MediaConfig | undefined) => ReactNode; }; export function MediaConfigLoader({ children }: MediaConfigLoaderProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx])); + const [state, load] = useAsyncCallback( + useCallback(async () => { + const resp = await ct.requestAuthJSON(getConfig({})); + + const [, config] = await parseMatrixResp(resp); + + return config; + }, [ct]) + ); useEffect(() => { load(); diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 4cfcb7dc..423fb1ff 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,147 +1,101 @@ import React from 'react'; -import { MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts } from 'linkifyjs'; import { config } from 'folds'; import { - AudioContent, - DownloadFile, - FileContent, - ImageContent, MAudio, - MBadEncrypted, MEmote, MFile, MImage, MLocation, MNotice, + MSticker, MText, MVideo, - ReadPdfFile, - ReadTextFile, - RenderBody, - ThumbnailContent, + RedactedContent, 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'; -import { testMatrixTo } from '../plugins/matrix-to'; -import { IImageContent } from '../../types/matrix/common'; +import { MsgType } from '../../types/matrix/common'; +import { rContent } from '../../client/reader'; +import { MessageEvent } from '../../types/matrix/room'; type RenderMessageContentProps = { displayName: string; - msgType: string; + eventType: string; ts: number; edited?: boolean; - getContent: () => T; + content: object; mediaAutoLoad?: boolean; urlPreview?: boolean; highlightRegex?: RegExp; htmlReactParserOptions: HTMLReactParserOptions; linkifyOpts: Opts; outlineAttachment?: boolean; + redacted?: boolean; }; export function RenderMessageContent({ displayName, - msgType, + eventType, ts, edited, - getContent, + content, mediaAutoLoad, urlPreview, highlightRegex, htmlReactParserOptions, linkifyOpts, outlineAttachment, + redacted, }: RenderMessageContentProps) { - const renderUrlsPreview = (urls: string[]) => { - const filteredUrls = urls.filter((url) => !testMatrixTo(url)); - if (filteredUrls.length === 0) return undefined; + if (redacted) return ; + + if (eventType === MessageEvent.Sticker) { return ( - - {filteredUrls.map((url) => ( - - ))} - + ); - }; + } + + if (eventType !== MessageEvent.RoomMessage) { + return ; + } + const renderCaption = () => { - const content: IImageContent = getContent(); - if (content.filename && content.filename !== content.body) { + const filename = rContent.filename(content); + const body = rContent.body(content); + + if (filename && filename !== body && body) { return ( ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } return null; }; - const renderFile = () => ( - <> - ( - ( - } - /> - )} - renderAsTextFile={() => ( - } - /> - )} - > - - - )} - outlined={outlineAttachment} - /> - {renderCaption()} - - ); + const msgType = rContent.msgtype(content); if (msgType === MsgType.Text) { return ( ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + content={content} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } @@ -151,16 +105,12 @@ export function RenderMessageContent({ ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + content={content} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } @@ -169,16 +119,12 @@ export function RenderMessageContent({ return ( ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + content={content} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } @@ -186,18 +132,7 @@ export function RenderMessageContent({ if (msgType === MsgType.Image) { return ( <> - ( - } - renderViewer={(p) => } - /> - )} - outlined={outlineAttachment} - /> + {renderCaption()} ); @@ -206,31 +141,7 @@ export function RenderMessageContent({ if (msgType === MsgType.Video) { return ( <> - ( - ( - ( - {body} - )} - /> - ) - : undefined - } - renderVideo={(p) => } before={ {dm ? ( ( - + + {nameInitials(name)} + )} /> ) : ( - + )} } > - {room.name} + {name} ); diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 7a8012eb..1d56325d 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -1,32 +1,29 @@ -import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import React, { useEffect, KeyboardEvent as ReactKeyboardEvent, useCallback } from 'react'; import { Editor } from 'slate'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; -import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteMenu } from './AutocompleteMenu'; -import { useRoomMembers } from '../../../hooks/useRoomMembers'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { - SearchItemStrGetter, - UseAsyncSearchOptions, - useAsyncSearch, -} from '../../../hooks/useAsyncSearch'; +import { GetRoomMemberMembershipCallback, useRoomMembersAPI } from '../../../hooks/useRoomMembers'; +import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { useKeyDown } from '../../../hooks/useKeyDown'; -import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; -import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; +import { getMxIdServer, isUserId } from '../../../utils/matrix'; +import { getMemberSearchStr } from '../../../utils/room'; import { UserAvatar } from '../../user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { Membership } from '../../../../types/matrix/room'; +import { useClient } from '../../../hooks/useClient'; +import { JoinRoomMemberObject } from '../../../../client/storage/collections'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; +import { nameInitials } from '../../../utils/common'; type MentionAutoCompleteHandler = (userId: string, name: string) => void; -const userIdFromQueryText = (mx: MatrixClient, text: string) => +const userIdFromQueryText = (myUserId: string, text: string) => isUserId(`@${text}`) ? `@${text}` - : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`; function UnknownMentionItem({ userId, @@ -62,16 +59,18 @@ function UnknownMentionItem({ } type UserMentionAutocompleteProps = { - room: Room; + roomId: string; editor: Editor; query: AutocompleteQuery; requestClose: () => void; }; -const withAllowedMembership = (member: RoomMember): boolean => - member.membership === Membership.Join || - member.membership === Membership.Invite || - member.membership === Membership.Knock; +const withAllowedMembership = + (getMembership: GetRoomMemberMembershipCallback) => + (member: JoinRoomMemberObject): boolean => + getMembership(member.userId) === 'join' || + getMembership(member.userId) === 'invite' || + getMembership(member.userId) === 'knock'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -80,25 +79,28 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = { }, }; -const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId; -const getRoomMemberStr: SearchItemStrGetter = (m, query) => - getMemberSearchStr(m, query, mxIdToName); +const EMPTY_MEMBER_LIST: JoinRoomMemberObject[] = []; export function UserMentionAutocomplete({ - room, + roomId, editor, query, requestClose, }: UserMentionAutocompleteProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const roomId: string = room.roomId!; - const roomAliasOrId = room.getCanonicalAlias() || roomId; - const members = useRoomMembers(mx, roomId); + const roomAliasOrId = ct.roomToAlias.read.get(roomId) || roomId; + const membersAPI = useRoomMembersAPI(); + const members = membersAPI.memberEventsResult.value ?? EMPTY_MEMBER_LIST; + + const [result, search, resetSearch] = useAsyncSearch( + members, + useCallback((m, q) => getMemberSearchStr(m, q, membersAPI.getName), [membersAPI.getName]), + SEARCH_OPTIONS + ); - const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter( - withAllowedMembership + withAllowedMembership(membersAPI.getMembership) ); useEffect(() => { @@ -110,7 +112,7 @@ export function UserMentionAutocomplete({ const mentionEl = createMentionElement( uId, name.startsWith('@') ? name : `@${name}`, - mx.getUserId() === uId || roomAliasOrId === uId + uId === ct.session.userId || roomAliasOrId === uId ); replaceWithElement(editor, query.range, mentionEl); moveCursor(editor, true); @@ -124,18 +126,15 @@ export function UserMentionAutocomplete({ return; } if (autoCompleteMembers.length === 0) { - const userId = userIdFromQueryText(mx, query.text); + const userId = userIdFromQueryText(ct.session.userId, query.text); handleAutocomplete(userId, userId); return; } const roomMember = autoCompleteMembers[0]; - handleAutocomplete(roomMember.userId, roomMember.name); + handleAutocomplete(roomMember.userId, membersAPI.getName(roomMember.userId)); }); }); - const getName = (member: RoomMember) => - getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; - return ( Mentions} requestClose={requestClose}> {query.text === 'room' && ( @@ -147,15 +146,20 @@ export function UserMentionAutocomplete({ )} {autoCompleteMembers.length === 0 ? ( ) : ( autoCompleteMembers.map((roomMember) => { - const avatarMxcUrl = roomMember.getMxcAvatarUrl(); + const name = membersAPI.getName(roomMember.userId); + const avatarMxcUrl = membersAPI.getAvatar(roomMember.userId); const avatarUrl = avatarMxcUrl - ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) + ? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({ + width: 32, + height: 32, + method: 'crop', + }) : undefined; return ( ) => - onTabPress(evt, () => handleAutocomplete(roomMember.userId, getName(roomMember))) + onTabPress(evt, () => handleAutocomplete(roomMember.userId, name)) } - onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))} + onClick={() => handleAutocomplete(roomMember.userId, name)} after={ {roomMember.userId} @@ -176,14 +180,18 @@ export function UserMentionAutocomplete({ } + alt={name} + renderFallback={() => ( + + {nameInitials(name)} + + )} /> } > - {getName(roomMember)} + {name} ); diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index dbdd51f3..76454885 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -1,5 +1,4 @@ import { Descendant, Editor, Text } from 'slate'; -import { MatrixClient } from 'matrix-js-sdk'; import { sanitizeText } from '../../utils/sanitize'; import { BlockType } from './types'; import { CustomElement } from './slate'; @@ -11,7 +10,8 @@ import { } from '../../plugins/markdown'; import { findAndReplace } from '../../utils/findAndReplace'; import { sanitizeForRegex } from '../../utils/regex'; -import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix'; +import { isUserId } from '../../utils/matrix'; +import { ClientContext } from '../../../client/context'; export type OutputOptions = { allowTextFormatting?: boolean; @@ -201,7 +201,7 @@ export type MentionsData = { room: boolean; users: Set; }; -export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => { +export const getMentions = (ct: ClientContext, editor: Editor): MentionsData => { const mentionData: MentionsData = { room: false, users: new Set(), @@ -212,10 +212,10 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M if (node.type === BlockType.CodeBlock) return; if (node.type === BlockType.Mention) { - if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) { + if (node.name === '@room') { mentionData.room = true; } - if (isUserId(node.id) && node.id !== mx.getUserId()) { + if (isUserId(node.id) && node.id !== ct.session.userId) { mentionData.users.add(node.id); } diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 3db27e2a..eab41f9f 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -12,7 +12,6 @@ import React, { import { Box, config, Icons, Scroll } from 'folds'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; -import { Room } from 'matrix-js-sdk'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji'; @@ -20,9 +19,8 @@ import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { useRelevantImagePacks } from '../../hooks/useImagePacks'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../hooks/useRecentEmoji'; -import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; +import { isUserId } from '../../utils/matrix'; import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; @@ -52,6 +50,10 @@ import { } from './components'; import { EmojiBoardTab, EmojiType } from './types'; import { VirtualTile } from '../virtualizer'; +import { useClient } from '../../hooks/useClient'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { GetRoomNameCallback, useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { mxcToHttp } from '../../../client/mxcToHttp'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -69,11 +71,10 @@ type StickerGroupItem = { const useGroups = ( tab: EmojiBoardTab, - imagePacks: ImagePack[] + imagePacks: ImagePack[], + getRoomName: GetRoomNameCallback ): [EmojiGroupItem[], StickerGroupItem[]] => { - const mx = useMatrixClient(); - - const recentEmojis = useRecentEmoji(mx, 21); + const recentEmojisResult = useRecentEmoji(21); const labels = useEmojiGroupLabels(); const emojiGroupItems = useMemo(() => { @@ -83,12 +84,12 @@ const useGroups = ( g.push({ id: RECENT_GROUP_ID, name: 'Recent', - items: recentEmojis, + items: recentEmojisResult.value ?? [], }); imagePacks.forEach((pack) => { let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id); g.push({ id: pack.id, @@ -108,7 +109,7 @@ const useGroups = ( }); return g; - }, [mx, recentEmojis, labels, imagePacks, tab]); + }, [getRoomName, recentEmojisResult, labels, imagePacks, tab]); const stickerGroupItems = useMemo(() => { const g: StickerGroupItem[] = []; @@ -116,7 +117,7 @@ const useGroups = ( imagePacks.forEach((pack) => { let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id); g.push({ id: pack.id, @@ -128,37 +129,24 @@ const useGroups = ( }); return g; - }, [mx, imagePacks, tab]); + }, [getRoomName, imagePacks, tab]); return [emojiGroupItems, stickerGroupItems]; }; const useItemRenderer = (tab: EmojiBoardTab) => { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const renderItem = (emoji: IEmoji | PackImageReader, index: number) => { if ('unicode' in emoji) { return ; } + const url = mxcToHttp(ct.session.baseUrl, emoji.url, useAuthentication).url(); if (tab === EmojiBoardTab.Sticker) { - return ( - - ); + return ; } - return ( - - ); + return ; }; return renderItem; @@ -168,9 +156,10 @@ type EmojiSidebarProps = { activeGroupAtom: PrimitiveAtom; packs: ImagePack[]; onScrollToGroup: (groupId: string) => void; + getRoomName: GetRoomNameCallback; }; -function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) { - const mx = useMatrixClient(); +function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup, getRoomName }: EmojiSidebarProps) { + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); @@ -199,11 +188,12 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP {packs.map((pack) => { let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id); - const url = - mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || - pack.meta.avatar; + const avatarMxc = pack.getAvatarUrl(usage); + const url = avatarMxc + ? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url() + : undefined; return ( ; packs: ImagePack[]; onScrollToGroup: (groupId: string) => void; + getRoomName: GetRoomNameCallback; }; -function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) { - const mx = useMatrixClient(); +function StickerSidebar({ + activeGroupAtom, + packs, + onScrollToGroup, + getRoomName, +}: StickerSidebarProps) { + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); @@ -263,10 +259,12 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide {packs.map((pack) => { let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id); - const url = - mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar; + const avatarMxc = pack.getAvatarUrl(usage); + const url = avatarMxc + ? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url() + : undefined; return ( void; - imagePackRooms: Room[]; + imagePackRooms: string[]; requestClose: () => void; returnFocusOnDeactivate?: boolean; onEmojiSelect?: (unicode: string, shortcode: string) => void; @@ -377,7 +375,9 @@ export function EmojiBoard({ allowTextCustomEmoji, addToRecentEmoji = true, }: EmojiBoardProps) { - const mx = useMatrixClient(); + const ct = useClient(); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(imagePackRooms); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); const emojiTab = tab === EmojiBoardTab.Emoji; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; @@ -388,8 +388,8 @@ export function EmojiBoard({ ); const activeGroupIdAtom = useMemo(() => atom(undefined), []); const setActiveGroupId = useSetAtom(activeGroupIdAtom); - const imagePacks = useRelevantImagePacks(usage, imagePackRooms); - const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); + const [imagePacks, loading] = useRelevantImagePacks(usage, imagePackRooms); + const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, getRoomName); const groups = emojiTab ? emojiGroupItems : stickerGroupItems; const renderItem = useItemRenderer(tab); @@ -438,7 +438,7 @@ export function EmojiBoard({ if (emojiInfo.type === EmojiType.Emoji) { onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) { - addRecentEmoji(mx, emojiInfo.data); + addRecentEmoji(ct, emojiInfo.data); } } if (emojiInfo.type === EmojiType.CustomEmoji) { @@ -522,57 +522,61 @@ export function EmojiBoard({ activeGroupAtom={activeGroupIdAtom} packs={imagePacks} onScrollToGroup={handleScrollToGroup} + getRoomName={getRoomName} /> ) : ( ) } > - - {searchedItems && ( - - {searchedItems.map(renderItem)} - - )} -
- {vItems.map((vItem) => { - const group = groups[vItem.index]; + {searchedItems && ( + + {searchedItems.map(renderItem)} + + )} +
+ {vItems.map((vItem) => { + const group = groups[vItem.index]; - return ( - - - {group.items.map(renderItem)} - - - ); - })} -
- {tab === EmojiBoardTab.Sticker && groups.length === 0 && } - + return ( + + + {group.items.map(renderItem)} + + + ); + })} +
+ {tab === EmojiBoardTab.Sticker && groups.length === 0 && } +
+ )}
diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index c3fd3c3a..021ce1b2 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -1,11 +1,9 @@ import React from 'react'; import { Box } from 'folds'; -import { MatrixClient } from 'matrix-js-sdk'; import { EmojiItemInfo, EmojiType } from '../types'; import * as css from './styles.css'; import { PackImageReader } from '../../../plugins/custom-emoji'; import { IEmoji } from '../../../plugins/emoji'; -import { mxcUrlToHttp } from '../../../utils/matrix'; export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => { const label = element.getAttribute('title'); @@ -46,11 +44,10 @@ export function EmojiItem({ emoji }: EmojiItemProps) { } type CustomEmojiItemProps = { - mx: MatrixClient; - useAuthentication?: boolean; + url?: string; image: PackImageReader; }; -export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) { +export function CustomEmojiItem({ url, image }: CustomEmojiItemProps) { return ( ); } type StickerItemProps = { - mx: MatrixClient; - useAuthentication?: boolean; + url?: string; image: PackImageReader; }; -export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) { +export function StickerItem({ url, image }: StickerItemProps) { return ( ); diff --git a/src/app/components/emoji-board/components/Preview.tsx b/src/app/components/emoji-board/components/Preview.tsx index 3f5f8d3a..093aa455 100644 --- a/src/app/components/emoji-board/components/Preview.tsx +++ b/src/app/components/emoji-board/components/Preview.tsx @@ -2,9 +2,9 @@ import { Box, Text } from 'folds'; import React from 'react'; import { Atom, atom, useAtomValue } from 'jotai'; import * as css from './styles.css'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { mxcUrlToHttp } from '../../../utils/matrix'; +import { useClient } from '../../../hooks/useClient'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; export type PreviewData = { key: string; @@ -18,7 +18,7 @@ type PreviewProps = { previewAtom: Atom; }; export function Preview({ previewAtom }: PreviewProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const { key, shortcode } = useAtomValue(previewAtom) ?? {}; @@ -37,7 +37,7 @@ export function Preview({ previewAtom }: PreviewProps) { {key.startsWith('mxc://') ? ( {shortcode} ) : ( diff --git a/src/app/components/encrypted-event/EncryptedEvent.tsx b/src/app/components/encrypted-event/EncryptedEvent.tsx new file mode 100644 index 00000000..692aa725 --- /dev/null +++ b/src/app/components/encrypted-event/EncryptedEvent.tsx @@ -0,0 +1,109 @@ +import React, { ReactNode } from 'react'; +import { as, Box, color, config, Icon, Icons, Text } from 'folds'; +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { + DecryptionManager, + EventDecryptionIdle, + EventDecryptionPending, + EventDecryptionSuccess, +} from '../../../client/decryption'; +import { useObservableValue } from '../../hooks/useObservableValue'; + +export const MessageDecryptionIdle = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + Decrypt message event to view. + +)); + +export const MessageDecryptionPending = as<'div', { children?: never }>(({ ...props }, ref) => ( + + + Waiting for message to be decrypted... + +)); + +export const MessageDecryptionError = as<'div', { message: string; children?: never }>( + ({ message, ...props }, ref) => ( + + + {message} + + ) +); + +type EncryptedMessageEventProps = { + decryptionManager: DecryptionManager; + roomId: string; + event: MessageEventWithoutRoomId; + children: (decryptedEvent: MessageEventWithoutRoomId) => ReactNode; +}; +export function EncryptedMessageEvent({ + decryptionManager, + roomId, + event, + children, +}: EncryptedMessageEventProps) { + const encryptedEvent = decryptionManager.decryptableMessageEvent(roomId, event); + if (encryptedEvent.decryption.read instanceof EventDecryptionIdle) { + encryptedEvent.decrypt(); + } + const decryption = useObservableValue(encryptedEvent.decryption); + + if (decryption instanceof EventDecryptionIdle) { + return ( + + + + ); + } + + if (decryption instanceof EventDecryptionPending) { + return ( + + + + ); + } + + if (decryption instanceof EventDecryptionSuccess) { + return children(decryption.event); + } + + const { cause, error } = decryption; + + if (cause) { + return ( + + + + ); + } + + if (!error) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/src/app/components/encrypted-event/index.ts b/src/app/components/encrypted-event/index.ts new file mode 100644 index 00000000..0a307315 --- /dev/null +++ b/src/app/components/encrypted-event/index.ts @@ -0,0 +1 @@ +export * from './EncryptedEvent'; diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c7900237..e4291e02 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import classNames from 'classnames'; import { Avatar, @@ -13,33 +13,46 @@ import { as, config, } from 'folds'; -import { Room } from 'matrix-js-sdk'; -import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; -import { getMemberDisplayName } from '../../utils/room'; -import { getMxIdLocalPart } from '../../utils/matrix'; import * as css from './EventReaders.css'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UserAvatar } from '../user-avatar'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getMouseEventCords } from '../../utils/dom'; +import { useClient } from '../../hooks/useClient'; +import { useObservableValuePromise } from '../../hooks/useObservableValue'; +import { useRoomMembersAPI } from '../../hooks/useRoomMembers'; +import { mxcToHttp } from '../../../client/mxcToHttp'; +import { useRoom } from '../../hooks/useRoom'; export type EventReadersProps = { - room: Room; eventId: string; requestClose: () => void; }; export const EventReaders = as<'div', EventReadersProps>( - ({ className, room, eventId, requestClose, ...props }, ref) => { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const latestEventReaders = useRoomEventReaders(room, eventId); - const openProfile = useOpenUserRoomProfile(); - const space = useSpaceOptionally(); + ({ className, eventId, requestClose, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); + const membersAPI = useRoomMembersAPI(); - const getName = (userId: string) => - getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const eventReceipt = useObservableValuePromise( + useMemo( + () => ct.storage.readReceipt.observableRead.queryIndex('eventId', eventId), + [ct, eventId] + ) + ); + + const users: string[] = useMemo(() => { + const u = new Set(); + eventReceipt.value?.forEach((r) => { + u.add(r.userId); + }); + return Array.from(u); + }, [eventReceipt]); + + const useAuthentication = useMediaAuthentication(); + const openProfile = useOpenUserRoomProfile(); + const spaceId = useSpaceOptionally(); return ( ( - {latestEventReaders.map((readerId) => { - const name = getName(readerId); - const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); + {users.map((readerId) => { + const name = membersAPI.getName(readerId); + const avatarMxcUrl = membersAPI.getAvatar(readerId); const avatarUrl = avatarMxcUrl - ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) + ? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; return ( @@ -81,8 +90,8 @@ export const EventReaders = as<'div', EventReadersProps>( radii="400" onClick={(event) => { openProfile( - room.roomId, - space?.roomId, + roomId, + spaceId, readerId, getMouseEventCords(event.nativeEvent), 'Bottom' diff --git a/src/app/components/image-pack-view/ImagePackContent.tsx b/src/app/components/image-pack-view/ImagePackContent.tsx index a696ebe8..ff4c7dff 100644 --- a/src/app/components/image-pack-view/ImagePackContent.tsx +++ b/src/app/components/image-pack-view/ImagePackContent.tsx @@ -19,7 +19,7 @@ import * as css from './style.css'; import { useFilePicker } from '../../hooks/useFilePicker'; import { CompactUploadCardRenderer } from '../upload-card'; import { UploadSuccess } from '../../state/upload'; -import { getImageInfo, TUploadContent } from '../../utils/matrix'; +import { getImageInfo, UploadContent } from '../../utils/matrix'; import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom'; import { replaceSpaceWithDash, suffixRename } from '../../utils/common'; import { getFileNameWithoutExt } from '../../utils/mimeTypes'; @@ -110,7 +110,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>( [imagePack.meta] ); - const handleUploadRemove = useCallback((file: TUploadContent) => { + const handleUploadRemove = useCallback((file: UploadContent) => { setFiles((fs) => fs.filter((f) => f !== file)); }, []); diff --git a/src/app/components/image-pack-view/ImageTile.tsx b/src/app/components/image-pack-view/ImageTile.tsx index 37337ff7..959426f4 100644 --- a/src/app/components/image-pack-view/ImageTile.tsx +++ b/src/app/components/image-pack-view/ImageTile.tsx @@ -7,7 +7,7 @@ import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/cust import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SettingTile } from '../setting-tile'; import { useObjectURL } from '../../hooks/useObjectURL'; -import { createUploadAtom, TUploadAtom } from '../../state/upload'; +import { createUploadAtom, UploadAtom } from '../../state/upload'; import { replaceSpaceWithDash } from '../../utils/common'; type ImageTileProps = { @@ -91,7 +91,7 @@ export function ImageTile({ type ImageTileUploadProps = { file: File; - children: (uploadAtom: TUploadAtom) => ReactNode; + children: (uploadAtom: UploadAtom) => ReactNode; }; export function ImageTileUpload({ file, children }: ImageTileUploadProps) { const url = useObjectURL(file); diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index ddac0576..57f54c0d 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -3,7 +3,6 @@ import React, { FormEventHandler, KeyboardEventHandler, useCallback, - useMemo, useRef, useState, } from 'react'; @@ -29,19 +28,18 @@ import { Scroll, MenuItem, } from 'folds'; -import { Room } from 'matrix-js-sdk'; import { isKeyHotkey } from 'is-hotkey'; import FocusTrap from 'focus-trap-react'; +import { postInvite } from 'neurone/lib/api/matrix/client/v3/rooms'; import { stopPropagation } from '../../utils/keyboard'; import { useDirectUsers } from '../../hooks/useDirectUsers'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; -import { Membership } from '../../../types/matrix/room'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useAlive } from '../../hooks/useAlive'; +import { useClient } from '../../hooks/useClient'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -52,27 +50,19 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = { const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; type InviteUserProps = { - room: Room; + roomId: string; requestClose: () => void; }; -export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { - const mx = useMatrixClient(); +export function InviteUserPrompt({ roomId, requestClose }: InviteUserProps) { + const ct = useClient(); const alive = useAlive(); const inputRef = useRef(null); const directUsers = useDirectUsers(); const [validUserId, setValidUserId] = useState(); - const filteredUsers = useMemo( - () => - directUsers.filter((userId) => { - const membership = room.getMember(userId)?.membership; - return membership !== Membership.Join; - }), - [directUsers, room] - ); const [result, search, resetSearch] = useAsyncSearch( - filteredUsers, + directUsers, getUserIdString, SEARCH_OPTIONS ); @@ -83,9 +73,17 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { const [inviteState, invite] = useAsyncCallback( useCallback( async (userId, reason) => { - await mx.invite(room.roomId, userId, reason); + await ct.requestAuthJSON( + postInvite({ + pathParams: { roomId }, + body: { + user_id: userId, + reason, + }, + }) + ); }, - [mx, room] + [ct, roomId] ) ); diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e6..eba75721 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -16,10 +16,16 @@ import { Button, Spinner, } from 'folds'; -import { MatrixError } from 'matrix-js-sdk'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { + MatrixResponseResult, + parseMatrixResp, + printMatrixError, +} from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; +import { useClient } from '../../hooks/useClient'; +import { deleteJoinRooms } from '../../../client/syncWriter/utils'; type LeaveRoomPromptProps = { roomId: string; @@ -27,23 +33,31 @@ type LeaveRoomPromptProps = { onCancel: () => void; }; export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [leaveState, leaveRoom] = useAsyncCallback( + const [leaveState, leaveRoom] = useAsyncCallback, Error, []>( useCallback(async () => { - mx.leave(roomId); - }, [mx, roomId]) + const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } })); + deleteJoinRooms(ct, [roomId]) + .then(() => console.log('=====> deleted')) + .catch((e) => { + console.error(e); + }); + return parseMatrixResp(response); + }, [ct, roomId]) ); + const success = leaveState.status === AsyncStatus.Success; + const [requestError] = success ? leaveState.data : []; const handleLeave = () => { leaveRoom(); }; useEffect(() => { - if (leaveState.status === AsyncStatus.Success) { + if (success) { onDone(); } - }, [leaveState, onDone]); + }, [success, onDone]); return ( }> @@ -80,6 +94,11 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro Failed to leave room! {leaveState.error.message} )} + {requestError && ( + + Failed to leave room! {printMatrixError(requestError)} + + )} {invitePrompt && ( - setInvitePrompt(false)} /> + setInvitePrompt(false)} /> + )} + {typeof prevRoomId === 'string' && ( + )} - {typeof prevRoomId === 'string' && - (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? ( - - ) : ( - - ))}
diff --git a/src/app/components/splash-screen/SplashScreen.css.ts b/src/app/components/splash-screen/SplashScreen.css.ts index bd3c300a..3cb6a483 100644 --- a/src/app/components/splash-screen/SplashScreen.css.ts +++ b/src/app/components/splash-screen/SplashScreen.css.ts @@ -1,5 +1,5 @@ import { style } from '@vanilla-extract/css'; -import { color, config } from 'folds'; +import { color, config, toRem } from 'folds'; export const SplashScreen = style({ minHeight: '100%', @@ -7,6 +7,11 @@ export const SplashScreen = style({ color: color.Background.OnContainer, }); +export const SplashLogo = style({ + width: toRem(42), + height: toRem(42), +}); + export const SplashScreenFooter = style({ padding: config.space.S400, }); diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx index 27adadba..7df70c4a 100644 --- a/src/app/components/splash-screen/SplashScreen.tsx +++ b/src/app/components/splash-screen/SplashScreen.tsx @@ -3,26 +3,33 @@ import React, { ReactNode } from 'react'; import classNames from 'classnames'; import * as patternsCSS from '../../styles/Patterns.css'; import * as css from './SplashScreen.css'; +import CinnySVG from '../../../../public/res/svg/cinny.svg'; type SplashScreenProps = { - children: ReactNode; + footer?: ReactNode; + children?: ReactNode; }; -export function SplashScreen({ children }: SplashScreenProps) { +export function SplashScreen({ footer, children }: SplashScreenProps) { return ( - {children} + {children || ( + + Cinny Logo + + Cinny + + + )} - - Cinny - + {footer} ); diff --git a/src/app/components/uia-stages/DummyStage.tsx b/src/app/components/uia-stages/DummyStage.tsx index 7e0f8586..ce19ab26 100644 --- a/src/app/components/uia-stages/DummyStage.tsx +++ b/src/app/components/uia-stages/DummyStage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useCallback } from 'react'; import { Dialog, Text, Box, Button, config } from 'folds'; -import { AuthType } from 'matrix-js-sdk'; import { StageComponentProps } from './types'; +import { AuthType } from '../../utils/matrix-uia'; function DummyErrorDialog({ title, diff --git a/src/app/components/uia-stages/EmailStage.tsx b/src/app/components/uia-stages/EmailStage.tsx index fdc2b61a..fd2cc3d0 100644 --- a/src/app/components/uia-stages/EmailStage.tsx +++ b/src/app/components/uia-stages/EmailStage.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useCallback, FormEventHandler } from 'react'; import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds'; -import { AuthType, MatrixError } from 'matrix-js-sdk'; +import { printMatrixError } from 'neurone/lib/matrix-request'; import { StageComponentProps } from './types'; import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback'; -import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types'; +import { RequestEmailTokenCallback, RequestEmailTokenResult } from '../../hooks/types'; +import { AuthType } from '../../utils/matrix-uia'; function EmailErrorDialog({ title, @@ -77,10 +78,14 @@ export function EmailStageDialog({ }: StageComponentProps & { email?: string; clientSecret: string; - emailTokenState: AsyncState; + emailTokenState: AsyncState; requestEmailToken: RequestEmailTokenCallback; }) { const { errorCode, error, session } = stageData; + const [matrixError, result] = + emailTokenState.status === AsyncStatus.Success ? emailTokenState.data : []; + const requestError = + emailTokenState.status === AsyncStatus.Error ? emailTokenState.error : undefined; const handleSubmit = useCallback( (sessionId: string) => { @@ -120,14 +125,23 @@ export function EmailStageDialog({ ); } - if (emailTokenState.status === AsyncStatus.Error) { + if (requestError) { return ( + ); + } + + if (matrixError) { + return ( + Verification Request Sent - {`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`} + {`Please check your email "${result.email}" and validate before continuing further.`} {errorCode && ( {`${errorCode}: ${error}`} )} - diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0f837594..4d7c40d2 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,16 +1,11 @@ import React, { useCallback, useRef } from 'react'; import { Box, Text, config } from 'folds'; -import { EventType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; -import { useStateEvent } from '../../hooks/useStateEvent'; -import { StateEvent } from '../../../types/matrix/room'; import { 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, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; @@ -22,6 +17,12 @@ import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoom } from '../../hooks/useRoom'; +import { useObservableValue } from '../../hooks/useObservableValue'; +import { useClient } from '../../hooks/useClient'; +import { MessageEvent } from '../../../types/matrix/room'; +import { useRoomMembersAPI } from '../../hooks/useRoomMembers'; +import { Timeline } from '../../../client/room'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -56,23 +57,25 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return true; }; -export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { +export function RoomView({ eventId, timeline }: { eventId?: string; timeline: Timeline }) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const { roomId } = room; + const roomId = useRoom(); const editor = useEditor(); + const ct = useClient(); + const { memberEventsResult } = useRoomMembersAPI(); - const mx = useMatrixClient(); + const roomToReplacement = useObservableValue(ct.roomToReplacement); + const replacementRoom = roomToReplacement.get(roomId); - const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); + const creators = useRoomCreators(roomId); const powerLevels = usePowerLevelsContext(); - const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); - const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + const canMessage = permissions.event(MessageEvent.RoomMessage, ct.session.userId); useKeyDown( window, @@ -95,33 +98,24 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { - - + {timeline && ( + + )}
- {tombstoneEvent ? ( - + {replacementRoom ? ( + ) : ( <> {canMessage && ( - + )} {!canMessage && ( )}
- {hideActivity ? : } + {hideActivity ? : }
); diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx index 5a96e6ad..8a5b17ca 100644 --- a/src/app/features/room/RoomViewFollowing.tsx +++ b/src/app/features/room/RoomViewFollowing.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Box, Icon, @@ -11,135 +11,142 @@ import { as, config, } from 'folds'; -import { Room } from 'matrix-js-sdk'; +import { ObservableValue } from 'neurone/lib/utils/observable'; 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'; import { stopPropagation } from '../../utils/keyboard'; +import { useClient } from '../../hooks/useClient'; +import { useObservableValue, useObservableValuePromise } from '../../hooks/useObservableValue'; +import { useRoom } from '../../hooks/useRoom'; +import { useRoomMembersAPI } from '../../hooks/useRoomMembers'; export function RoomViewFollowingPlaceholder() { return
; } -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 - ); +export const RoomViewFollowing = as<'div'>(({ className, ...props }, ref) => { + const [open, setOpen] = useState(false); + const ct = useClient(); + const roomId = useRoom(); + const membersAPI = useRoomMembersAPI(); - const eventId = latestEvent?.getId(); + const roomToLatestEvent = useObservableValue(ct.roomToLatestEvent); + const latestEvent = roomToLatestEvent.get(roomId); + const eventId = latestEvent?.event_id; - return ( - <> - {eventId && ( - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - setOpen(false)} /> - - - - + const receiptsResult = useObservableValuePromise( + useMemo(() => { + if (!eventId) return Promise.resolve(new ObservableValue(undefined, '')); + return ct.storage.readReceipt.observableRead.queryIndex('eventId', eventId); + }, [ct, eventId]) + ); + const receipts = receiptsResult.value; + + const names = useMemo( + () => + receipts + ?.map((receipt) => receipt.userId) + .filter((readerId) => readerId !== ct.session.userId) + .map((readerId) => membersAPI.getName(readerId)) ?? [], + [ct, receipts, membersAPI] + ); + + return ( + <> + {eventId && ( + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + setOpen(false)} /> + + + + + )} + 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 && ( + <> + + + {names.length === 1 && ( + <> + {names[0]} + + {' is following the conversation.'} + + + )} + {names.length === 2 && ( + <> + {names[0]} + + {' and '} + + {names[1]} + + {' are following the conversation.'} + + + )} + {names.length === 3 && ( + <> + {names[0]} + + {', '} + + {names[1]} + + {' and '} + + {names[2]} + + {' are following the conversation.'} + + + )} + {names.length > 3 && ( + <> + {names[0]} + + {', '} + + {names[1]} + + {', '} + + {names[2]} + + {' and '} + + {names.length - 3} others + + {' are following the conversation.'} + + + )} + + )} - 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 && ( - <> - - - {names.length === 1 && ( - <> - {names[0]} - - {' is following the conversation.'} - - - )} - {names.length === 2 && ( - <> - {names[0]} - - {' and '} - - {names[1]} - - {' are following the conversation.'} - - - )} - {names.length === 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {' and '} - - {names[2]} - - {' are following the conversation.'} - - - )} - {names.length > 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {', '} - - {names[2]} - - {' and '} - - {names.length - 3} others - - {' are following the conversation.'} - - - )} - - - )} - - - ); - } -); + + + ); +}); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 9b4bfd23..8e83b106 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -23,32 +23,20 @@ import { Spinner, } from 'folds'; import { 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 { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import { _SearchPathSearchParams } from '../../pages/paths'; import * as css from './RoomViewHeader.css'; -import { useRoomUnread } from '../../state/hooks/unread'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; -import { markAsRead } from '../../utils/notifications'; -import { roomToUnreadAtom } from '../../state/room/roomToUnread'; 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 { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; @@ -69,28 +57,43 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useClient } from '../../hooks/useClient'; +import { useObservableValue } from '../../hooks/useObservableValue'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../../client/mxcToHttp'; +import { useRoomTopic } from '../../hooks/storage/useRoomTopic'; +import { + useRoomNotifications, + useRoomTotalNotifications, +} from '../../state/hooks/roomNotifications'; +import { roomToNotificationsAtom } from '../../state/room/roomToNotifications'; type RoomMenuProps = { - room: Room; requestClose: () => void; }; -const RoomMenu = forwardRef(({ room, requestClose }, ref) => { - const mx = useMatrixClient(); +const RoomMenu = forwardRef(({ requestClose }, ref) => { + const ct = useClient(); + const roomId = useRoom(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomUnread(room.roomId, roomToUnreadAtom); + + const roomNotifications = useRoomNotifications(roomId, roomToNotificationsAtom); + const notifications = useRoomTotalNotifications(roomNotifications); + const powerLevels = usePowerLevelsContext(); - const creators = useRoomCreators(room); + const creators = useRoomCreators(roomId); const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); + const canInvite = permissions.action('invite', ct.session.userId); const notificationPreferences = useRoomsNotificationPreferencesContext(); - const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); + const notificationMode = getRoomNotificationMode(notificationPreferences, roomId); const { navigateRoom } = useRoomNavigate(); const [invitePrompt, setInvitePrompt] = useState(false); const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); + // markAsRead(mx, room.roomId, hideActivity); requestClose(); }; @@ -99,16 +102,16 @@ const RoomMenu = forwardRef(({ room, requestClose }; const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + const alias = ct.roomToAlias.read.get(roomId); + const viaServers = alias ? undefined : getViaServers(creators, powerLevels); + copyToClipboard(getMatrixToRoom(alias ?? roomId, viaServers)); requestClose(); }; const openSettings = useOpenRoomSettings(); - const parentSpace = useSpaceOptionally(); + const parentSpaceId = useSpaceOptionally(); const handleOpenSettings = () => { - openSettings(room.roomId, parentSpace?.roomId); + openSettings(roomId, parentSpaceId); requestClose(); }; @@ -116,7 +119,7 @@ const RoomMenu = forwardRef(({ room, requestClose {invitePrompt && ( { setInvitePrompt(false); requestClose(); @@ -129,13 +132,13 @@ const RoomMenu = forwardRef(({ room, requestClose size="300" after={} radii="300" - disabled={!unread} + disabled={!notifications || notifications.count === 0} > Mark as Read - + {(handleOpen, opened, changing) => ( (({ room, requestClose { setPromptJump(false); - navigateRoom(room.roomId, eventId); + navigateRoom(roomId, eventId); requestClose(); }} onCancel={() => setPromptJump(false)} @@ -241,7 +244,7 @@ const RoomMenu = forwardRef(({ room, requestClose {promptLeave && ( setPromptLeave(false)} /> @@ -256,33 +259,40 @@ const RoomMenu = forwardRef(({ room, requestClose export function RoomViewHeader() { const navigate = useNavigate(); - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const screenSize = useScreenSizeContext(); - const room = useRoom(); - const space = useSpaceOptionally(); + const roomId = useRoom(); + const spaceId = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); - const mDirects = useAtomValue(mDirectAtom); + const encryptedRooms = useObservableValue(ct.encryptedRooms); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); - const pinnedEvents = useRoomPinnedEvents(room); - 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 pinnedEvents = useRoomPinnedEvents(roomId); + const [getRoomHeroes, roomToHeroMemberResult] = useRoomHeroes(roomId); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMemberResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMemberResult.value); + + const avatarMxc = getRoomAvatar(roomId); + const name = getRoomName(roomId) ?? 'Unknown'; + const topic = useRoomTopic(roomId); const avatarUrl = avatarMxc - ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + ? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { - rooms: room.roomId, + rooms: roomId, }; - const path = space - ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) + const path = spaceId + ? getSpaceSearchPath(ct.roomToAlias.read.get(spaceId) ?? spaceId) : getHomeSearchPath(); navigate(withSearchParam(path, searchParams)); }; @@ -313,15 +323,11 @@ export function RoomViewHeader() { {screenSize !== ScreenSize.Mobile && ( ( - + )} /> @@ -370,7 +376,7 @@ export function RoomViewHeader() { - {!ecryptedRoom && ( + {!encryptedRooms.has(roomId) && ( - setPinMenuAnchor(undefined)} /> + setPinMenuAnchor(undefined)} /> } /> @@ -492,7 +498,7 @@ export function RoomViewHeader() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} /> + setMenuAnchor(undefined)} /> } /> diff --git a/src/app/features/room/RoomViewTyping.css.ts b/src/app/features/room/RoomViewTyping.css.ts index 5c90a178..8d4872a2 100644 --- a/src/app/features/room/RoomViewTyping.css.ts +++ b/src/app/features/room/RoomViewTyping.css.ts @@ -13,12 +13,11 @@ const SlideUpAnime = keyframes({ export const RoomViewTyping = style([ DefaultReset, { - padding: `0 ${config.space.S500}`, + padding: `0 0 0 ${config.space.S500}`, width: '100%', - backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, position: 'absolute', - bottom: 0, + bottom: `calc(-1 * ${config.space.S600})`, animation: `${SlideUpAnime} 100ms ease-in-out`, }, ]); diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 1142a3a8..9e3f3713 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -1,121 +1,115 @@ 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'; +import { useRoom } from '../../hooks/useRoom'; +import { useRoomMembersAPI } from '../../hooks/useRoomMembers'; +import { useClient } from '../../hooks/useClient'; -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); +export const RoomViewTyping = as<'div'>(({ className, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); + const membersAPI = useRoomMembersAPI(); - const typingNames = typingMembers - .filter((receipt) => receipt.userId !== mx.getUserId()) - .map( - (receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId) - ) - .reverse(); + const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom); + const typingMembers = useRoomTypingMember(roomId); - if (typingNames.length === 0) { - return null; - } + const typingNames = typingMembers + .filter((userId) => userId !== ct.session.userId) + .map((userId) => membersAPI.getName(userId)) + .reverse(); - 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 ( -
- - - - {typingNames.length === 1 && ( - <> - {typingNames[0]} - - {' is typing...'} - - - )} - {typingNames.length === 2 && ( - <> - {typingNames[0]} - - {' and '} - - {typingNames[1]} - - {' are typing...'} - - - )} - {typingNames.length === 3 && ( - <> - {typingNames[0]} - - {', '} - - {typingNames[1]} - - {' and '} - - {typingNames[2]} - - {' are typing...'} - - - )} - {typingNames.length > 3 && ( - <> - {typingNames[0]} - - {', '} - - {typingNames[1]} - - {', '} - - {typingNames[2]} - - {' and '} - - {typingNames.length - 3} others - - {' are typing...'} - - - )} - - - - - -
- ); + if (typingNames.length === 0) { + return null; } -); + + const handleDropAll = () => { + setTypingMembers(new Map()); + }; + + return ( +
+ + + + {/* {typingNames.length === 0 && No one is typing...} */} + {typingNames.length === 1 && ( + <> + {typingNames[0]} + + {' is typing...'} + + + )} + {typingNames.length === 2 && ( + <> + {typingNames[0]} + + {' and '} + + {typingNames[1]} + + {' are typing...'} + + + )} + {typingNames.length === 3 && ( + <> + {typingNames[0]} + + {', '} + + {typingNames[1]} + + {' and '} + + {typingNames[2]} + + {' are typing...'} + + + )} + {typingNames.length > 3 && ( + <> + {typingNames[0]} + + {', '} + + {typingNames[1]} + + {', '} + + {typingNames[2]} + + {' and '} + + {typingNames.length - 3} others + + {' are typing...'} + + + )} + + + + + +
+ ); +}); diff --git a/src/app/features/room/components/JumpToLatestButton.tsx b/src/app/features/room/components/JumpToLatestButton.tsx new file mode 100644 index 00000000..c53e6880 --- /dev/null +++ b/src/app/features/room/components/JumpToLatestButton.tsx @@ -0,0 +1,39 @@ +import React, { useCallback } from 'react'; +import { Chip, Icon, Icons, Spinner, Text } from 'folds'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { scrollToBottom } from '../../../utils/dom'; +import { useTimelineView } from '../hooks/useTimeline'; + +export function JumpToLatestButton({ + getScrollElement, +}: { + getScrollElement: () => HTMLDivElement | null; +}) { + const timeline = useTimelineView(); + + const [jumpState, jumpToBottom] = useAsyncCallback( + useCallback(async () => { + await timeline.jumpToLatest(); + const scrollEl = getScrollElement(); + if (scrollEl) scrollToBottom(scrollEl, 'instant'); + }, [timeline, getScrollElement]) + ); + + return ( + + ) : ( + + ) + } + > + Jump to Latest + + ); +} diff --git a/src/app/features/room/components/TimelineDivider.tsx b/src/app/features/room/components/TimelineDivider.tsx new file mode 100644 index 00000000..f2e0c923 --- /dev/null +++ b/src/app/features/room/components/TimelineDivider.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { as, Badge, Box, color, ContainerColor, Line, Text } from 'folds'; +import { MessageBase } from '../../../components/message'; +import { timeDayMonthYear, today, yesterday } from '../../../utils/time'; +import { MessageSpacing } from '../../../state/settings'; + +export const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( + ({ variant, children, ...props }, ref) => ( + + + {children} + + + ) +); + +export function DayDivider({ messageSpacing, ts }: { messageSpacing: MessageSpacing; ts: number }) { + return ( + + + + + {(() => { + if (today(ts)) return 'Today'; + if (yesterday(ts)) return 'Yesterday'; + return timeDayMonthYear(ts); + })()} + + + + + ); +} + +export function NewMessageDivider({ messageSpacing }: { messageSpacing: MessageSpacing }) { + return ( + + + + New Messages + + + + ); +} diff --git a/src/app/features/room/components/TimelineFloat.tsx b/src/app/features/room/components/TimelineFloat.tsx new file mode 100644 index 00000000..c39c1de6 --- /dev/null +++ b/src/app/features/room/components/TimelineFloat.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { as, Box } from 'folds'; +import classNames from 'classnames'; +import * as css from './styles.css'; + +export const TimelineFloat = as<'div', css.TimelineFloatVariants>( + ({ position, className, ...props }, ref) => ( + + ) +); diff --git a/src/app/features/room/components/index.ts b/src/app/features/room/components/index.ts new file mode 100644 index 00000000..2d58db04 --- /dev/null +++ b/src/app/features/room/components/index.ts @@ -0,0 +1,3 @@ +export * from './TimelineDivider'; +export * from './TimelineFloat'; +export * from './JumpToLatestButton'; diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/components/styles.css.ts similarity index 100% rename from src/app/features/room/RoomTimeline.css.ts rename to src/app/features/room/components/styles.css.ts diff --git a/src/app/features/room/hooks/useEnsureRoomEncryptionSession.ts b/src/app/features/room/hooks/useEnsureRoomEncryptionSession.ts new file mode 100644 index 00000000..793b142f --- /dev/null +++ b/src/app/features/room/hooks/useEnsureRoomEncryptionSession.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react'; +import { + EncryptionSettings, + HistoryVisibility, + RoomEncryptionSettings, +} from 'neurone/lib/crypto/machine'; +import { ObjectReader } from 'neurone/lib/utils'; +import { useClient } from '../../../hooks/useClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { useRoomMembersAPI } from '../../../hooks/useRoomMembers'; + +export function buildEncryptionSettings( + historyVisibility: HistoryVisibility, + roomSettings: RoomEncryptionSettings | undefined, + encryptionEventContent: object | undefined +): EncryptionSettings { + const rotationPeriodMs = ObjectReader.number(encryptionEventContent, 'rotation_period_ms'); + + const rotationPeriodMsgs = ObjectReader.number(encryptionEventContent, 'rotation_period_msgs'); + + const rotationMilliseconds = roomSettings?.sessionRotationPeriodMs ?? rotationPeriodMs; + + return { + algorithm: 'm.megolm.v1.aes-sha2', + historyVisibility, + encryptStateEvents: roomSettings?.encryptStateEvents, + // EncryptionSettings expects microseconds. + rotationPeriod: rotationMilliseconds === undefined ? undefined : rotationMilliseconds * 1_000, + rotationPeriodMessages: roomSettings?.sessionRotationPeriodMessages ?? rotationPeriodMsgs, + sharingStrategy: roomSettings?.onlyAllowTrustedDevices ? 'only-trusted-devices' : 'all-devices', + }; +} + +export const useEnsureRoomEncryptionSession = () => { + const ct = useClient(); + const roomId = useRoom(); + + const { memberEventsResult, getMembership } = useRoomMembersAPI(); + + const prepareEncryptionSession = useCallback(async () => { + if (!ct.encryptedRooms.read.has(roomId)) { + return; + } + const historyVisibility = ct.roomToHistoryVisibility.read.get(roomId) ?? 'shared'; + const encryptForInvited = historyVisibility !== 'joined'; + + // TODO: make sure all members are loaded. + const targetMembers = memberEventsResult.value + ?.map((e) => e.userId) + .filter((userId) => { + const membership = getMembership(userId); + return membership === 'join' || (membership === 'invite' && encryptForInvited); + }); + + if (!targetMembers) return; + + await ct.machine.userTracker.track(targetMembers); + await ct.machine.outgoingRequests.processAll(); + await ct.machine.outgoingRequests.claimKeys(targetMembers); + + const roomSettings = await ct.machine.roomEncryption.getSettings(roomId); + const encryptionEventContent = ct.state.join.encryption.read.find((ev) => ev.roomId === roomId) + ?.event.content; + + const encryptionSettings: EncryptionSettings = buildEncryptionSettings( + historyVisibility, + roomSettings, + encryptionEventContent + ); + + await ct.machine.roomKeys.shareRoomKey(roomId, targetMembers, encryptionSettings); + }, [ct, roomId, memberEventsResult, getMembership]); + + return prepareEncryptionSession; +}; diff --git a/src/app/features/room/hooks/useSendMessage.ts b/src/app/features/room/hooks/useSendMessage.ts new file mode 100644 index 00000000..5672dfb8 --- /dev/null +++ b/src/app/features/room/hooks/useSendMessage.ts @@ -0,0 +1,276 @@ +import { useCallback } from 'react'; +import { Editor } from 'slate'; +import { + customHtmlEqualsPlainText, + getBeginCommand, + getMentions, + resetEditor, + resetEditorHistory, + toMatrixCustomHTML, + toPlainText, + trimCommand, + trimCustomHtml, +} from '../../../components/editor'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; +import { MessageEvent } from '../../../../types/matrix/room'; +import { Command, CommandRecord, SHRUG, TABLEFLIP, UNFLIP } from '../../../hooks/useCommands'; +import { useClient } from '../../../hooks/useClient'; +import { getMentionContent } from '../../../utils/room'; +import { useRoom } from '../../../hooks/useRoom'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; +import { getImageInfo } from '../../../utils/matrix'; +import { getImageUrlBlob, loadImageElement } from '../../../utils/dom'; +import { UploadItem } from '../../../state/room/roomInputDrafts'; +import { Upload, UploadSuccess } from '../../../state/upload'; +import { fulfilledPromiseSettledResult } from '../../../utils/common'; +import { + getAudioMsgContent, + getFileMsgContent, + getImageMsgContent, + getVideoMsgContent, +} from '../msgContent'; +import { + MsgType, + MessageContent, + RelationType, + StickerContent, +} from '../../../../types/matrix/common'; +import { useEnsureRoomEncryptionSession } from './useEnsureRoomEncryptionSession'; +import { LocalMessage } from '../../../../client/room/local-event'; +import { useTimelineView } from './useTimeline'; + +const useSendLocalMessage = () => { + const ct = useClient(); + const roomId = useRoom(); + const prepareEncryptionSession = useEnsureRoomEncryptionSession(); + const timeline = useTimelineView(); + + const sendLocalMessage = useCallback( + async (localMessage: LocalMessage) => { + let wireMessage = localMessage.clone(); + + if (ct.encryptedRooms.read.has(roomId)) { + await prepareEncryptionSession(); + const encStr = await ct.machine.roomEncryption.encryptMessageEvent( + roomId, + localMessage.type, + localMessage.content + ); + const encContent = JSON.parse(encStr); + + wireMessage = new LocalMessage(roomId, MessageEvent.RoomMessageEncrypted, encContent); + } + + const txnId = ct.getTxnId(); + + await timeline.sendMessage(localMessage, wireMessage, txnId); + }, + [ct, roomId, prepareEncryptionSession, timeline] + ); + + return sendLocalMessage; +}; + +export type ReplyToData = { + userId: string; + eventId: string; + ping: boolean; +}; + +export type SendMessageCallback = (replyTo?: ReplyToData) => void; + +export const useSendMessage = ( + editor: Editor, + commands: CommandRecord, + threadId?: string +): SendMessageCallback => { + const ct = useClient(); + const roomId = useRoom(); + const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + + const sendLocalMessage = useSendLocalMessage(); + + const sendMessage: SendMessageCallback = useCallback( + async (replyTo) => { + const commandName = getBeginCommand(editor); + let plainText = toPlainText(editor.children, isMarkdown).trim(); + let customHtml = trimCustomHtml( + toMatrixCustomHTML(editor.children, { + allowTextFormatting: true, + allowBlockMarkdown: isMarkdown, + allowInlineMarkdown: isMarkdown, + }) + ); + let msgType: 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 === Command.TableFlip) { + plainText = `${TABLEFLIP} ${plainText}`; + customHtml = `${TABLEFLIP} ${customHtml}`; + } else if (commandName === Command.UnFlip) { + plainText = `${UNFLIP} ${plainText}`; + customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName) { + const commandContent = commands[commandName as Command]; + if (commandContent) { + commandContent.exe(plainText); + } + resetEditor(editor); + resetEditorHistory(editor); + return; + } + + if (plainText === '') { + return; + } + + const body = plainText; + const formattedBody = customHtml; + const mentionData = getMentions(ct, editor); + + const content: MessageContent = { + msgtype: msgType, + body, + }; + + if (replyTo && replyTo.userId !== ct.session.userId && replyTo.ping) { + mentionData.users.add(replyTo.userId); + } + + const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); + content['m.mentions'] = mMentions; + + if (replyTo || !customHtmlEqualsPlainText(formattedBody, body)) { + content.format = 'org.matrix.custom.html'; + content.formatted_body = formattedBody; + } + if (replyTo) { + content['m.relates_to'] = { + 'm.in_reply_to': { + event_id: replyTo.eventId, + }, + }; + } + if (threadId) { + content['m.relates_to'] = { + ...content['m.relates_to'], + event_id: threadId, + rel_type: RelationType.Thread, + }; + if (!replyTo) { + // this should be the latest threaded message event_id + // but we will always keep it to root. The intention is + // so user always see this message in response to original + // thread root message as they are not in response to the latest + // message from thread + content['m.relates_to']['m.in_reply_to'] = { event_id: threadId }; + content['m.relates_to'].is_falling_back = true; + } + } + + const localMessage = new LocalMessage(roomId, MessageEvent.RoomMessage, content); + sendLocalMessage(localMessage); + + resetEditor(editor); + resetEditorHistory(editor); + }, + [ct, roomId, editor, isMarkdown, commands, threadId, sendLocalMessage] + ); + + return sendMessage; +}; + +export const useSendStickerMessage = (threadId?: string) => { + const ct = useClient(); + const roomId = useRoom(); + const useAuthentication = useMediaAuthentication(); + + const sendLocalMessage = useSendLocalMessage(); + + const sendSticker = async (mxc: string, shortcode: string, label: string) => { + const stickerUrl = mxcToHttp(ct.session.baseUrl, mxc, useAuthentication).url(); + if (!stickerUrl) return; + + const info = await getImageInfo( + await loadImageElement(stickerUrl), + await getImageUrlBlob(stickerUrl) + ); + const content: StickerContent = { + body: label, + url: mxc, + info, + }; + if (threadId) { + content['m.relates_to'] = { + event_id: threadId, + rel_type: RelationType.Thread, + 'm.in_reply_to': { event_id: threadId }, + is_falling_back: true, + }; + } + + const localMessage = new LocalMessage(roomId, MessageEvent.Sticker, content); + sendLocalMessage(localMessage); + }; + + return sendSticker; +}; + +export const useSendUploadMessage = ( + dropUploads: (uploads: Upload[]) => void, + threadId?: string +) => { + const ct = useClient(); + const roomId = useRoom(); + + const sendLocalMessage = useSendLocalMessage(); + + const sendUpload = async (uploadItems: UploadItem[], uploads: UploadSuccess[]) => { + const contentsPromises = uploads.map(async (upload) => { + const fileItem = uploadItems.find((f) => f.file === upload.file); + if (!fileItem) throw new Error('Broken upload'); + + if (fileItem.file.type.startsWith('image')) { + return getImageMsgContent(ct, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('video')) { + return getVideoMsgContent(ct, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('audio')) { + return getAudioMsgContent(fileItem, upload.mxc); + } + return getFileMsgContent(fileItem, upload.mxc); + }); + dropUploads(uploads); + const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); + + contents.forEach((c) => { + const content = { ...c }; + if (threadId) { + content['m.relates_to'] = { + event_id: threadId, + rel_type: RelationType.Thread, + 'm.in_reply_to': { event_id: threadId }, + is_falling_back: true, + }; + } + + const localMessage = new LocalMessage(roomId, MessageEvent.RoomMessage, content); + sendLocalMessage(localMessage); + }); + }; + + return sendUpload; +}; diff --git a/src/app/features/room/hooks/useTimeline.ts b/src/app/features/room/hooks/useTimeline.ts new file mode 100644 index 00000000..2c95205a --- /dev/null +++ b/src/app/features/room/hooks/useTimeline.ts @@ -0,0 +1,284 @@ +import { + createContext, + MouseEventHandler, + RefObject, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useAtom } from 'jotai'; +import { Editor } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { scrollToBottom } from '../../../utils/dom'; +import { MessageBatch } from '../../../../client/room/messages'; +import { getResizeObserverEntry, useResizeObserver } from '../../../hooks/useResizeObserver'; +import { + getIntersectionObserverEntry, + useIntersectionObserver, +} from '../../../hooks/useIntersectionObserver'; +import { useDebounce } from '../../../hooks/useDebounce'; +import { TimelineView } from '../../../../client/room'; +import { + composerToReplyDraftAtom, + getComposerId, + ReplyDraft, +} from '../../../state/room/roomInputDrafts'; +import { useRoom } from '../../../hooks/useRoom'; +import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; +import { useRoomMembersAPI } from '../../../hooks/useRoomMembers'; +import { useClient } from '../../../hooks/useClient'; +import { createMentionElement, moveCursor } from '../../../components/editor'; + +const TimelineViewContext = createContext(null); +export const TimelineViewProvider = TimelineViewContext.Provider; + +export const useTimelineView = (): TimelineView => { + const timelineView = useContext(TimelineViewContext); + if (!timelineView) { + throw new Error('TimelineView is not provided!'); + } + + return timelineView; +}; + +export const useTimelinePrefetch = (timeline: TimelineView, limit: number) => { + useEffect( + () => + timeline.range.info.subscribe((info) => { + if (info.start <= limit) { + requestAnimationFrame(() => timeline.paginateBackward(limit)); + } + if (info.end >= Math.max(timeline.batch.read.size - limit, 0)) { + requestAnimationFrame(() => timeline.paginateForward(limit)); + } + }), + [timeline, limit] + ); +}; + +export const useTimelinePaginate = (timeline: TimelineView, limit: number) => + useCallback( + (back: boolean) => { + if (back) { + timeline.paginateBackward(limit); + return; + } + timeline.paginateForward(limit); + }, + [timeline, limit] + ); + +export const useTimelineScrollRestore = ( + timeline: TimelineView, + scrollRef: RefObject +) => { + useLayoutEffect(() => { + const scrollEl = scrollRef.current; + if (scrollEl) { + const scrollTop = timeline.range.scrollTopCache; + if (scrollTop) { + scrollEl.scrollTo({ top: scrollTop, behavior: 'instant' }); + } else { + scrollToBottom(scrollEl); + } + } + return () => { + if (!scrollEl) return; + // eslint-disable-next-line no-param-reassign + timeline.range.scrollTopCache = scrollEl.scrollTop; + }; + }, [timeline, scrollRef]); +}; + +/** + * When room loads and timeline does not hav any message + * scrolls to bottom after message appears. + */ +export const useRestoreScrollToBottom = ( + scrollRef: RefObject, + msgBatch: MessageBatch +) => { + const prevSize = useRef(); + + useEffect(() => { + const pSize = prevSize.current; + prevSize.current = msgBatch.size; + + const scrollElement = scrollRef.current; + if (pSize === 0 && msgBatch.next.end && scrollElement) { + requestAnimationFrame(() => { + scrollToBottom(scrollElement, 'instant'); + }); + } + }, [msgBatch, scrollRef]); +}; + +export const useEditorResizeStickBottom = ( + getScrollElement: () => HTMLElement | null, + getEditorElement: () => HTMLElement | null, + atBottom: boolean +) => { + const atBottomRef = useRef(atBottom); + atBottomRef.current = atBottom; + + useResizeObserver( + useMemo(() => { + let mounted = false; + return (entries) => { + if (!mounted) { + // skip initial mounting call + mounted = true; + return; + } + const editorEl = getEditorElement(); + if (!editorEl) return; + const editorBaseEntry = getResizeObserverEntry(editorEl, entries); + const scrollElement = getScrollElement(); + if (!editorBaseEntry || !scrollElement) return; + + if (atBottomRef.current) { + scrollToBottom(scrollElement); + } + }; + }, [getScrollElement, getEditorElement, atBottomRef]), + getEditorElement + ); +}; + +export const useAtBottomToggle = ( + getScrollElement: () => HTMLElement | null, + atLiveEnd: boolean, + onBottom?: (bottom: boolean) => void +): [boolean, RefObject] => { + const atBottomAnchorRef = useRef(null); + const [atBottom, setAtBottom] = useState(true); + + const debounceSetAtBottom = useDebounce( + useCallback( + (entry: IntersectionObserverEntry) => { + if (!entry.isIntersecting) { + setAtBottom(false); + onBottom?.(false); + } + }, + [onBottom] + ), + { wait: 1000 } + ); + useIntersectionObserver( + useCallback( + (entries) => { + const target = atBottomAnchorRef.current; + if (!target) return; + const targetEntry = getIntersectionObserverEntry(target, entries); + if (targetEntry) debounceSetAtBottom(targetEntry); + if (targetEntry?.isIntersecting && atLiveEnd) { + setAtBottom(true); + onBottom?.(true); + } + }, + [debounceSetAtBottom, atLiveEnd, onBottom] + ), + useCallback( + () => ({ + root: getScrollElement(), + rootMargin: '100px', + }), + [getScrollElement] + ), + useCallback(() => atBottomAnchorRef.current, []) + ); + + return [atBottom, atBottomAnchorRef]; +}; + +export const useReplyClickHandler = ( + timeline: TimelineView, + editor: Editor, + threadId?: string +): [ReplyDraft | undefined, MouseEventHandler] => { + const roomId = useRoom(); + const [replyDraft, setReplyDraft] = useAtom( + composerToReplyDraftAtom(getComposerId(roomId, threadId)) + ); + + const handleReplyClick: MouseEventHandler = useCallback( + (evt) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) { + console.warn('Button should have "data-event-id" attribute!'); + return; + } + const senderId = timeline.batch.read.messages.get(replyId)?.sender; + if (!senderId) return; + + if (senderId) { + setReplyDraft({ + userId: senderId, + eventId: replyId, + }); + setTimeout(() => ReactEditor.focus(editor), 100); + } + }, + [setReplyDraft, editor, timeline] + ); + + return [replyDraft, handleReplyClick]; +}; + +export const useUserClickHandler = () => { + const roomId = useRoom(); + const spaceId = useSpaceOptionally(); + + const openUserRoomProfile = useOpenUserRoomProfile(); + + const handleUserClick: MouseEventHandler = 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; + } + openUserRoomProfile(roomId, spaceId, userId, evt.currentTarget.getBoundingClientRect()); + }, + [roomId, spaceId, openUserRoomProfile] + ); + + return handleUserClick; +}; + +export const useUsernameClickHandler = (editor: Editor) => { + const ct = useClient(); + const membersAPI = useRoomMembersAPI(); + + const handleUsernameClick: MouseEventHandler = 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 = membersAPI.getName(userId); + editor.insertNode( + createMentionElement( + userId, + name.startsWith('@') ? name : `@${name}`, + userId === ct.session.userId + ) + ); + ReactEditor.focus(editor); + moveCursor(editor); + }, + [ct, membersAPI, editor] + ); + + return handleUsernameClick; +}; diff --git a/src/app/features/room/hooks/useUploadItemPicker.ts b/src/app/features/room/hooks/useUploadItemPicker.ts new file mode 100644 index 00000000..b40f3fa2 --- /dev/null +++ b/src/app/features/room/hooks/useUploadItemPicker.ts @@ -0,0 +1,62 @@ +import { RefObject, useCallback } from 'react'; +import { useFileDropZone } from '../../../hooks/useFileDrop'; +import { useFilePasteHandler } from '../../../hooks/useFilePasteHandler'; +import { useFilePicker } from '../../../hooks/useFilePicker'; +import { safeFile } from '../../../utils/mimeTypes'; +import { UploadItem } from '../../../state/room/roomInputDrafts'; +import { useRoom } from '../../../hooks/useRoom'; +import { fulfilledPromiseSettledResult } from '../../../utils/common'; +import { encryptFile } from '../../../utils/matrix'; +import { useClient } from '../../../hooks/useClient'; + +export const useUploadItemPicker = ( + dropAreaRef: RefObject, + onPick: (uploadItems: UploadItem[]) => void +) => { + const ct = useClient(); + const roomId = useRoom(); + + const handleFiles = useCallback( + async (files: File[]) => { + const safeFiles = files.map(safeFile); + const uploadItems: UploadItem[] = []; + + if (ct.encryptedRooms.read.has(roomId)) { + const encryptFiles = fulfilledPromiseSettledResult( + await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) + ); + encryptFiles.forEach((ef) => + uploadItems.push({ + ...ef, + metadata: { + markedAsSpoiler: false, + }, + }) + ); + } else { + safeFiles.forEach((f) => + uploadItems.push({ + file: f, + originalFile: f, + encInfo: undefined, + metadata: { + markedAsSpoiler: false, + }, + }) + ); + } + onPick(uploadItems); + }, + [roomId, ct, onPick] + ); + + const pickFile = useFilePicker(handleFiles, true); + const handlePaste = useFilePasteHandler(handleFiles); + const dropZoneVisible = useFileDropZone(dropAreaRef, handleFiles); + + return { + pickFile, + handlePaste, + dropZoneVisible, + }; +}; diff --git a/src/app/features/room/hooks/useUploadItems.ts b/src/app/features/room/hooks/useUploadItems.ts new file mode 100644 index 00000000..4c9400cd --- /dev/null +++ b/src/app/features/room/hooks/useUploadItems.ts @@ -0,0 +1,69 @@ +import { useAtom } from 'jotai'; +import { useCallback } from 'react'; +import { + composerToUploadItemsAtom, + getComposerId, + roomUploadContentToUploadAtom, + UploadItem, + UploadMetadata, +} from '../../../state/room/roomInputDrafts'; +import { useRoom } from '../../../hooks/useRoom'; +import { UploadContent } from '../../../utils/matrix'; +import { Upload, UploadStatus } from '../../../state/upload'; + +export const useUploadItems = (threadId?: string) => { + const roomId = useRoom(); + const [uploadItems, setUploadItems] = useAtom( + composerToUploadItemsAtom(getComposerId(roomId, threadId)) + ); + + const updateUploadMetadata = useCallback( + (item: UploadItem, metadata: UploadMetadata) => { + setUploadItems({ + type: 'REPLACE', + item, + replacement: { ...item, metadata }, + }); + }, + [setUploadItems] + ); + + const removeUploadItem = useCallback( + (fileOrFiles: UploadContent | UploadContent[]) => { + const files = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]; + setUploadItems({ + type: 'DELETE', + item: uploadItems.filter((i) => files.find((f) => f === i.file)), + }); + files.forEach((f) => roomUploadContentToUploadAtom.remove(f)); + }, + [uploadItems, setUploadItems] + ); + + const addUploadItems = useCallback( + (items: UploadItem[]) => { + setUploadItems({ + type: 'PUT', + item: items, + }); + }, + [setUploadItems] + ); + + const dropUploads = (uploads: Upload[]) => { + uploads.forEach((upload) => { + if (upload.status === UploadStatus.Loading) { + upload.abortController.abort(); + } + }); + removeUploadItem(uploads.map((upload) => upload.file)); + }; + + return { + uploadItems, + addUploadItems, + removeUploadItem, + updateUploadMetadata, + dropUploads, + }; +}; diff --git a/src/app/features/room/hooks/useUploadsAtom.ts b/src/app/features/room/hooks/useUploadsAtom.ts new file mode 100644 index 00000000..4f0d02a1 --- /dev/null +++ b/src/app/features/room/hooks/useUploadsAtom.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { roomUploadContentToUploadAtom, UploadItem } from '../../../state/room/roomInputDrafts'; +import { createUploadsAtom } from '../../../state/upload'; + +export const useUploadsAtom = (uploadItems: UploadItem[]) => { + const uploadsAtom = useMemo( + () => + createUploadsAtom( + roomUploadContentToUploadAtom, + uploadItems.map((f) => f.file) + ), + [uploadItems] + ); + + return uploadsAtom; +}; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 9324e1c2..0eaa5445 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -2,6 +2,7 @@ import { Avatar, Box, Button, + Chip, Dialog, Header, Icon, @@ -32,14 +33,19 @@ import React, { } 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 { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { + postReportEventId, + putRedact, + putStateEvent, +} from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import classNames from 'classnames'; -import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { AvatarBase, BubbleLayout, CompactLayout, + EventContent, MessageBase, ModernLayout, Time, @@ -48,18 +54,12 @@ import { } from '../../../components/message'; import { canEditEvent, - getEventEdits, - getMemberAvatarMxc, - getMemberDisplayName, + fallbackReply, + getReactionGroups, + getReplyId, + getThreadId, } from '../../../utils/room'; -import { - getCanonicalAliasOrRoomId, - getMxIdLocalPart, - isRoomAlias, - mxcUrlToHttp, -} 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'; @@ -75,10 +75,17 @@ import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; -import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room'; +import { MemberPowerTag, StateEventType } from '../../../../types/matrix/room'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; +import { useClient } from '../../../hooks/useClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; +import { ReactionContent } from '../../../../types/matrix/common'; +import { RoomMembersAPI } from '../../../hooks/useRoomMembers'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { usePowerLevelsContext } from '../../../hooks/usePowerLevels'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -87,10 +94,10 @@ type MessageQuickReactionsProps = { }; export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( ({ onReaction, ...props }, ref) => { - const mx = useMatrixClient(); - const recentEmojis = useRecentEmoji(mx, 4); + const recentEmojisResult = useRecentEmoji(4); + const recentEmojis = recentEmojisResult.value; - if (recentEmojis.length === 0) return ; + if (recentEmojis && recentEmojis.length === 0) return ; return ( <> ( {...props} ref={ref} > - {recentEmojis.map((emoji) => ( + {recentEmojis?.map((emoji) => ( ( export const MessageAllReactionItem = as< 'button', { - room: Room; - relations: Relations; + reactionEvents: MessageEventWithoutRoomId[]; onClose?: () => void; } ->(({ room, relations, onClose, ...props }, ref) => { +>(({ reactionEvents, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); const handleClose = () => { @@ -158,8 +164,7 @@ export const MessageAllReactionItem = as< > setOpen(false)} /> @@ -186,11 +191,10 @@ export const MessageAllReactionItem = as< export const MessageReadReceiptItem = as< 'button', { - room: Room; eventId: string; onClose?: () => void; } ->(({ room, eventId, onClose, ...props }, ref) => { +>(({ eventId, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); const handleClose = () => { @@ -211,7 +215,7 @@ export const MessageReadReceiptItem = as< }} > - + @@ -236,36 +240,26 @@ export const MessageReadReceiptItem = as< export const MessageSourceCodeItem = as< 'button', { - room: Room; - mEvent: MatrixEvent; + event: MessageEventWithoutRoomId; onClose?: () => void; } ->(({ room, mEvent, onClose, ...props }, ref) => { +>(({ event, onClose, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); 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(); + const timeline = ct.roomToTimeline.read.get(roomId); + const editEvents = timeline?.relations.edits.eventToRelations.read.get(event.event_id); - if (!edits) return JSON.stringify(getContent(mEvent), null, 2); + if (!editEvents) return JSON.stringify(event, null, 2); const content: Record = { - '<== MAIN_EVENT ==>': getContent(mEvent), + '<== MAIN_EVENT ==>': event, }; - edits.forEach((editEvt, index) => { - content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt); + editEvents?.forEach((evt, index) => { + content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = evt; }); return JSON.stringify(content, null, 2); @@ -319,19 +313,20 @@ export const MessageSourceCodeItem = as< export const MessageCopyLinkItem = as< 'button', { - room: Room; - mEvent: MatrixEvent; + eventId: string; onClose?: () => void; } ->(({ room, mEvent, onClose, ...props }, ref) => { - const mx = useMatrixClient(); +>(({ eventId, onClose, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); + const creators = useRoomCreators(roomId); + const powerLevels = usePowerLevelsContext(); const handleCopy = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const eventId = mEvent.getId(); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + const alias = ct.roomToAlias.read.get(roomId); + const viaServers = alias ? undefined : getViaServers(creators, powerLevels); if (!eventId) return; - copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers)); + copyToClipboard(getMatrixToRoomEvent(alias ?? roomId, eventId, viaServers)); onClose?.(); }; @@ -354,24 +349,28 @@ export const MessageCopyLinkItem = as< export const MessagePinItem = as< 'button', { - room: Room; - mEvent: MatrixEvent; + eventId: string; onClose?: () => void; } ->(({ room, mEvent, onClose, ...props }, ref) => { - const mx = useMatrixClient(); - const pinnedEvents = useRoomPinnedEvents(room); - const isPinned = pinnedEvents.includes(mEvent.getId() ?? ''); +>(({ eventId, onClose, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); + const pinnedEvents = useRoomPinnedEvents(roomId); + const isPinned = pinnedEvents.includes(eventId); const handlePin = () => { - const eventId = mEvent.getId(); - const pinContent: RoomPinnedEventsEventContent = { + const pinContent = { pinned: Array.from(pinnedEvents).filter((id) => id !== eventId), }; if (!isPinned && eventId) { pinContent.pinned.push(eventId); } - mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent); + ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomPinnedEvents, stateKey: '' }, + body: pinContent, + }) + ); onClose?.(); }; @@ -394,25 +393,37 @@ export const MessagePinItem = as< export const MessageDeleteItem = as< 'button', { - room: Room; - mEvent: MatrixEvent; + eventId: string; onClose?: () => void; } ->(({ room, mEvent, onClose, ...props }, ref) => { - const mx = useMatrixClient(); +>(({ eventId, onClose, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); 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] + async (reason?: string) => { + const resp = await ct.requestAuthJSON( + putRedact({ + pathParams: { roomId, eventId, txnId: ct.getTxnId() }, + body: { + reason, + }, + }) + ); + + const [err] = await parseMatrixResp(resp); + if (err) { + throw new Error(printMatrixError(err)); + } + }, + [ct, roomId, eventId] ) ); const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); - const eventId = mEvent.getId(); if ( !eventId || deleteState.status === AsyncStatus.Loading || @@ -422,7 +433,7 @@ export const MessageDeleteItem = as< const target = evt.target as HTMLFormElement | undefined; const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); - deleteMessage(eventId, reason); + deleteMessage(reason); }; const handleClose = () => { @@ -523,25 +534,37 @@ export const MessageDeleteItem = as< export const MessageReportItem = as< 'button', { - room: Room; - mEvent: MatrixEvent; + eventId: string; onClose?: () => void; } ->(({ room, mEvent, onClose, ...props }, ref) => { - const mx = useMatrixClient(); +>(({ eventId, onClose, ...props }, ref) => { + const ct = useClient(); + const roomId = useRoom(); 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] + async (score: number, reason: string) => { + const resp = await ct.requestAuthJSON( + postReportEventId({ + pathParams: { roomId, eventId }, + body: { + reason, + score, + }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) { + throw new Error(printMatrixError(err)); + } + }, + [ct, roomId, eventId] ) ); const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); - const eventId = mEvent.getId(); if ( !eventId || reportState.status === AsyncStatus.Loading || @@ -552,7 +575,7 @@ export const MessageReportItem = as< 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'); + reportMessage(reason ? -100 : -50, reason || 'No reason provided'); }; const handleClose = () => { @@ -654,19 +677,169 @@ export const MessageReportItem = as< ); }); +export function MessageAvatar({ + userId, + displayName, + avatarUrl, + onUserClick, + bubbleLayout, +}: { + userId: string; + displayName: string; + avatarUrl?: string; + onUserClick?: MouseEventHandler; + bubbleLayout?: boolean; +}) { + const ct = useClient(); + const useAuthentication = useMediaAuthentication(); + + return ( + + + } + /> + + + ); +} + +export function MessageUsername({ + userId, + displayName, + onUserClick, + onUsernameClick, + messageLayout, + memberPowerTag, + accessibleTagColors, + legacyUsernameColor, +}: { + userId: string; + displayName: string; + onUserClick?: MouseEventHandler; + onUsernameClick?: MouseEventHandler; + messageLayout: MessageLayout; + memberPowerTag?: MemberPowerTag; + accessibleTagColors?: Map; + legacyUsernameColor?: boolean; +}) { + const ct = useClient(); + const useAuthentication = useMediaAuthentication(); + + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(ct, useAuthentication, memberPowerTag.icon) + : undefined; + + const usernameColor = legacyUsernameColor ? colorMXID(userId) : tagColor; + + return ( + + + + {displayName} + + + {tagIconSrc && } + + ); +} + +type MessageLayoutProps = { + avatar?: ReactNode; + username?: ReactNode; + time?: ReactNode; + collapse: boolean; + messageLayout: MessageLayout; + onContextMenu?: MouseEventHandler; + children: ReactNode; +}; +export function RenderMessageLayout({ + collapse, + messageLayout, + avatar, + username, + time, + onContextMenu, + children, +}: MessageLayoutProps) { + const headerJSX = !collapse && ( + + {username} + + {time} + + + ); + + return ( + <> + {messageLayout === MessageLayout.Compact && ( + + {children} + + )} + {messageLayout === MessageLayout.Bubble && ( + + {children} + + )} + {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( + + {headerJSX} + {children} + + )} + + ); +} + export type MessageProps = { - room: Room; - mEvent: MatrixEvent; + event: MessageEventWithoutRoomId; collapse: boolean; highlight: boolean; + selected?: boolean; edit?: boolean; canDelete?: boolean; canSendReaction?: boolean; canPinEvent?: boolean; - imagePackRooms?: Room[]; - relations?: Relations; + imagePackRooms?: string[]; + reactionEvents?: MessageEventWithoutRoomId[]; messageLayout: MessageLayout; messageSpacing: MessageSpacing; + membersAPI: RoomMembersAPI; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; onReplyClick: ( @@ -676,7 +849,7 @@ export type MessageProps = { onEditId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; - reactions?: ReactNode; + redacted?: boolean; hideReadReceipts?: boolean; showDeveloperTools?: boolean; memberPowerTag?: MemberPowerTag; @@ -689,25 +862,26 @@ export const Message = as<'div', MessageProps>( ( { className, - room, - mEvent, + event, collapse, highlight, + selected, edit, canDelete, canSendReaction, canPinEvent, imagePackRooms, - relations, + reactionEvents, messageLayout, messageSpacing, + membersAPI, onUserClick, onUsernameClick, onReplyClick, onReactionToggle, onEditId, reply, - reactions, + redacted, hideReadReceipts, showDeveloperTools, memberPowerTag, @@ -720,9 +894,9 @@ export const Message = as<'div', MessageProps>( }, ref ) => { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const senderId = mEvent.getSender() ?? ''; + const ct = useClient(); + const roomId = useRoom(); + const senderId = event.sender; const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); @@ -730,110 +904,25 @@ export const Message = as<'div', MessageProps>( const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); - const senderDisplayName = - getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; - const senderAvatarMxc = getMemberAvatarMxc(room, senderId); - - const tagColor = memberPowerTag?.color - ? accessibleTagColors?.get(memberPowerTag.color) - : undefined; - const tagIconSrc = memberPowerTag?.icon - ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) - : undefined; - - const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; - - const headerJSX = !collapse && ( - - - - - {senderDisplayName} - - - {tagIconSrc && } - - - {messageLayout === MessageLayout.Modern && hover && ( - <> - - {senderId} - - - | - - - )} - - - ); - - const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( - - - } - /> - - - ); + const senderDisplayName = membersAPI.getName(senderId); + const senderAvatarMxc = membersAPI.getAvatar(senderId); const msgContentJSX = ( - {reply} {edit && onEditId ? ( onEditId()} /> ) : ( children )} - {reactions} ); @@ -874,8 +963,6 @@ export const Message = as<'div', MessageProps>( }, 100); }; - const isThreadedMessage = mEvent.threadRootId !== undefined; - return ( ( })} tabIndex={0} space={messageSpacing} - collapse={collapse} + collapse={collapse || (!!getReplyId(event.content) && !fallbackReply(event.content))} highlight={highlight} - selected={!!menuAnchor || !!emojiBoardAnchor} + selected={!!selected || !!menuAnchor || !!emojiBoardAnchor} {...props} {...hoverProps} {...focusWithinProps} @@ -907,11 +994,11 @@ export const Message = as<'div', MessageProps>( returnFocusOnDeactivate={false} allowTextCustomEmoji onEmojiSelect={(key) => { - onReactionToggle(mEvent.getId()!, key); + onReactionToggle(event.event_id, key); setEmojiBoardAnchor(undefined); }} onCustomEmojiSelect={(mxc, shortcode) => { - onReactionToggle(mEvent.getId()!, mxc, shortcode); + onReactionToggle(event.event_id, mxc, shortcode); setEmojiBoardAnchor(undefined); }} requestClose={() => { @@ -933,17 +1020,17 @@ export const Message = as<'div', MessageProps>( )} - + - {!isThreadedMessage && ( + {!getThreadId(event) && ( onReplyClick(ev, true)} - data-event-id={mEvent.getId()} + data-event-id={event.event_id} variant="SurfaceVariant" size="300" radii="300" @@ -951,9 +1038,9 @@ export const Message = as<'div', MessageProps>( )} - {canEditEvent(mx, mEvent) && onEditId && ( + {canEditEvent(ct, event) && onEditId && ( onEditId(mEvent.getId())} + onClick={() => onEditId(event.event_id)} variant="SurfaceVariant" size="300" radii="300" @@ -981,7 +1068,7 @@ export const Message = as<'div', MessageProps>( {canSendReaction && ( { - onReactionToggle(mEvent.getId()!, key, shortcode); + onReactionToggle(event.event_id, key, shortcode); closeMenu(); }} /> @@ -1004,18 +1091,17 @@ export const Message = as<'div', MessageProps>( )} - {relations && ( + {reactionEvents && ( )} } + after={} radii="300" - data-event-id={mEvent.getId()} + data-event-id={event.event_id} onClick={(evt: any) => { onReplyClick(evt); closeMenu(); @@ -1027,15 +1113,15 @@ export const Message = as<'div', MessageProps>( size="T300" truncate > - Reply + Quote - {!isThreadedMessage && ( + {!getThreadId(event) && ( } radii="300" - data-event-id={mEvent.getId()} + data-event-id={event.event_id} onClick={(evt: any) => { onReplyClick(evt, true); closeMenu(); @@ -1051,14 +1137,14 @@ export const Message = as<'div', MessageProps>( )} - {canEditEvent(mx, mEvent) && onEditId && ( + {canEditEvent(ct, event) && onEditId && ( } radii="300" - data-event-id={mEvent.getId()} + data-event-id={event.event_id} onClick={() => { - onEditId(mEvent.getId()); + onEditId(event.event_id); closeMenu(); }} > @@ -1073,42 +1159,25 @@ export const Message = as<'div', MessageProps>( )} {!hideReadReceipts && ( - + )} {showDeveloperTools && ( - + )} - + {canPinEvent && ( - + )} - {((!mEvent.isRedacted() && canDelete) || - mEvent.getSender() !== mx.getUserId()) && ( + {((!redacted && canDelete) || event.sender !== ct.session.userId) && ( <> - {!mEvent.isRedacted() && canDelete && ( - + {!redacted && canDelete && ( + )} - {mEvent.getSender() !== mx.getUserId() && ( - + {event.sender !== ct.session.userId && ( + )} @@ -1131,32 +1200,60 @@ export const Message = as<'div', MessageProps>(
)} - {messageLayout === MessageLayout.Compact && ( - - {msgContentJSX} - - )} - {messageLayout === MessageLayout.Bubble && ( - - {msgContentJSX} - - )} - {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( - - {headerJSX} - {msgContentJSX} - - )} + + {reply} + + + ) + } + username={ + !collapse && ( + + ) + } + time={ + ); } ); export type EventProps = { - room: Room; - mEvent: MatrixEvent; + event: MessageEventWithoutRoomId; highlight: boolean; canDelete?: boolean; + redacted?: boolean; messageSpacing: MessageSpacing; hideReadReceipts?: boolean; showDeveloperTools?: boolean; @@ -1165,10 +1262,10 @@ export const Event = as<'div', EventProps>( ( { className, - room, - mEvent, + event, highlight, canDelete, + redacted, messageSpacing, hideReadReceipts, showDeveloperTools, @@ -1177,12 +1274,11 @@ export const Event = as<'div', EventProps>( }, ref ) => { - const mx = useMatrixClient(); + const ct = useClient(); const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); - const stateEvent = typeof mEvent.getStateKey() === 'string'; const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed) return; @@ -1242,39 +1338,22 @@ export const Event = as<'div', EventProps>( {!hideReadReceipts && ( - + )} {showDeveloperTools && ( - + )} - + - {((!mEvent.isRedacted() && canDelete && !stateEvent) || - (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( + {((!redacted && canDelete) || event.sender !== ct.session.userId) && ( <> - {!mEvent.isRedacted() && canDelete && ( - + {!redacted && canDelete && ( + )} - {mEvent.getSender() !== mx.getUserId() && ( - + {event.sender !== ct.session.userId && ( + )} @@ -1302,3 +1381,67 @@ export const Event = as<'div', EventProps>( ); } ); + +export function ThreadEvents({ + users, + ts, + messageLayout, + hour24Clock, + dateFormatString, + messageSpacing, + count, + onClick, +}: { + users: string[]; + ts: number; + messageLayout: MessageLayout; + hour24Clock: boolean; + dateFormatString: string; + messageSpacing: MessageSpacing; + count: number; + onClick?: () => void; +}) { + return ( + + + } + iconSrc={Icons.Thread} + content={ + + {users.length === 1 && {users[0]}} + {users.length === 2 && ( + <> + {users[0]}, {users[1]} + + )} + {users.length > 2 && ( + <> + {users[0]}, {users[1]}, +{users.length - 2} others + + )}{' '} + replied in thread.{' '} + onClick?.()} + style={{ flexShrink: 0 }} + radii="Pill" + variant="Primary" + fill="None" + > + + {count} {count === 1 ? 'Reply' : 'Replies'} + + + + } + /> + + ); +} diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 9a7567aa..339931c6 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -21,7 +21,7 @@ import { } from 'folds'; import { Editor, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; -import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; import { isKeyHotkey } from 'is-hotkey'; import { AUTOCOMPLETE_PREFIXES, @@ -50,21 +50,21 @@ 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, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; +import { useClient } from '../../../hooks/useClient'; +import { MentionsContent, MessageContent, RelationType } from '../../../../types/matrix/common'; type MessageEditorProps = { roomId: string; - room: Room; - mEvent: MatrixEvent; - imagePackRooms?: Room[]; + event: MessageEventWithoutRoomId; + imagePackRooms?: string[]; onCancel: () => void; }; export const MessageEditor = as<'div', MessageEditorProps>( - ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { - const mx = useMatrixClient(); + ({ roomId, event, imagePackRooms, onCancel, ...props }, ref) => { + const ct = useClient(); const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); @@ -78,9 +78,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( const getPrevBodyAndFormattedBody = useCallback((): [ string | undefined, string | undefined, - IMentions | undefined + MentionsContent | undefined ] => { - const evtId = mEvent.getId()!; + const evtId = event.event_id; const evtTimeline = room.getTimelineForEvent(evtId); const editedEvent = evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); @@ -95,7 +95,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( typeof customHtml === 'string' ? customHtml : undefined, mMentions, ]; - }, [room, mEvent]); + }, [roomId, event]); const [saveState, save] = useAsyncCallback( useCallback(async () => { @@ -124,12 +124,13 @@ export const MessageEditor = as<'div', MessageEditorProps>( } } - const newContent: IContent = { - msgtype: mEvent.getContent().msgtype, + const prevContent = event.content as MessageContent; + const newContent: MessageContent = { + msgtype: prevContent.msgtype, body: plainText, }; - const mentionData = getMentions(mx, roomId, editor); + const mentionData = getMentions(ct, editor); prevMentions?.user_ids?.forEach((prevMentionId) => { mentionData.users.add(prevMentionId); @@ -143,18 +144,20 @@ export const MessageEditor = as<'div', MessageEditorProps>( newContent.formatted_body = customHtml; } - const content: IContent = { + const content: MessageContent = { ...newContent, body: `* ${plainText}`, 'm.new_content': newContent, 'm.relates_to': { - event_id: mEvent.getId(), + event_id: event.event_id, rel_type: RelationType.Replace, }, }; - return mx.sendMessage(roomId, content); - }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]) + // TODO: send message + // return mx.sendMessage(roomId, content); + return Promise.resolve(); + }, [ct, editor, event, isMarkdown, getPrevBodyAndFormattedBody]) ); const handleSave = useCallback(() => { @@ -241,7 +244,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( []; 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(); + ({ className, reactions, eventId, canSendReaction, onReactionToggle, ...props }, ref) => { + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const [viewer, setViewer] = useState(false); - const myUserId = mx.getUserId(); - const reactions = useRelations( - relations, - useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []) - ); const handleViewReaction: MouseEventHandler = (evt) => { evt.stopPropagation(); @@ -50,6 +43,8 @@ export const Reactions = as<'div', ReactionsProps>( else setViewer(key); }; + const reactionGroups = useMemo(() => getReactionGroups(reactions), [reactions]); + return ( ( {...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(); + {reactionGroups.map((reaction) => { + const isPressed = reaction.users.has(ct.session.userId); return ( - + } @@ -79,13 +75,15 @@ export const Reactions = as<'div', ReactionsProps>( {(targetRef) => ( onReactionToggle(mEventId, key) : undefined} + key={reaction.key} + ct={ct} + reaction={reaction.key} + count={reaction.users.size} + onClick={ + canSendReaction ? () => onReactionToggle(eventId, reaction.key) : undefined + } onContextMenu={handleViewReaction} aria-disabled={!canSendReaction} useAuthentication={useAuthentication} @@ -114,9 +112,8 @@ export const Reactions = as<'div', ReactionsProps>( > setViewer(false)} /> diff --git a/src/app/features/room/message/thread-selector/ThreadSelector.tsx b/src/app/features/room/message/thread-selector/ThreadSelector.tsx new file mode 100644 index 00000000..41bbc9b0 --- /dev/null +++ b/src/app/features/room/message/thread-selector/ThreadSelector.tsx @@ -0,0 +1,75 @@ +import { Box, Icon, Icons, Line, Text } from 'folds'; +import React, { ReactNode } from 'react'; +import classNames from 'classnames'; +import * as css from './styles.css'; +import { Time } from '../../../../components/message'; +import { ThreadTimeline } from '../../../../../client/room/ThreadTimeline'; +import { useObservableValue } from '../../../../hooks/useObservableValue'; +import { GetRoomMemberNameCallback } from '../../../../hooks/useRoomMembers'; + +export function ThreadSelectorContainer({ children }: { children: ReactNode }) { + return {children}; +} + +type ThreadSelectorProps = { + thread: ThreadTimeline; + outlined?: boolean; + hour24Clock: boolean; + dateFormatString: string; + getMemberName: GetRoomMemberNameCallback; + onClick?: (threadId: string) => void; +}; + +export function ThreadSelector({ + thread, + outlined, + hour24Clock, + dateFormatString, + getMemberName, + onClick, +}: ThreadSelectorProps) { + const meta = useObservableValue(thread.meta); + const latestEvent = meta?.latestEvent; + + return ( + onClick?.(thread.threadId)} + > + + + + {meta?.count ?? `+${thread.liveMessages.read.size || 1}`}{' '} + {meta?.count === 1 ? 'Reply' : 'Replies'} + + + {latestEvent && ( + <> + + + + Last reply by + {getMemberName(latestEvent.sender)} + — + + + + + )} + + ); +} diff --git a/src/app/features/room/message/thread-selector/index.ts b/src/app/features/room/message/thread-selector/index.ts new file mode 100644 index 00000000..2dfa5d9e --- /dev/null +++ b/src/app/features/room/message/thread-selector/index.ts @@ -0,0 +1 @@ +export * from './ThreadSelector'; diff --git a/src/app/features/room/message/thread-selector/styles.css.ts b/src/app/features/room/message/thread-selector/styles.css.ts new file mode 100644 index 00000000..e0e3d68c --- /dev/null +++ b/src/app/features/room/message/thread-selector/styles.css.ts @@ -0,0 +1,38 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, FocusOutline, toRem } from 'folds'; +import { ContainerColor } from '../../../../styles/ContainerColor.css'; + +export const ThreadSelectorContainer = style({ + marginTop: config.space.S200, +}); + +export const ThreadSelector = style([ + ContainerColor({ variant: 'SurfaceVariant' }), + { + padding: config.space.S200, + borderRadius: config.radii.R400, + cursor: 'pointer', + + selectors: { + '&:hover, &:focus-visible': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + '&:active': { + backgroundColor: color.SurfaceVariant.ContainerActive, + }, + }, + }, + FocusOutline, +]); + +export const ThreadSectorOutlined = style({ + borderWidth: config.borderWidth.B300, +}); + +export const ThreadSelectorDivider = style({ + height: toRem(16), +}); + +export const ThreadRepliesCount = style({ + color: color.Primary.Main, +}); diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 5b7cd145..48a10113 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -1,9 +1,14 @@ -import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; import to from 'await-to-js'; +import { postUpload, PostUploadOK } from 'neurone/lib/api/matrix/media/v3/upload'; import { + IAudioContent, + IFileContent, + IImageContent, IThumbnailContent, + IVideoContent, MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME, + MsgType, } from '../../../types/matrix/common'; import { getImageFileUrl, @@ -14,12 +19,14 @@ import { loadVideoElement, } from '../../utils/dom'; import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix'; -import { TUploadItem } from '../../state/room/roomInputDrafts'; +import { UploadItem } from '../../state/room/roomInputDrafts'; import { encodeBlurHash } from '../../utils/blurHash'; import { scaleYDimension } from '../../utils/common'; +import { ClientContext } from '../../../client/context'; +import { getAuthUploadRequest } from '../../../client/request'; const generateThumbnailContent = async ( - mx: MatrixClient, + ct: ClientContext, img: HTMLImageElement | HTMLVideoElement, dimensions: [number, number], encrypt: boolean @@ -30,7 +37,8 @@ const generateThumbnailContent = async ( const thumbnailFile = encThumbData?.file ?? thumbnail; if (!thumbnailFile) throw new Error('Can not create thumbnail!'); - const data = await mx.uploadContent(thumbnailFile); + const request = getAuthUploadRequest(ct.session.baseUrl, ct.session.accessToken); + const [, data] = await request(postUpload({ body: thumbnailFile })); const thumbMxc = data?.content_uri; if (!thumbMxc) throw new Error('Failed when uploading thumbnail!'); const thumbnailContent = getThumbnailContent({ @@ -44,15 +52,15 @@ const generateThumbnailContent = async ( }; export const getImageMsgContent = async ( - mx: MatrixClient, - item: TUploadItem, + ct: ClientContext, + item: UploadItem, mxc: string -): Promise => { +): Promise => { const { file, originalFile, encInfo, metadata } = item; const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); if (imgError) console.warn(imgError); - const content: IContent = { + const content: IImageContent = { msgtype: MsgType.Image, filename: file.name, body: file.name, @@ -78,16 +86,16 @@ export const getImageMsgContent = async ( }; export const getVideoMsgContent = async ( - mx: MatrixClient, - item: TUploadItem, + ct: ClientContext, + item: UploadItem, mxc: string -): Promise => { +): Promise => { const { file, originalFile, encInfo, metadata } = item; const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile))); if (videoError) console.warn(videoError); - const content: IContent = { + const content: IVideoContent = { msgtype: MsgType.Video, filename: file.name, body: file.name, @@ -96,7 +104,7 @@ export const getVideoMsgContent = async ( if (videoEl) { const [thumbError, thumbContent] = await to( generateThumbnailContent( - mx, + ct, videoEl, getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight), !!encInfo @@ -126,9 +134,9 @@ export const getVideoMsgContent = async ( return content; }; -export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => { +export const getAudioMsgContent = (item: UploadItem, mxc: string): IAudioContent => { const { file, encInfo } = item; - const content: IContent = { + const content: IAudioContent = { msgtype: MsgType.Audio, filename: file.name, body: file.name, @@ -148,9 +156,9 @@ export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => return content; }; -export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { +export const getFileMsgContent = (item: UploadItem, mxc: string): IFileContent => { const { file, encInfo } = item; - const content: IContent = { + const content: IFileContent = { msgtype: MsgType.File, body: file.name, filename: file.name, diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index 6c686bc5..8af2186d 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { Avatar, @@ -14,13 +14,8 @@ import { 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 { ReactionGroup } from '../../../utils/room'; import * as css from './ReactionViewer.css'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useRelations } from '../../../hooks/useRelations'; import { Reaction } from '../../../components/message'; import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji'; import { UserAvatar } from '../../../components/user-avatar'; @@ -28,44 +23,28 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { getMouseEventCords } from '../../../utils/dom'; +import { useClient } from '../../../hooks/useClient'; +import { useRoomMembersAPI } from '../../../hooks/useRoomMembers'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; +import { useRoom } from '../../../hooks/useRoom'; export type ReactionViewerProps = { - room: Room; initialKey?: string; - relations: Relations; + reactions: ReactionGroup[]; requestClose: () => void; }; export const ReactionViewer = as<'div', ReactionViewerProps>( - ({ className, room, initialKey, relations, requestClose, ...props }, ref) => { - const mx = useMatrixClient(); + ({ className, initialKey, reactions, requestClose, ...props }, ref) => { + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const reactions = useRelations( - relations, - useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []) - ); - const space = useSpaceOptionally(); + const roomId = useRoom(); + const spaceId = useSpaceOptionally(); const openProfile = useOpenUserRoomProfile(); + const membersAPI = useRoomMembersAPI(); - const [selectedKey, setSelectedKey] = useState(() => { - 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; + const [selectedReaction, setSelectedReaction] = useState(() => + reactions.find((r) => (initialKey ? r.key === initialKey : r)) + ); return ( ( - {reactions.map(([key, evts]) => { - if (typeof key !== 'string') return null; - return ( - setSelectedKey(key)} - useAuthentication={useAuthentication} - /> - ); - })} + {reactions.map((reaction) => ( + setSelectedReaction(reaction)} + useAuthentication={useAuthentication} + /> + ))} @@ -98,7 +74,17 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
- {`Reacted with :${selectedShortcode}:`} + {selectedReaction ? ( + {`Reacted with :${ + selectedReaction.shortcode ?? + getShortcodeFor(getHexcodeForEmoji(selectedReaction.key)) ?? + selectedReaction.key + }:`} + ) : ( + + Reactions + + )} @@ -108,58 +94,51 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( - {selectedReactions.map((mEvent) => { - const senderId = mEvent.getSender(); - if (!senderId) return null; - const member = room.getMember(senderId); - const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId; + {selectedReaction && + Array.from(selectedReaction.users).map((userId) => { + const name = membersAPI.getName(userId); + const avatarMxcUrl = membersAPI.getAvatar(userId); + const avatarUrl = avatarMxcUrl + ? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) + : undefined; - const avatarMxcUrl = member?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl - ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) - : undefined; - - return ( - { - openProfile( - room.roomId, - space?.roomId, - senderId, - getMouseEventCords(event.nativeEvent), - 'Bottom' - ); - }} - before={ - - } - /> - - } - > - - - {name} - - - - ); - })} + return ( + { + openProfile( + roomId, + spaceId, + userId, + getMouseEventCords(event.nativeEvent), + 'Bottom' + ); + }} + before={ + + } + /> + + } + > + + + {name} + + + + ); + })} diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9986849f..0939f0d5 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -49,7 +49,12 @@ import { getMemberDisplayName, getStateEvent, } from '../../../utils/room'; -import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; +import { + GetContentCallback, + MessageEvent, + StateEvent, + StateEventType, +} from '../../../../types/matrix/room'; import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; import { @@ -76,7 +81,7 @@ import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags'; import { useTheme } from '../../../hooks/useTheme'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; -import { useIsDirectRoom } from '../../../hooks/useRoom'; +import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { @@ -86,6 +91,7 @@ import { useGetMemberPowerTag, } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { useClient } from '../../../hooks/useClient'; type PinnedMessageProps = { room: Room; @@ -244,302 +250,292 @@ function PinnedMessage({ } type RoomPinMenuProps = { - room: Room; requestClose: () => void; }; -export const RoomPinMenu = forwardRef( - ({ room, requestClose }, ref) => { - const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const powerLevels = usePowerLevelsContext(); - const creators = useRoomCreators(room); +export const RoomPinMenu = forwardRef(({ requestClose }, ref) => { + const ct = useClient(); + const roomId = useRoom(); + const { userId } = ct.session; + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(roomId); - const permissions = useRoomPermissions(creators, powerLevels); - const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, userId); + const permissions = useRoomPermissions(creators, powerLevels); + const canPinEvent = permissions.stateEvent(StateEventType.RoomPinnedEvents, userId); - const creatorsTag = useRoomCreatorsTag(); - const powerLevelTags = usePowerLevelTags(room, powerLevels); - const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); - const theme = useTheme(); - const accessibleTagColors = useAccessiblePowerTagColors( - theme.kind, - creatorsTag, - powerLevelTags - ); + const theme = useTheme(); + const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags); - const pinnedEvents = useRoomPinnedEvents(room); - const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]); - const useAuthentication = useMediaAuthentication(); - const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); - const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const pinnedEvents = useRoomPinnedEvents(roomId); + const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]); + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); - const direct = useIsDirectRoom(); - const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + const direct = useIsDirectRoom(); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); - const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); - const { navigateRoom } = useRoomNavigate(); - const scrollRef = useRef(null); + const { navigateRoom } = useRoomNavigate(); + const scrollRef = useRef(null); - const virtualizer = useVirtualizer({ - count: sortedPinnedEvent.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 75, - overscan: 4, - }); + const virtualizer = useVirtualizer({ + count: sortedPinnedEvent.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 75, + overscan: 4, + }); - const mentionClickHandler = useMentionClickHandler(room.roomId); - const spoilerClickHandler = useSpoilerClickHandler(); + const mentionClickHandler = useMentionClickHandler(roomId); + const spoilerClickHandler = useSpoilerClickHandler(); - const linkifyOpts = useMemo( - () => ({ - ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) - ), + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) + ), + }), + [mx, room, mentionClickHandler] + ); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, }), - [mx, room, mentionClickHandler] - ); - const htmlReactParserOptions = useMemo( - () => - getReactCustomHtmlParser(mx, room.roomId, { - linkifyOpts, - useAuthentication, - handleSpoilerClick: spoilerClickHandler, - handleMentionClick: mentionClickHandler, - }), - [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] - ); + [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] + ); - const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>( - { - [MessageEvent.RoomMessage]: (event, displayName, getContent) => { - if (event.isRedacted()) { - return ( - - ); - } + const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>( + { + [MessageEvent.RoomMessage]: (event, displayName, getContent) => { + if (event.isRedacted()) { + return ; + } + return ( + + ); + }, + [MessageEvent.RoomMessageEncrypted]: (event, displayName) => { + const eventId = event.getId()!; + const evtTimeline = room.getTimelineForEvent(eventId); + + const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId); + + if (!mEvent || !evtTimeline) { return ( - + + + {event.getType()} + {' event'} + + ); - }, - [MessageEvent.RoomMessageEncrypted]: (event, displayName) => { - const eventId = event.getId()!; - const evtTimeline = room.getTimelineForEvent(eventId); + } - const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId); + return ( + + {() => { + if (mEvent.isRedacted()) return ; + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + if (mEvent.getType() === MessageEvent.RoomMessage) { + const editedEvent = getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet()); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? + mEvent.getContent()) as GetContentCallback; - if (!mEvent || !evtTimeline) { - return ( - - - {event.getType()} - {' event'} - - - ); - } - - return ( - - {() => { - if (mEvent.isRedacted()) return ; - if (mEvent.getType() === MessageEvent.Sticker) - return ( - ( - } - renderViewer={(p) => } - /> - )} - /> - ); - if (mEvent.getType() === MessageEvent.RoomMessage) { - const editedEvent = getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet()); - const getContent = (() => - editedEvent?.getContent()['m.new_content'] ?? - mEvent.getContent()) as GetContentCallback; - - return ( - - ); - } - if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) - return ( - - - - ); + return ( + + ); + } + if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) return ( - + ); - }} - - ); - }, - [MessageEvent.Sticker]: (event, displayName, getContent) => { - if (event.isRedacted()) { - return ( - - ); - } - return ( - ( - } - renderViewer={(p) => } - /> - )} - /> - ); - }, + return ( + + + + ); + }} + + ); }, - undefined, - (event) => { + [MessageEvent.Sticker]: (event, displayName, getContent) => { if (event.isRedacted()) { return ; } return ( - - - {event.getType()} - {' event'} - - + ( + } + renderViewer={(p) => } + /> + )} + /> ); + }, + }, + undefined, + (event) => { + if (event.isRedacted()) { + return ; } - ); - - const handleOpen = (roomId: string, eventId: string) => { - navigateRoom(roomId, eventId); - requestClose(); - }; - - return ( - + return ( -
- - Pinned Messages - - - - - - -
- - - - {sortedPinnedEvent.length > 0 ? ( -
- {virtualizer.getVirtualItems().map((vItem) => { - const eventId = sortedPinnedEvent[vItem.index]; - if (!eventId) return null; + + {event.getType()} + {' event'} + + + ); + } + ); - return ( - { + navigateRoom(roomId, eventId); + requestClose(); + }; + + return ( + + +
+ + Pinned Messages + + + + + + +
+ + + + {sortedPinnedEvent.length > 0 ? ( +
+ {virtualizer.getVirtualItems().map((vItem) => { + const eventId = sortedPinnedEvent[vItem.index]; + if (!eventId) return null; + + return ( + + - - - - - ); - })} -
- ) : ( + + + + ); + })} +
+ ) : ( + + - - - - No Pinned Messages - - - Users with sufficient power level can pin a messages from its context menu. - - + + No Pinned Messages + + + Users with sufficient power level can pin a messages from its context menu. + - )} - -
-
+
+ )} + + -
- ); - } -); + +
+ ); +}); diff --git a/src/app/features/room/thread-view/ThreadView.tsx b/src/app/features/room/thread-view/ThreadView.tsx new file mode 100644 index 00000000..69d5f2ef --- /dev/null +++ b/src/app/features/room/thread-view/ThreadView.tsx @@ -0,0 +1,626 @@ +import React, { + forwardRef, + MouseEventHandler, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import classNames from 'classnames'; +import { ReactEditor } from 'slate-react'; +import { + Badge, + Box, + Icon, + IconButton, + Icons, + Scroll, + Text, + Tooltip, + TooltipProvider, + config, +} from 'folds'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import FocusTrap from 'focus-trap-react'; +import { useVirtualPaginator } from '../../../hooks/useVirtualPaginator'; +import { useAlive } from '../../../hooks/useAlive'; +import { scrollToBottom } from '../../../utils/dom'; +import { + DefaultPlaceholder, + CompactPlaceholder, + MessageBase, + Reply, +} from '../../../components/message'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../../plugins/react-custom-html-parser'; +import { fallbackReply, getLatestEditEvent, getReplyId } from '../../../utils/room'; +import { useSetting } from '../../../state/hooks/settings'; +import { MessageLayout, settingsAtom } from '../../../state/settings'; +import { Reactions, Message } from '../message'; +import * as css from './styles.css'; +import { inSameDay, minuteDifference } from '../../../utils/time'; +import { usePowerLevelsContext } from '../../../hooks/usePowerLevels'; +import { MessageEvent, StateEvent } from '../../../../types/matrix/room'; +import { RenderMessageContent } from '../../../components/RenderMessageContent'; +import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; +import { useImagePackRooms } from '../../../hooks/useImagePackRooms'; +import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { + useAccessiblePowerTagColors, + useGetMemberPowerTag, +} from '../../../hooks/useMemberPowerTag'; +import { useTheme } from '../../../hooks/useTheme'; +import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { usePowerLevelTags, usePowerLevelTagsState } from '../../../hooks/usePowerLevelTags'; +import { ThreadTimeline } from '../../../../client/room/ThreadTimeline'; +import { useObservableValue } from '../../../hooks/useObservableValue'; +import { useClient } from '../../../hooks/useClient'; +import { useRoomMembersAPI } from '../../../hooks/useRoomMembers'; +import { useGetRoomName } from '../../../hooks/storage/useGetRoomName'; +import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros'; +import { + useAtBottomToggle, + useEditorResizeStickBottom, + useReplyClickHandler, + useRestoreScrollToBottom, + useTimelinePaginate, + useTimelinePrefetch, + useTimelineScrollRestore, + useUserClickHandler, + useUsernameClickHandler, +} from '../hooks/useTimeline'; +import { rContent } from '../../../../client/reader'; +import { Page, PageHeader } from '../../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useEditor } from '../../../components/editor'; +import { TimelineDivider, TimelineFloat, JumpToLatestButton, DayDivider } from '../components'; +import { RoomInput } from '../RoomInput'; +import { RoomViewFollowingPlaceholder } from '../RoomViewFollowing'; + +type PlaceholderListProps = { + count: number; + Placeholder: React.ComponentType; +}; + +const PlaceholderList = forwardRef( + ({ count, Placeholder }, ref) => ( +
+ {Array.from({ length: count }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + + + ))} +
+ ) +); + +type ThreadViewProps = { + threadTimeline: ThreadTimeline; + requestClose: () => void; +}; + +const PAGINATION_LIMIT = 80; + +export function ThreadView({ threadTimeline, requestClose }: ThreadViewProps) { + const ct = useClient(); + const roomId = useRoom(); + const useAuthentication = useMediaAuthentication(); + const encryptedRooms = useObservableValue(ct.encryptedRooms); + const screenSize = useScreenSizeContext(); + const floating = screenSize !== ScreenSize.Desktop; + const editor = useEditor(); + const roomInputRef = useRef(null); + const roomViewRef = useRef(null); + + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + const direct = useIsDirectRoom(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); + const showUrlPreview = encryptedRooms.has(roomId) ? encUrlPreview : urlPreview; + const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const ignoredUsersList = useIgnoredUsers(); + const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); + + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(roomId); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(roomId); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + + const membersAPI = useRoomMembersAPI(); + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTagsState = usePowerLevelTagsState(roomId); + const powerLevelTags = usePowerLevelTags(powerLevels, powerLevelTagsState.value); + const getMemberPowerTag = useGetMemberPowerTag(creators, powerLevels); + + const theme = useTheme(); + const accessiblePowerTagColors = useAccessiblePowerTagColors( + theme.kind, + creatorsTag, + powerLevelTags + ); + + const permissions = useRoomPermissions(creators, powerLevels); + + const canRedact = permissions.action('redact', ct.session.userId); + const canSendReaction = permissions.event(MessageEvent.Reaction, ct.session.userId); + const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, ct.session.userId); + const [editId, setEditId] = useState(); + + const mentionClickHandler = useMentionClickHandler(roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const imagePackRooms = useImagePackRooms(roomId); + + const scrollRef = useRef(null); + const scrollToBottomRef = useRef({ + count: 0, + smooth: true, + }); + + const [focusItem, setFocusItem] = useState< + | { + index: number; + scrollTo: boolean; + highlight: boolean; + } + | undefined + >(); + const alive = useAlive(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention( + ct, + roomId, + href, + makeMentionCustomProps(mentionClickHandler), + membersAPI.getName, + getRoomName + ) + ), + }), + [ct, roomId, mentionClickHandler, membersAPI.getName, getRoomName] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(ct, roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + getMemberName: membersAPI.getName, + getRoomName, + }), + [ + ct, + roomId, + linkifyOpts, + spoilerClickHandler, + mentionClickHandler, + useAuthentication, + membersAPI.getName, + getRoomName, + ] + ); + + const redacted = useObservableValue(threadTimeline.relations.redacted); + const reactions = useObservableValue(threadTimeline.relations.reactions.eventToRelations); + const edits = useObservableValue(threadTimeline.relations.edits.eventToRelations); + const messageBatch = useObservableValue(threadTimeline.batch); + const { events } = messageBatch.messages; + + window.tt = threadTimeline; + + const range = useObservableValue(threadTimeline.range.info); + const rangeAtStart = range.start === 0; + const rangeAtEnd = range.end === messageBatch.size; + + const getScrollElement = useCallback(() => scrollRef.current, []); + + useTimelinePrefetch(threadTimeline, PAGINATION_LIMIT); + const handlePaginate = useTimelinePaginate(threadTimeline, PAGINATION_LIMIT); + useTimelineScrollRestore(threadTimeline, scrollRef); + useRestoreScrollToBottom(scrollRef, messageBatch); + + const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } = + useVirtualPaginator({ + count: messageBatch.size, + limit: PAGINATION_LIMIT, + range, + onRangeChange: useCallback((r) => threadTimeline.range.setRange(r), [threadTimeline]), + getScrollElement, + getItemElement: useCallback( + (index: number) => + (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? + undefined, + [] + ), + onEnd: handlePaginate, + }); + + const tryAutoMarkAsRead = useCallback(() => { + console.log('mark thread as read.'); + }, []); + + const [atBottom, atBottomAnchorRef] = useAtBottomToggle( + getScrollElement, + messageBatch.next.end ?? false, + useCallback( + (bottom: boolean) => { + if (bottom && document.hasFocus()) { + tryAutoMarkAsRead(); + } + }, + [tryAutoMarkAsRead] + ) + ); + + useEditorResizeStickBottom( + getScrollElement, + useCallback(() => roomInputRef?.current ?? null, [roomInputRef]), + atBottom + ); + + // 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]); + + // 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 handleOpenReply: MouseEventHandler = useCallback(async (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + console.log(targetId); + }, []); + + const handleUserClick = useUserClickHandler(); + const handleUsernameClick = useUsernameClickHandler(editor); + const [replyDraft, handleReplyClick] = useReplyClickHandler( + threadTimeline, + editor, + threadTimeline.threadId + ); + + const handleReactionToggle = useCallback((targetEventId: string) => { + console.log(targetEventId); + }, []); + + const handleEdit = useCallback( + (editEvtId?: string) => { + if (editEvtId) { + setEditId(editEvtId); + return; + } + setEditId(undefined); + if (editor) { + ReactEditor.focus(editor); + } + }, + [editor] + ); + + const renderMatrixEvent = (event: MessageEventWithoutRoomId, item: number, collapse: boolean) => { + const reactionEvents = reactions.get(event.event_id)?.filter((r) => !redacted.has(r.event_id)); + const editEvents = edits.get(event.event_id); + const editedEvent = editEvents && getLatestEditEvent(event.sender, editEvents, redacted); + const highlighted = focusItem?.index === item && focusItem.highlight; + + const reactionJSX = reactionEvents && reactionEvents?.length > 0 && ( + + ); + + const content = editedEvent + ? rContent.newContent(editedEvent.content) ?? event.content + : event.content; + + return ( + 0 ? reactionEvents : undefined} + onUserClick={handleUserClick} + onUsernameClick={handleUsernameClick} + onReplyClick={handleReplyClick} + onReactionToggle={handleReactionToggle} + onEditId={handleEdit} + redacted={redacted.has(event.event_id)} + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(event.sender)} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + + {reactionJSX} + + ); + }; + + let prevEvent: MessageEventWithoutRoomId | undefined; + let isPrevRendered = false; + let dayDivider = false; + + const eventRenderer = (item: number) => { + const event = events[item]; + + if (!event) return null; + + if (event.sender && ignoredUsersSet.has(event.sender)) { + return null; + } + if (redacted.has(event.event_id) && !showHiddenEvents) { + return null; + } + + if (!dayDivider) { + dayDivider = prevEvent + ? !inSameDay(prevEvent.origin_server_ts, event.origin_server_ts) + : false; + } + + const collapsed = + isPrevRendered && + !dayDivider && + prevEvent !== undefined && + prevEvent.sender === event.sender && + prevEvent.type === event.type && + minuteDifference(prevEvent.origin_server_ts, event.origin_server_ts) < 2 && + !getReplyId(event.content); + + const eventJSX = renderMatrixEvent(event, item, collapsed); + prevEvent = event; + isPrevRendered = !!eventJSX; + + const dayDividerJSX = + dayDivider && eventJSX ? ( + + ) : null; + + const replyEventId = getReplyId(event.content); + + const replyJSX = + !fallbackReply(event.content) && replyEventId ? ( + + ) : null; + + if (eventJSX && dayDividerJSX) { + dayDivider = false; + + return ( + + {dayDividerJSX} + {replyJSX} + {eventJSX} + + ); + } + + if (replyJSX) { + return ( + + {replyJSX} + {eventJSX} + + ); + } + + return eventJSX; + }; + + return ( + + + + + + + Thread + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + + + + + + + {!messageBatch.prev.end || !rangeAtStart ? ( + + ) : ( + + + + Replies + + + + )} + {getItems().map(eventRenderer)} + {(!messageBatch.next.end || !rangeAtEnd) && ( + + )} + + + + + + + + {!atBottom && ( + + + + )} + + + + ); +} diff --git a/src/app/features/room/thread-view/index.ts b/src/app/features/room/thread-view/index.ts new file mode 100644 index 00000000..6ba05aab --- /dev/null +++ b/src/app/features/room/thread-view/index.ts @@ -0,0 +1 @@ +export * from './ThreadView'; diff --git a/src/app/features/room/thread-view/styles.css.ts b/src/app/features/room/thread-view/styles.css.ts new file mode 100644 index 00000000..5709c58f --- /dev/null +++ b/src/app/features/room/thread-view/styles.css.ts @@ -0,0 +1,22 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const ThreadView = style({ + // width: toRem(456), + width: toRem(496), + flexShrink: 0, + flexGrow: 0, +}); + +export const ThreadViewFloating = style({ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + zIndex: 1, + + maxWidth: toRem(496), + flexShrink: 1, + width: '100vw', + boxShadow: config.shadow.E400, +}); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..a4d879e8 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -20,8 +20,7 @@ import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../componen import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { Account } from './account'; import { useUserProfile } from '../../hooks/useUserProfile'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { getMxIdLocalPart } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { UserAvatar } from '../../components/user-avatar'; import { nameInitials } from '../../utils/common'; @@ -33,6 +32,8 @@ import { About } from './about'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; +import { useClient } from '../../hooks/useClient'; +import { mxcToHttp } from '../../../client/mxcToHttp'; export enum SettingsPages { GeneralPage, @@ -97,13 +98,17 @@ type SettingsProps = { requestClose: () => void; }; export function Settings({ initialPage, requestClose }: SettingsProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const { userId } = ct.session; const profile = useUserProfile(userId); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl - ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined + const displayName = profile?.displayName ?? getMxIdLocalPart(userId) ?? userId; + const avatarUrl = profile?.avatarUrl + ? mxcToHttp(ct.session.baseUrl, profile.avatarUrl, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; const screenSize = useScreenSizeContext(); diff --git a/src/app/features/settings/account/ContactInfo.tsx b/src/app/features/settings/account/ContactInfo.tsx index cfde7e29..6449ddd7 100644 --- a/src/app/features/settings/account/ContactInfo.tsx +++ b/src/app/features/settings/account/ContactInfo.tsx @@ -1,15 +1,22 @@ import React, { useCallback, useEffect } from 'react'; import { Box, Text, Chip } from 'folds'; +import { get3pid, Get3pidOK } from 'neurone/lib/api/matrix/client/v3/account'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useClient } from '../../../hooks/useClient'; export function ContactInformation() { - const mx = useMatrixClient(); + const ct = useClient(); const [threePIdsState, loadThreePIds] = useAsyncCallback( - useCallback(() => mx.getThreePids(), [mx]) + useCallback(async () => { + const response = await ct.requestAuthJSON(get3pid({})); + const [err, result] = await parseMatrixResp(response); + if (err) throw new Error(printMatrixError(err)); + return result; + }, [ct]) ); const threePIds = threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined; diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx index 98db9459..89f0c10c 100644 --- a/src/app/features/settings/account/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -1,25 +1,31 @@ import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react'; import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { isUserId } from '../../../utils/matrix'; -import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; +import { getIgnoredUsersContent, useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useAlive } from '../../../hooks/useAlive'; +import { useClient } from '../../../hooks/useClient'; +import { AccountDataType } from '../../../../types/matrix/accountData'; function IgnoreUserInput({ userList }: { userList: string[] }) { - const mx = useMatrixClient(); + const ct = useClient(); const [userId, setUserId] = useState(''); const alive = useAlive(); const [ignoreState, ignore] = useAsyncCallback( useCallback( - async (uId: string) => { - await mx.setIgnoredUsers([...userList, uId]); - }, - [mx, userList] + (uId: string) => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, + body: getIgnoredUsersContent(userList.filter((id) => id !== uId).concat([uId])), + }) + ), + [ct, userList] ) ); const ignoring = ignoreState.status === AsyncStatus.Loading; @@ -96,11 +102,17 @@ function IgnoreUserInput({ userList }: { userList: string[] }) { } function IgnoredUserChip({ userId, userList }: { userId: string; userList: string[] }) { - const mx = useMatrixClient(); + const ct = useClient(); const [unignoreState, unignore] = useAsyncCallback( useCallback( - () => mx.setIgnoredUsers(userList.filter((uId) => uId !== userId)), - [mx, userId, userList] + async () => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, + body: getIgnoredUsersContent(userList.filter((u) => u !== userId)), + }) + ), + [ct, userList, userId] ) ); diff --git a/src/app/features/settings/account/MatrixId.tsx b/src/app/features/settings/account/MatrixId.tsx index 0c06a75d..bb02510e 100644 --- a/src/app/features/settings/account/MatrixId.tsx +++ b/src/app/features/settings/account/MatrixId.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { Box, Text, Chip } from 'folds'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { copyToClipboard } from '../../../utils/dom'; +import { useClient } from '../../../hooks/useClient'; export function MatrixId() { - const mx = useMatrixClient(); - const userId = mx.getUserId()!; + const ct = useClient(); + const { userId } = ct.session; return ( diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index e982a799..a29c239d 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -25,12 +25,12 @@ import { Spinner, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { putAvatarUrl, putDisplayname } from 'neurone/lib/api/matrix/client/v3/profile'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { getMxIdLocalPart } from '../../../utils/matrix'; import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { nameInitials } from '../../../utils/common'; @@ -43,21 +43,27 @@ import { ModalWide } from '../../../styles/Modal.css'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useCapabilities } from '../../../hooks/useCapabilities'; +import { useClient } from '../../../hooks/useClient'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; type ProfileProps = { - profile: UserProfile; + profile?: UserProfile; userId: string; }; function ProfileAvatar({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const capabilities = useCapabilities(); const [alertRemove, setAlertRemove] = useState(false); const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl - ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined + const defaultDisplayName = profile?.displayName ?? getMxIdLocalPart(userId) ?? userId; + const avatarUrl = profile?.avatarUrl + ? mxcToHttp(ct.session.baseUrl, profile.avatarUrl, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; const [imageFile, setImageFile] = useState(); @@ -76,14 +82,14 @@ function ProfileAvatar({ profile, userId }: ProfileProps) { const handleUploaded = useCallback( (upload: UploadSuccess) => { const { mxc } = upload; - mx.setAvatarUrl(mxc); + ct.requestAuthJSON(putAvatarUrl({ pathParams: { userId }, body: { avatar_url: mxc } })); handleRemoveUpload(); }, - [mx, handleRemoveUpload] + [ct, userId, handleRemoveUpload] ); const handleRemoveAvatar = () => { - mx.setAvatarUrl(''); + ct.requestAuthJSON(putAvatarUrl({ pathParams: { userId }, body: { avatar_url: '' } })); setAlertRemove(false); }; @@ -206,15 +212,19 @@ function ProfileAvatar({ profile, userId }: ProfileProps) { } function ProfileDisplayName({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); + const ct = useClient(); const capabilities = useCapabilities(); const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; + const defaultDisplayName = profile?.displayName ?? getMxIdLocalPart(userId) ?? userId; const [displayName, setDisplayName] = useState(defaultDisplayName); const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) + useCallback( + (name: string) => + ct.requestAuthJSON(putDisplayname({ pathParams: { userId }, body: { displayname: name } })), + [ct, userId] + ) ); const changingDisplayName = changeState.status === AsyncStatus.Loading; @@ -304,8 +314,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { } export function Profile() { - const mx = useMatrixClient(); - const userId = mx.getUserId()!; + const ct = useClient(); + const { userId } = ct.session; const profile = useUserProfile(userId); return ( diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx index 301cb9a7..bcf3589e 100644 --- a/src/app/features/settings/notifications/AllMessages.tsx +++ b/src/app/features/settings/notifications/AllMessages.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useMemo } from 'react'; import { Badge, Box, Text } from 'folds'; -import { ConditionKind, IPushRules, PushRuleCondition, PushRuleKind, RuleId } from 'matrix-js-sdk'; -import { useAccountData } from '../../../hooks/useAccountData'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { PushRuleCondition, PushRules } from 'neurone/lib/api/types'; +import { putPushRuleActions } from 'neurone/lib/api/matrix/client/v3/pushrules'; +import { AccountDataType, ConditionKind, PushRuleId } from '../../../../types/matrix/accountData'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -13,10 +13,11 @@ import { NotificationMode, useNotificationModeActions, } from '../../../hooks/useNotificationMode'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useClient } from '../../../hooks/useClient'; +import { useAccountData } from '../../../hooks/storage/useAccountData'; const getAllMessageDefaultRule = ( - ruleId: RuleId, + ruleId: PushRuleId, encrypted: boolean, oneToOne: boolean ): PushRuleData => { @@ -33,7 +34,7 @@ const getAllMessageDefaultRule = ( }); return { - kind: PushRuleKind.Underride, + kind: 'underride', pushRule: { rule_id: ruleId, default: true, @@ -45,8 +46,8 @@ const getAllMessageDefaultRule = ( }; type PushRulesProps = { - ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage; - pushRules: IPushRules; + ruleId: PushRuleId.DM | PushRuleId.EncryptedDM | PushRuleId.Message | PushRuleId.EncryptedMessage; + pushRules: PushRules; encrypted?: boolean; oneToOne?: boolean; }; @@ -56,7 +57,7 @@ function AllMessagesModeSwitcher({ encrypted = false, oneToOne = false, }: PushRulesProps) { - const mx = useMatrixClient(); + const ct = useClient(); const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne); const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData; const getModeActions = useNotificationModeActions(); @@ -64,20 +65,26 @@ function AllMessagesModeSwitcher({ const handleChange = useCallback( async (mode: NotificationMode) => { const actions = getModeActions(mode); - await mx.setPushRuleActions('global', kind, ruleId, actions); + await ct.requestAuthJSON( + putPushRuleActions({ + pathParams: { scope: 'global', kind, ruleId }, + body: { actions }, + }) + ); }, - [mx, getModeActions, kind, ruleId] + [ct, getModeActions, kind, ruleId] ); return ; } export function AllMessagesNotifications() { - const pushRulesEvt = useAccountData(AccountDataEvent.PushRules); - const pushRules = useMemo( - () => pushRulesEvt?.getContent() ?? { global: {} }, - [pushRulesEvt] - ); + const pushRuleEventResult = useAccountData(AccountDataType.PushRules); + const pushRules: PushRules | undefined = useMemo(() => { + if (pushRuleEventResult.pending) return undefined; + const content = pushRuleEventResult.value?.content as PushRules; + return content ?? { global: {} }; + }, [pushRuleEventResult]); return ( @@ -98,7 +105,11 @@ export function AllMessagesNotifications() { > } + after={ + pushRules && ( + + ) + } /> + pushRules && ( + + ) } /> @@ -127,7 +140,11 @@ export function AllMessagesNotifications() { > } + after={ + pushRules && ( + + ) + } /> + pushRules && ( + + ) } /> diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index 7f84d607..259736c8 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -1,12 +1,16 @@ import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react'; -import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk'; import { Box, Text, Badge, Button, Input, config, IconButton, Icons, Icon, Spinner } from 'folds'; -import { useAccountData } from '../../../hooks/useAccountData'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { + deletePushRule, + putPushRule, + putPushRuleActions, +} from 'neurone/lib/api/matrix/client/v3/pushrules'; +import { PushRule, PushRules } from 'neurone/lib/api/types'; +import { useAccountData } from '../../../hooks/storage/useAccountData'; +import { AccountDataType } from '../../../../types/matrix/accountData'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getNotificationModeActions, NotificationMode, @@ -15,25 +19,31 @@ import { } from '../../../hooks/useNotificationMode'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useClient } from '../../../hooks/useClient'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, }; function KeywordInput() { - const mx = useMatrixClient(); + const ct = useClient(); const [keyword, setKeyword] = useState(''); const [keywordState, addKeyword] = useAsyncCallback( useCallback( async (k: string) => { - mx.addPushRule('global', PushRuleKind.ContentSpecific, k, { - actions: getNotificationModeActions(NotificationMode.Notify, NOTIFY_MODE_OPS), - pattern: k, - }); + await ct.requestAuthJSON( + putPushRule({ + pathParams: { scope: 'global', kind: 'content', ruleId: k }, + body: { + actions: getNotificationModeActions(NotificationMode.Notify, NOTIFY_MODE_OPS), + pattern: k, + }, + }) + ); setKeyword(''); }, - [mx] + [ct] ) ); const addingKeyword = keywordState.status === AsyncStatus.Loading; @@ -104,15 +114,20 @@ function KeywordInput() { } type PushRulesProps = { - pushRule: IPushRule; + pushRule: PushRule; }; function KeywordCross({ pushRule }: PushRulesProps) { - const mx = useMatrixClient(); + const ct = useClient(); const [removeState, remove] = useAsyncCallback( useCallback( - () => mx.deletePushRule('global', PushRuleKind.ContentSpecific, pushRule.rule_id), - [mx, pushRule] + () => + ct.requestAuthJSON( + deletePushRule({ + pathParams: { scope: 'global', kind: 'content', ruleId: pushRule.rule_id }, + }) + ), + [ct, pushRule] ) ); @@ -125,35 +140,38 @@ function KeywordCross({ pushRule }: PushRulesProps) { } function KeywordModeSwitcher({ pushRule }: PushRulesProps) { - const mx = useMatrixClient(); + const ct = useClient(); const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS); const handleChange = useCallback( async (mode: NotificationMode) => { const actions = getModeActions(mode); - await mx.setPushRuleActions( - 'global', - PushRuleKind.ContentSpecific, - pushRule.rule_id, - actions + await ct.requestAuthJSON( + putPushRuleActions({ + pathParams: { scope: 'global', kind: 'content', ruleId: pushRule.rule_id }, + body: { + actions, + }, + }) ); }, - [mx, getModeActions, pushRule] + [ct, getModeActions, pushRule] ); return ; } export function KeywordMessagesNotifications() { - const pushRulesEvt = useAccountData(AccountDataEvent.PushRules); - const pushRules = useMemo( - () => pushRulesEvt?.getContent() ?? { global: {} }, - [pushRulesEvt] - ); + const pushRuleEventResult = useAccountData(AccountDataType.PushRules); + const pushRules: PushRules | undefined = useMemo(() => { + if (pushRuleEventResult.pending) return undefined; + const content = pushRuleEventResult.value?.content as PushRules; + return content ?? { global: {} }; + }, [pushRuleEventResult]); const keywordPushRules = useMemo(() => { - const content = pushRules.global.content ?? []; + const content = pushRules?.global.content ?? []; return content.filter( (pushRule) => pushRule.default === false && typeof pushRule.pattern === 'string' ); diff --git a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx b/src/app/features/settings/notifications/NotificationModeSwitcher.tsx index fe008390..9e353d6f 100644 --- a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx +++ b/src/app/features/settings/notifications/NotificationModeSwitcher.tsx @@ -11,9 +11,9 @@ import { Spinner, Text, } from 'folds'; -import { IPushRule } from 'matrix-js-sdk'; import React, { MouseEventHandler, useMemo, useState } from 'react'; import FocusTrap from 'focus-trap-react'; +import { PushRule } from 'neurone/lib/api/types'; import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode'; import { stopPropagation } from '../../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; @@ -32,7 +32,7 @@ const useNotificationModeStr = (): Record => ); type NotificationModeSwitcherProps = { - pushRule: IPushRule; + pushRule: PushRule; onChange: (mode: NotificationMode) => Promise; }; export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) { diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx index cbebf648..edea496b 100644 --- a/src/app/features/settings/notifications/SpecialMessages.tsx +++ b/src/app/features/settings/notifications/SpecialMessages.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useMemo } from 'react'; -import { ConditionKind, IPushRules, PushRuleKind, RuleId } from 'matrix-js-sdk'; import { Box, Text, Badge } from 'folds'; -import { useAccountData } from '../../../hooks/useAccountData'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { PushRules } from 'neurone/lib/api/types'; +import { putPushRuleActions } from 'neurone/lib/api/matrix/client/v3/pushrules'; +import { useAccountData } from '../../../hooks/storage/useAccountData'; +import { AccountDataType, ConditionKind, PushRuleId } from '../../../../types/matrix/accountData'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useUserProfile } from '../../../hooks/useUserProfile'; import { getMxIdLocalPart } from '../../../utils/matrix'; import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule'; @@ -17,14 +17,15 @@ import { useNotificationModeActions, } from '../../../hooks/useNotificationMode'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; +import { useClient } from '../../../hooks/useClient'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, }; const getDefaultIsUserMention = (userId: string): PushRuleData => makePushRuleData( - PushRuleKind.Override, - RuleId.IsUserMention, + 'override', + PushRuleId.IsUserMention, getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }), [ { @@ -36,8 +37,8 @@ const getDefaultIsUserMention = (userId: string): PushRuleData => ); const DefaultContainsDisplayName = makePushRuleData( - PushRuleKind.Override, - RuleId.ContainsDisplayName, + 'override', + PushRuleId.ContainsDisplayName, getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }), [ { @@ -48,16 +49,16 @@ const DefaultContainsDisplayName = makePushRuleData( const getDefaultContainsUsername = (username: string) => makePushRuleData( - PushRuleKind.ContentSpecific, - RuleId.ContainsUserName, + 'content', + PushRuleId.ContainsUserName, getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }), undefined, username ); const DefaultIsRoomMention = makePushRuleData( - PushRuleKind.Override, - RuleId.IsRoomMention, + 'override', + PushRuleId.IsRoomMention, getNotificationModeActions(NotificationMode.Notify, { highlight: true }), [ { @@ -73,8 +74,8 @@ const DefaultIsRoomMention = makePushRuleData( ); const DefaultAtRoomNotification = makePushRuleData( - PushRuleKind.Override, - RuleId.AtRoomNotification, + 'override', + PushRuleId.AtRoomNotification, getNotificationModeActions(NotificationMode.Notify, { highlight: true }), [ { @@ -90,12 +91,12 @@ const DefaultAtRoomNotification = makePushRuleData( ); type PushRulesProps = { - ruleId: RuleId; - pushRules: IPushRules; + ruleId: PushRuleId; + pushRules: PushRules; defaultPushRuleData: PushRuleData; }; function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRulesProps) { - const mx = useMatrixClient(); + const ct = useClient(); const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData; const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS); @@ -103,23 +104,30 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul const handleChange = useCallback( async (mode: NotificationMode) => { const actions = getModeActions(mode); - await mx.setPushRuleActions('global', kind, ruleId, actions); + await ct.requestAuthJSON( + putPushRuleActions({ + pathParams: { scope: 'global', kind, ruleId }, + body: { actions }, + }) + ); }, - [mx, getModeActions, kind, ruleId] + [ct, getModeActions, kind, ruleId] ); return ; } export function SpecialMessagesNotifications() { - const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const { displayName } = useUserProfile(userId); - const pushRulesEvt = useAccountData(AccountDataEvent.PushRules); - const pushRules = useMemo( - () => pushRulesEvt?.getContent() ?? { global: {} }, - [pushRulesEvt] - ); + const ct = useClient(); + const { userId } = ct.session; + const { displayName } = useUserProfile(userId) ?? {}; + + const pushRuleEventResult = useAccountData(AccountDataType.PushRules); + const pushRules: PushRules | undefined = useMemo(() => { + if (pushRuleEventResult.pending) return undefined; + const content = pushRuleEventResult.value?.content as PushRules; + return content ?? { global: {} }; + }, [pushRuleEventResult]); return ( @@ -141,11 +149,13 @@ export function SpecialMessagesNotifications() { + pushRules && ( + + ) } /> @@ -158,11 +168,13 @@ export function SpecialMessagesNotifications() { + pushRules && ( + + ) } /> @@ -175,11 +187,13 @@ export function SpecialMessagesNotifications() { + pushRules && ( + + ) } /> @@ -192,11 +206,13 @@ export function SpecialMessagesNotifications() { + pushRules && ( + + ) } /> @@ -209,11 +225,13 @@ export function SpecialMessagesNotifications() { + pushRules && ( + + ) } /> diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e0df06df..11c077ff 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import { Box, Text, Switch, Button, color, Spinner } from 'folds'; -import { IPusherRequest } from 'matrix-js-sdk'; +import { postPushersSet } from 'neurone/lib/api/matrix/client/v3/pushers'; +import { PusherData } from 'neurone/lib/api/types'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -9,37 +10,45 @@ import { settingsAtom } from '../../../state/settings'; import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useClient } from '../../../hooks/useClient'; function EmailNotification() { - const mx = useMatrixClient(); + const ct = useClient(); const [result, refreshResult] = useEmailNotifications(); const [setState, setEnable] = useAsyncCallback( useCallback( async (email: string, enable: boolean) => { if (enable) { - await mx.setPusher({ - kind: 'email', - app_id: 'm.email', - pushkey: email, - app_display_name: 'Email Notifications', - device_display_name: email, - lang: 'en', - data: { - brand: 'Cinny', - }, - append: true, - }); + await ct.requestAuthJSON( + postPushersSet({ + body: { + kind: 'email', + app_id: 'm.email', + pushkey: email, + app_display_name: 'Email Notifications', + device_display_name: email, + lang: 'en', + data: { + brand: 'Cinny', // brand field is not in spec. + } as PusherData, + append: true, + }, + }) + ); return; } - await mx.setPusher({ - pushkey: email, - app_id: 'm.email', - kind: null, - } as unknown as IPusherRequest); + await ct.requestAuthJSON( + postPushersSet({ + body: { + pushkey: email, + app_id: 'm.email', + kind: null, + }, + }) + ); }, - [mx] + [ct] ) ); diff --git a/src/app/hooks/router/useSelectedRoom.ts b/src/app/hooks/router/useSelectedRoom.ts index 6d0ee4f1..8358aeb0 100644 --- a/src/app/hooks/router/useSelectedRoom.ts +++ b/src/app/hooks/router/useSelectedRoom.ts @@ -1,15 +1,15 @@ import { useParams } from 'react-router-dom'; -import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; -import { useMatrixClient } from '../useMatrixClient'; +import { isRoomAlias } from '../../utils/matrix'; +import { useClient } from '../useClient'; export const useSelectedRoom = (): string | undefined => { - const mx = useMatrixClient(); + const ct = useClient(); const { roomIdOrAlias } = useParams(); - const roomId = - roomIdOrAlias && isRoomAlias(roomIdOrAlias) - ? getCanonicalAliasRoomId(mx, roomIdOrAlias) - : roomIdOrAlias; - return roomId; + if (roomIdOrAlias && isRoomAlias(roomIdOrAlias)) { + return ct.rooms.join.read.find((roomId) => ct.roomToAlias.read.get(roomId) === roomIdOrAlias); + } + + return roomIdOrAlias; }; diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts index 2e8e73a0..dce85948 100644 --- a/src/app/hooks/router/useSelectedSpace.ts +++ b/src/app/hooks/router/useSelectedSpace.ts @@ -1,17 +1,23 @@ import { useMatch, useParams } from 'react-router-dom'; -import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; -import { useMatrixClient } from '../useMatrixClient'; +import { isRoomAlias } from '../../utils/matrix'; import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils'; +import { useClient } from '../useClient'; +import { useObservableValue } from '../useObservableValue'; export const useSelectedSpace = (): string | undefined => { - const mx = useMatrixClient(); - + const ct = useClient(); const { spaceIdOrAlias } = useParams(); - const spaceId = - spaceIdOrAlias && isRoomAlias(spaceIdOrAlias) - ? getCanonicalAliasRoomId(mx, spaceIdOrAlias) - : spaceIdOrAlias; + const roomToAlias = useObservableValue(ct.roomToAlias); + + if (!spaceIdOrAlias) return undefined; + + const spaceId = isRoomAlias(spaceIdOrAlias) + ? ct.rooms.join.read.find( + (roomId) => + roomToAlias.get(roomId) === spaceIdOrAlias && !ct.roomToReplacement.read.has(roomId) + ) + : spaceIdOrAlias; return spaceId; }; diff --git a/src/app/hooks/storage/useAccountData.ts b/src/app/hooks/storage/useAccountData.ts new file mode 100644 index 00000000..83b5c4d7 --- /dev/null +++ b/src/app/hooks/storage/useAccountData.ts @@ -0,0 +1,25 @@ +import { useCallback, useMemo } from 'react'; +import { AccountDataEvent } from 'neurone/lib/api/types'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; +import { useClient } from '../useClient'; +import { + useComputedPendingObservableResult, + useObservableValuePromise, +} from '../useObservableValue'; + +export function useAccountData( + eventType: string +): PendingObservableResult { + const ct = useClient(); + const observablePromise = useMemo( + () => ct.storage.accountData.observableRead.get(eventType), + [ct, eventType] + ); + + const result = useObservableValuePromise(observablePromise); + + return useComputedPendingObservableResult( + result, + useCallback((v) => v?.event as T | undefined, []) + ); +} diff --git a/src/app/hooks/storage/useGetRoomAvatar.ts b/src/app/hooks/storage/useGetRoomAvatar.ts new file mode 100644 index 00000000..b2fe8716 --- /dev/null +++ b/src/app/hooks/storage/useGetRoomAvatar.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; +import { useClient } from '../useClient'; +import { useObservableValue } from '../useObservableValue'; +import { GetRoomHeroesCallback, RoomToHeroMembers } from './useRoomHeros'; +import { roomMemberContentAvatarUrl } from '../../utils/room'; + +export type GetRoomAvatarCallback = (roomId: string) => string | undefined; + +export const useGetRoomAvatar = ( + getRoomHeroes: GetRoomHeroesCallback, + roomToHeroMembers: RoomToHeroMembers | undefined +): GetRoomAvatarCallback => { + const ct = useClient(); + const roomToAvatar = useObservableValue(ct.roomToAvatar); + + const getRoomAvatar: GetRoomAvatarCallback = useCallback( + (roomId: string) => { + const avatar = roomToAvatar.get(roomId); + if (avatar) return avatar; + + const members = roomToHeroMembers?.get(roomId); + + const heroesAvatar: string[] = []; + getRoomHeroes(roomId).forEach((userId) => { + const content = members?.get(userId)?.content; + const avatarUrl = content ? roomMemberContentAvatarUrl(content) : undefined; + if (avatarUrl) heroesAvatar.push(avatarUrl); + }); + + return heroesAvatar[0]; + }, + [roomToAvatar, getRoomHeroes, roomToHeroMembers] + ); + + return getRoomAvatar; +}; diff --git a/src/app/hooks/storage/useGetRoomName.ts b/src/app/hooks/storage/useGetRoomName.ts new file mode 100644 index 00000000..ca083daa --- /dev/null +++ b/src/app/hooks/storage/useGetRoomName.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; +import { useClient } from '../useClient'; +import { useObservableValue } from '../useObservableValue'; +import { GetRoomHeroesCallback, RoomToHeroMembers } from './useRoomHeros'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { roomMemberContentDisplayname } from '../../utils/room'; + +export type GetRoomNameCallback = (roomId: string) => string | undefined; + +export const useGetRoomName = ( + getRoomHeroes: GetRoomHeroesCallback, + roomToHeroMembers: RoomToHeroMembers | undefined +): GetRoomNameCallback => { + const ct = useClient(); + const roomToName = useObservableValue(ct.roomToName); + const roomToAlias = useObservableValue(ct.roomToAlias); + + const getRoomName: GetRoomNameCallback = useCallback( + (roomId: string) => { + const name = roomToName.get(roomId); + if (name) return name; + + const alias = roomToAlias.get(roomId); + if (alias) return alias; + + const members = roomToHeroMembers?.get(roomId); + + const heroesName: string[] = getRoomHeroes(roomId).map((userId) => { + const member = members?.get(userId); + if (!member) return getMxIdLocalPart(userId) ?? userId; + const { content } = member; + const username = + roomMemberContentDisplayname(content)?.trim() || getMxIdLocalPart(userId) || userId; + return username; + }); + + if (heroesName) return heroesName.join(', '); + + return undefined; + }, + [roomToName, roomToAlias, getRoomHeroes, roomToHeroMembers] + ); + + return getRoomName; +}; diff --git a/src/app/hooks/storage/useRoomHeros.ts b/src/app/hooks/storage/useRoomHeros.ts new file mode 100644 index 00000000..c1a214e0 --- /dev/null +++ b/src/app/hooks/storage/useRoomHeros.ts @@ -0,0 +1,57 @@ +import { useCallback, useMemo } from 'react'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; +import { StateEventWithoutRoomId } from 'neurone/lib/api/types'; +import { useClient } from '../useClient'; +import { + useComputedPendingObservableResult, + useObservableValue, + useObservableValuePromise, +} from '../useObservableValue'; +import { getJoinRoomMemberId } from '../../../client/storage/collections'; + +export type UserIdToRoomMember = Map; +export type RoomToHeroMembers = Map; + +export type GetRoomHeroesCallback = (roomId: string) => string[]; + +export const useRoomHeroes = ( + roomOrRooms: string | string[] +): [GetRoomHeroesCallback, PendingObservableResult] => { + const ct = useClient(); + const roomToSummary = useObservableValue(ct.roomToSummary); + + const getHeroes: GetRoomHeroesCallback = useCallback( + (roomId) => roomToSummary.get(roomId)?.['m.heroes'] ?? [], + [roomToSummary] + ); + + const membersResult = useObservableValuePromise( + useMemo(() => { + const ids = Array.isArray(roomOrRooms) + ? roomOrRooms.flatMap((roomId) => + getHeroes(roomId).map((userId) => getJoinRoomMemberId(roomId, userId)) + ) + : getHeroes(roomOrRooms).map((userId) => getJoinRoomMemberId(roomOrRooms, userId)); + + return ct.storage.joinRoomMember.observableRead.getMultiple(ids); + }, [roomOrRooms, getHeroes, ct]) + ); + + const roomToHeroesResult: PendingObservableResult = + useComputedPendingObservableResult( + membersResult, + useCallback((members) => { + const roomToHeroes = new Map(); + members.forEach((member) => { + if (!member) return; + const { roomId, userId, event } = member; + const userToEvent: UserIdToRoomMember = roomToHeroes.get(roomId) ?? new Map(); + userToEvent.set(userId, event); + roomToHeroes.set(roomId, userToEvent); + }); + return roomToHeroes; + }, []) + ); + + return [getHeroes, roomToHeroesResult]; +}; diff --git a/src/app/hooks/storage/useRoomStateEvent.ts b/src/app/hooks/storage/useRoomStateEvent.ts new file mode 100644 index 00000000..6c26559f --- /dev/null +++ b/src/app/hooks/storage/useRoomStateEvent.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; +import { QueryRange } from 'neurone/lib/storage/collection'; +import { getJoinRoomStateId, JoinRoomStateObject } from '../../../client/storage/collections'; +import { useClient } from '../useClient'; +import { useObservableValuePromise } from '../useObservableValue'; +import { MAX_UNICODE } from '../../../client/storage'; + +export const useRoomStateEvent = ( + roomId: string, + eventType: string, + stateKey: string +): PendingObservableResult => { + const ct = useClient(); + const stateEventPromise = useMemo( + () => + ct.storage.joinRoomState.observableRead.get(getJoinRoomStateId(roomId, eventType, stateKey)), + [ct, roomId, eventType, stateKey] + ); + + const stateEventResult = useObservableValuePromise(stateEventPromise); + + return stateEventResult; +}; + +export const useRoomStateEvents = ( + roomId: string, + eventType: string +): PendingObservableResult => { + const ct = useClient(); + const stateEventPromise = useMemo( + () => + ct.storage.joinRoomState.observableRead.query( + new QueryRange( + getJoinRoomStateId(roomId, eventType, ''), + getJoinRoomStateId(roomId, eventType, MAX_UNICODE) + ) + ), + [ct, roomId, eventType] + ); + + const stateEventResult = useObservableValuePromise(stateEventPromise); + + return stateEventResult; +}; diff --git a/src/app/hooks/storage/useRoomTopic.ts b/src/app/hooks/storage/useRoomTopic.ts new file mode 100644 index 00000000..14e1cebb --- /dev/null +++ b/src/app/hooks/storage/useRoomTopic.ts @@ -0,0 +1,11 @@ +import { StateEventType } from '../../../types/matrix/room'; +import { useRoomStateEvent } from './useRoomStateEvent'; + +export const useRoomTopic = (roomId: string): string | undefined => { + const topicEventResult = useRoomStateEvent(roomId, StateEventType.RoomTopic, ''); + + const content = topicEventResult.value?.event.content; + if (content && 'topic' in content && typeof content.topic === 'string') return content.topic; + + return undefined; +}; diff --git a/src/app/hooks/types.ts b/src/app/hooks/types.ts index 9aac2b31..1ec9dfea 100644 --- a/src/app/hooks/types.ts +++ b/src/app/hooks/types.ts @@ -1,12 +1,17 @@ -import { IRequestTokenResponse } from 'matrix-js-sdk'; +import { PostEmailRegisterTokenOK } from 'neurone/lib/api/matrix/client/v3/register'; +import { MatrixError } from 'neurone/lib/matrix-request'; export type RequestEmailTokenResponse = { email: string; clientSecret: string; - result: IRequestTokenResponse; + result: PostEmailRegisterTokenOK; }; +export type RequestEmailTokenResult = + | [undefined, RequestEmailTokenResponse] + | [MatrixError, undefined]; + export type RequestEmailTokenCallback = ( email: string, clientSecret: string, nextLink?: string -) => Promise; +) => Promise; diff --git a/src/app/hooks/useAuthFlows.ts b/src/app/hooks/useAuthFlows.ts index 7bb7ddc5..43faa6bb 100644 --- a/src/app/hooks/useAuthFlows.ts +++ b/src/app/hooks/useAuthFlows.ts @@ -1,6 +1,7 @@ import { createContext, useContext } from 'react'; -import { IAuthData, MatrixError } from 'matrix-js-sdk'; -import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth'; +import { MatrixError, MatrixErrorData } from 'neurone/lib/matrix-request'; +import { GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login'; +import { AuthResponse } from '../utils/matrix-uia'; export enum RegisterFlowStatus { FlowRequired = 401, @@ -12,14 +13,16 @@ export enum RegisterFlowStatus { export type RegisterFlowsResponse = | { status: RegisterFlowStatus.FlowRequired; - data: IAuthData; + data: AuthResponse; } | { status: Exclude; }; -export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsResponse => { - switch (matrixError.httpStatus) { +export const parseRegisterErrResp = ( + matrixError: MatrixError +): RegisterFlowsResponse => { + switch (matrixError.status) { case RegisterFlowStatus.InvalidRequest: { return { status: RegisterFlowStatus.InvalidRequest }; } @@ -32,7 +35,7 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes case RegisterFlowStatus.FlowRequired: { return { status: RegisterFlowStatus.FlowRequired, - data: matrixError.data as IAuthData, + data: matrixError.data as AuthResponse, }; } default: { @@ -42,7 +45,7 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes }; export type AuthFlows = { - loginFlows: ILoginFlowsResponse; + loginFlows: GetLoginOK; registerFlows: RegisterFlowsResponse; }; diff --git a/src/app/hooks/useAuthMetadata.ts b/src/app/hooks/useAuthMetadata.ts index db967463..bc991930 100644 --- a/src/app/hooks/useAuthMetadata.ts +++ b/src/app/hooks/useAuthMetadata.ts @@ -1,11 +1,11 @@ -import { ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { GetAuthMetadataOK } from 'neurone/lib/api/matrix/client/v1/authMetadata'; import { createContext, useContext } from 'react'; -const AuthMetadataContext = createContext(undefined); +const AuthMetadataContext = createContext(undefined); export const AuthMetadataProvider = AuthMetadataContext.Provider; -export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => { +export const useAuthMetadata = (): GetAuthMetadataOK | undefined => { const metadata = useContext(AuthMetadataContext); return metadata; diff --git a/src/app/hooks/useCapabilities.ts b/src/app/hooks/useCapabilities.ts index b2fbd640..72aeecac 100644 --- a/src/app/hooks/useCapabilities.ts +++ b/src/app/hooks/useCapabilities.ts @@ -1,6 +1,31 @@ -import { Capabilities } from 'matrix-js-sdk'; import { createContext, useContext } from 'react'; +export interface ICapability { + enabled: boolean; +} + +export enum RoomVersionStability { + Stable = 'stable', + Unstable = 'unstable', +} + +export interface IRoomVersionsCapability { + default: string; + available: Record; +} + +export interface Capabilities { + [key: string]: any; + 'm.change_password'?: ICapability; + 'm.room_versions'?: IRoomVersionsCapability; + 'io.element.thread'?: ICapability; + 'm.get_login_token'?: ICapability; + 'org.matrix.msc3882.get_login_token'?: ICapability; + 'm.set_displayname'?: ICapability; + 'm.set_avatar_url'?: ICapability; + 'uk.tcpip.msc4133.profile_fields'?: ICapability; +} + const CapabilitiesContext = createContext(null); export const CapabilitiesProvider = CapabilitiesContext.Provider; diff --git a/src/app/hooks/useClient.ts b/src/app/hooks/useClient.ts new file mode 100644 index 00000000..aafd8287 --- /dev/null +++ b/src/app/hooks/useClient.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; +import { ClientContext } from '../../client/context'; + +const ClientCtx = createContext(null); + +export const ClientContextProvider = ClientCtx.Provider; + +export const useClient = (): ClientContext => { + const ct = useContext(ClientCtx); + + if (ct === null) { + throw new Error('Client Context is not provided!'); + } + + return ct; +}; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..9eb27a95 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -1,19 +1,25 @@ -import { - Direction, - EventTimeline, - IContextResponse, - MatrixClient, - Method, - Preset, - Room, - RoomMember, - Visibility, -} from 'matrix-js-sdk'; -import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types'; import { useMemo } from 'react'; +import { postCreateRoom, PostCreateRoomOK } from 'neurone/lib/api/matrix/client/v3/createRoom'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { postJoin } from 'neurone/lib/api/matrix/client/v3/join'; +import { + getContext, + GetContextOK, + getMessages, + GetMessagesOK, + postBan, + postInvite, + postKick, + postLeave, + postUnban, + putRedact, + putStateEvent, +} from 'neurone/lib/api/matrix/client/v3/rooms'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; +import { getTimestampToEvent, GetTimestampToEventOK } from 'neurone/lib/api/matrix/client/v1/rooms'; import { addRoomIdToMDirect, - getDMRoomFor, + getUserDirectRooms, guessDmRoomUserId, isRoomAlias, isRoomId, @@ -23,10 +29,19 @@ import { removeRoomIdFromMDirect, } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; -import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent } from '../utils/room'; +import { RoomServerAclContent, StateEventType } from '../../types/matrix/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; +import { useClient } from './useClient'; +import { useRoom } from './useRoom'; +import { useRoomMembersAPI } from './useRoomMembers'; +import { + getJoinRoomMemberId, + getJoinRoomStateId, + JoinRoomMemberObject, +} from '../../client/storage/collections'; +import { AccountDataType } from '../../types/matrix/accountData'; +import { getIgnoredUsersContent, parseIgnoreUsersContent } from './useIgnoredUsers'; export const SHRUG = '¯\\_(ツ)_/¯'; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; @@ -36,7 +51,7 @@ const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b'; const FLAG_REG = new RegExp(FLAG_PAT); const FLAG_REG_G = new RegExp(FLAG_PAT, 'g'); -export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => { +const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => { const flagMatch = payload.match(FLAG_REG); if (!flagMatch) { @@ -48,7 +63,7 @@ export const splitPayloadContentAndFlags = (payload: string): [string, string | return [content, flags]; }; -export const parseFlags = (flags: string | undefined): Record => { +const parseFlags = (flags: string | undefined): Record => { const result: Record = {}; if (!flags) return result; @@ -69,7 +84,7 @@ export const parseFlags = (flags: string | undefined): Record { +const parseUsers = (payload: string): string[] => { const users: string[] = []; splitWithSpace(payload).forEach((item) => { @@ -81,7 +96,7 @@ export const parseUsers = (payload: string): string[] => { return users; }; -export const parseServers = (payload: string): string[] => { +const parseServers = (payload: string): string[] => { const servers: string[] = []; splitWithSpace(payload).forEach((item) => { @@ -93,13 +108,10 @@ export const parseServers = (payload: string): string[] => { return servers; }; -const getServerMembers = (room: Room, server: string): RoomMember[] => { - const members: RoomMember[] = room - .getMembers() - .filter((member) => member.userId.endsWith(`:${server}`)); - - return members; -}; +const getServerMembers = ( + members: JoinRoomMemberObject[], + server: string +): JoinRoomMemberObject[] => members.filter((member) => member.userId.endsWith(`:${server}`)); export const parseTimestampFlag = (input: string): number | undefined => { const match = input.match(/^(\d+(?:\.\d+)?)([dhms])$/); // supports floats like 1.5d @@ -169,7 +181,10 @@ export type CommandContent = { export type CommandRecord = Record; -export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { +export const useCommands = (): CommandRecord => { + const ct = useClient(); + const roomId = useRoom(); + const membersAPI = useRoomMembersAPI(); const { navigateRoom } = useRoomNavigate(); const commands: CommandRecord = useMemo( @@ -204,23 +219,33 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Start direct message with user. Example: /startdm userId1', exe: async (payload) => { const rawIds = splitWithSpace(payload); - const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getSafeUserId()); + const userIds = rawIds.filter((id) => isUserId(id) && id !== ct.session.userId); if (userIds.length === 0) return; if (userIds.length === 1) { - const dmRoomId = getDMRoomFor(mx, userIds[0])?.roomId; + const dmRoomId = getUserDirectRooms(ct, userIds[0])[0]; if (dmRoomId) { navigateRoom(dmRoomId); return; } } - const result = await mx.createRoom({ - is_direct: true, - invite: userIds, - visibility: Visibility.Private, - preset: Preset.TrustedPrivateChat, - initial_state: [createRoomEncryptionState()], - }); - addRoomIdToMDirect(mx, result.room_id, userIds[0]); + const response = await ct.requestAuthJSON( + postCreateRoom({ + body: { + is_direct: true, + invite: userIds, + visibility: 'private', + preset: 'trusted_private_chat', + initial_state: [createRoomEncryptionState()], + }, + }) + ); + const [err, result] = await parseMatrixResp(response); + if (err) { + console.error(printMatrixError(err)); + return; + } + + addRoomIdToMDirect(ct, result.room_id, userIds[0]); navigateRoom(result.room_id); }, }, @@ -232,8 +257,8 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const roomIdOrAliases = rawIds.filter( (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) ); - roomIdOrAliases.forEach(async (idOrAlias) => { - await mx.joinRoom(idOrAlias); + roomIdOrAliases.forEach(async (roomIdOrAlias) => { + await ct.requestAuthJSON(postJoin({ pathParams: { roomIdOrAlias } })); }); }, }, @@ -242,12 +267,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Leave current room.', exe: async (payload) => { if (payload.trim() === '') { - mx.leave(room.roomId); + ct.requestAuthJSON(postLeave({ pathParams: { roomId } })); return; } const rawIds = splitWithSpace(payload); const roomIds = rawIds.filter((id) => isRoomId(id)); - roomIds.map((id) => mx.leave(id)); + roomIds.map((id) => ct.requestAuthJSON(postLeave({ pathParams: { roomId: id } }))); }, }, [Command.Invite]: { @@ -258,7 +283,14 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const users = parseUsers(content); const flagToContent = parseFlags(flags); const reason = flagToContent.r; - users.map((id) => mx.invite(room.roomId, id, reason)); + users.map((id) => + ct.requestAuthJSON( + postInvite({ + pathParams: { roomId }, + body: { user_id: id, reason }, + }) + ) + ); }, }, [Command.DisInvite]: { @@ -269,7 +301,14 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const users = parseUsers(content); const flagToContent = parseFlags(flags); const reason = flagToContent.r; - users.map((id) => mx.kick(room.roomId, id, reason)); + users.map((id) => + ct.requestAuthJSON( + postKick({ + pathParams: { roomId }, + body: { user_id: id, reason }, + }) + ) + ); }, }, [Command.Kick]: { @@ -282,9 +321,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const flagToContent = parseFlags(flags); const reason = flagToContent.r; - const serverMembers = servers?.flatMap((server) => getServerMembers(room, server)); + const serverMembers = servers?.flatMap((server) => + getServerMembers(membersAPI.memberEventsResult.value ?? [], server) + ); const serverUsers = serverMembers - ?.filter((m) => m.membership !== Membership.Ban) + ?.filter((m) => membersAPI.getMembership(m.userId) !== 'ban') .map((m) => m.userId); if (Array.isArray(serverUsers)) { @@ -293,7 +334,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }); } - rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason)); + rateLimitedActions(users, async (id) => { + const response = await ct.requestAuthJSON( + postKick({ + pathParams: { roomId }, + body: { user_id: id, reason }, + }) + ); + const [err] = await parseMatrixResp(response); + return err; + }); }, }, [Command.Ban]: { @@ -306,7 +356,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const flagToContent = parseFlags(flags); const reason = flagToContent.r; - const serverMembers = servers?.flatMap((server) => getServerMembers(room, server)); + const serverMembers = servers?.flatMap((server) => + getServerMembers(membersAPI.memberEventsResult.value ?? [], server) + ); const serverUsers = serverMembers?.map((m) => m.userId); if (Array.isArray(serverUsers)) { @@ -315,7 +367,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }); } - rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason)); + rateLimitedActions(users, async (id) => { + const response = await ct.requestAuthJSON( + postBan({ + pathParams: { roomId }, + body: { user_id: id, reason }, + }) + ); + const [err] = await parseMatrixResp(response); + return err; + }); }, }, [Command.UnBan]: { @@ -324,7 +385,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { exe: async (payload) => { const rawIds = splitWithSpace(payload); const users = rawIds.filter((id) => isUserId(id)); - users.map((id) => mx.unban(room.roomId, id)); + rateLimitedActions(users, async (id) => { + const response = await ct.requestAuthJSON( + postUnban({ + pathParams: { roomId }, + body: { user_id: id }, + }) + ); + const [err] = await parseMatrixResp(response); + return err; + }); }, }, [Command.Ignore]: { @@ -334,9 +404,17 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const rawIds = splitWithSpace(payload); const userIds = rawIds.filter((id) => isUserId(id)); if (userIds.length > 0) { - let ignoredUsers = mx.getIgnoredUsers().concat(userIds); - ignoredUsers = [...new Set(ignoredUsers)]; - await mx.setIgnoredUsers(ignoredUsers); + const ignoreUserListObj = await ct.storage.accountData.read.get( + AccountDataType.IgnoredUserList + ); + const { content } = ignoreUserListObj?.event ?? {}; + const ignoredUsers = parseIgnoreUsersContent(content).concat(userIds); + await ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, + body: getIgnoredUsersContent(ignoredUsers), + }) + ); } }, }, @@ -347,8 +425,19 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const rawIds = splitWithSpace(payload); const userIds = rawIds.filter((id) => isUserId(id)); if (userIds.length > 0) { - const ignoredUsers = mx.getIgnoredUsers(); - await mx.setIgnoredUsers(ignoredUsers.filter((id) => !userIds.includes(id))); + const ignoreUserListObj = await ct.storage.accountData.read.get( + AccountDataType.IgnoredUserList + ); + const { content } = ignoreUserListObj?.event ?? {}; + const ignoredUsers = parseIgnoreUsersContent(content).filter( + (id) => !userIds.includes(id) + ); + await ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, + body: getIgnoredUsersContent(ignoredUsers), + }) + ); } }, }, @@ -358,20 +447,24 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { exe: async (payload) => { const nick = payload.trim(); if (nick === '') return; - const mEvent = room - .getLiveTimeline() - .getState(EventTimeline.FORWARDS) - ?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId()); - const content = mEvent?.getContent(); + const memberObj = await ct.storage.joinRoomMember.read.get( + getJoinRoomMemberId(roomId, ct.session.userId) + ); + const { content } = memberObj?.event ?? {}; if (!content) return; - await mx.sendStateEvent( - room.roomId, - StateEvent.RoomMember as any, - { - ...content, - displayname: nick, - }, - mx.getSafeUserId() + + ct.requestAuthJSON( + putStateEvent({ + pathParams: { + roomId, + eventType: StateEventType.RoomMember, + stateKey: ct.session.userId, + }, + body: { + ...content, + displayname: nick, + }, + }) ); }, }, @@ -380,20 +473,23 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc', exe: async (payload) => { if (payload.match(/^mxc:\/\/\S+$/)) { - const mEvent = room - .getLiveTimeline() - .getState(EventTimeline.FORWARDS) - ?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId()); - const content = mEvent?.getContent(); + const memberObj = await ct.storage.joinRoomMember.read.get( + getJoinRoomMemberId(roomId, ct.session.userId) + ); + const { content } = memberObj?.event ?? {}; if (!content) return; - await mx.sendStateEvent( - room.roomId, - StateEvent.RoomMember as any, - { - ...content, - avatar_url: payload, - }, - mx.getSafeUserId() + ct.requestAuthJSON( + putStateEvent({ + pathParams: { + roomId, + eventType: StateEventType.RoomMember, + stateKey: ct.session.userId, + }, + body: { + ...content, + avatar_url: payload, + }, + }) ); } }, @@ -402,15 +498,18 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: Command.ConvertToDm, description: 'Convert room to direct message', exe: async () => { - const dmUserId = guessDmRoomUserId(room, mx.getSafeUserId()); - await addRoomIdToMDirect(mx, room.roomId, dmUserId); + const dmUserId = guessDmRoomUserId( + membersAPI.memberEventsResult.value ?? [], + ct.session.userId + ); + await addRoomIdToMDirect(ct, roomId, dmUserId); }, }, [Command.ConvertToRoom]: { name: Command.ConvertToRoom, description: 'Convert direct message to room', exe: async () => { - await removeRoomIdFromMDirect(mx, room.roomId); + await removeRoomIdFromMDirect(ct, roomId); }, }, [Command.Delete]: { @@ -431,7 +530,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const ts = parseTimestampFlag(pastContent); if (!ts) return; - const serverMembers = servers?.flatMap((server) => getServerMembers(room, server)); + const serverMembers = servers?.flatMap((server) => + getServerMembers(membersAPI.memberEventsResult.value ?? [], server) + ); const serverUsers = serverMembers?.map((m) => m.userId); if (Array.isArray(serverUsers)) { @@ -440,26 +541,50 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }); } - const result = await mx.timestampToEvent(room.roomId, ts, Direction.Forward); + const tsResp = await ct.requestAuthJSON( + getTimestampToEvent({ + pathParams: { roomId }, + searchParams: { ts, dir: 'f' }, + }) + ); + const [err, result] = await parseMatrixResp(tsResp); + if (err) { + console.error(printMatrixError(err)); + return; + } const startEventId = result.event_id; - const path = `/rooms/${encodeURIComponent(room.roomId)}/context/${encodeURIComponent( - startEventId - )}`; - const eventContext = await mx.http.authedRequest(Method.Get, path, { - limit: 0, - }); + const ctxResp = await ct.requestAuthJSON( + getContext({ + pathParams: { roomId, eventId: startEventId }, + searchParams: { limit: 0 }, + }) + ); + const [ctxErr, eventContext] = await parseMatrixResp(ctxResp); + if (ctxErr) { + console.error(printMatrixError(ctxErr)); + return; + } let token: string | undefined = eventContext.start; while (token) { // eslint-disable-next-line no-await-in-loop - const response = await mx.createMessagesRequest( - room.roomId, - token, - 20, - Direction.Forward, - undefined + const msgResp = await ct.requestAuthJSON( + getMessages({ + pathParams: { roomId }, + searchParams: { + from: token, + limit: 20, + dir: 'f', + }, + }) ); + // eslint-disable-next-line no-await-in-loop + const [msgErr, response] = await parseMatrixResp(msgResp); + if (msgErr) { + console.error(printMatrixError(msgErr)); + break; + } const { end, chunk } = response; // remove until the latest event; token = end; @@ -468,15 +593,22 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { (roomEvent) => (messageTypes.length > 0 ? messageTypes.includes(roomEvent.type) : true) && users.includes(roomEvent.sender) && - roomEvent.unsigned?.redacted_because === undefined + (!roomEvent.unsigned || !('redacted_because' in roomEvent.unsigned)) ); const eventIds = eventsToDelete.map((roomEvent) => roomEvent.event_id); // eslint-disable-next-line no-await-in-loop - await rateLimitedActions(eventIds, (eventId) => - mx.redactEvent(room.roomId, eventId, undefined, { reason }) - ); + await rateLimitedActions(eventIds, async (eventId) => { + const redactResp = await ct.requestAuthJSON( + putRedact({ + pathParams: { roomId, eventId, txnId: ct.getTxnId() }, + body: { reason }, + }) + ); + const [redactErr] = await parseMatrixResp(redactResp); + return redactErr; + }); } }, }, @@ -498,12 +630,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const removeAllowList = removeAllowFlag ? splitWithSpace(removeAllowFlag) : []; const removeDenyList = removeDenyFlag ? splitWithSpace(removeDenyFlag) : []; - const serverAcl = getStateEvent( - room, - StateEvent.RoomServerAcl - )?.getContent(); + const serverAclObj = await ct.storage.joinRoomState.read.get( + getJoinRoomStateId(roomId, StateEventType.RoomServerAcl, '') + ); + const serverAcl: RoomServerAclContent = serverAclObj?.event.content ?? {}; - const aclContent: RoomServerAclEventContent = { + const aclContent: RoomServerAclContent = { allow: serverAcl?.allow ? [...serverAcl.allow] : [], allow_ip_literals: serverAcl?.allow_ip_literals, deny: serverAcl?.deny ? [...serverAcl.deny] : [], @@ -528,11 +660,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { aclContent.allow?.sort(); aclContent.deny?.sort(); - await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent); + await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomServerAcl, stateKey: '' }, + body: aclContent, + }) + ); }, }, }), - [mx, room, navigateRoom] + [ct, membersAPI, roomId, navigateRoom] ); return commands; diff --git a/src/app/hooks/useDirectUsers.ts b/src/app/hooks/useDirectUsers.ts index 3aa18928..4691bac9 100644 --- a/src/app/hooks/useDirectUsers.ts +++ b/src/app/hooks/useDirectUsers.ts @@ -1,27 +1,18 @@ import { useMemo } from 'react'; -import { AccountDataEvent, MDirectContent } from '../../types/matrix/accountData'; -import { useAccountData } from './useAccountData'; -import { useAllJoinedRoomsSet, useGetRoom } from './useGetRoom'; +import { useClient } from './useClient'; +import { useObservableValue } from './useObservableValue'; export const useDirectUsers = (): string[] => { - const directEvent = useAccountData(AccountDataEvent.Direct); - const content = directEvent?.getContent(); - - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); + const ct = useClient(); + const roomToSummary = useObservableValue(ct.roomToSummary); const users = useMemo(() => { - if (typeof content !== 'object') return []; + const heroes = Array.from(roomToSummary.values()).flatMap( + (summary) => summary['m.heroes'] ?? [] + ); - const u = Object.keys(content).filter((userId) => { - const rooms = content[userId]; - if (!Array.isArray(rooms)) return false; - const hasDM = rooms.some((roomId) => typeof roomId === 'string' && !!getRoom(roomId)); - return hasDM; - }); - - return u; - }, [content, getRoom]); + return Array.from(new Set(heroes)); + }, [roomToSummary]); return users; }; diff --git a/src/app/hooks/useEmailNotifications.ts b/src/app/hooks/useEmailNotifications.ts index 58639394..566fb4e7 100644 --- a/src/app/hooks/useEmailNotifications.ts +++ b/src/app/hooks/useEmailNotifications.ts @@ -1,6 +1,9 @@ import { useCallback } from 'react'; +import { get3pid, Get3pidOK } from 'neurone/lib/api/matrix/client/v3/account'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { getPushers, GetPushersOK } from 'neurone/lib/api/matrix/client/v3/pushers'; import { AsyncStatus, useAsyncCallbackValue } from './useAsyncCallback'; -import { useMatrixClient } from './useMatrixClient'; +import { useClient } from './useClient'; type RefreshHandler = () => void; @@ -13,19 +16,27 @@ export const useEmailNotifications = (): [ EmailNotificationResult | undefined | null, RefreshHandler ] => { - const mx = useMatrixClient(); + const ct = useClient(); const [emailState, refresh] = useAsyncCallbackValue( useCallback(async () => { - const tpIDs = (await mx.getThreePids())?.threepids; - const emailAddresses = tpIDs.filter((id) => id.medium === 'email').map((id) => id.address); + const response = await ct.requestAuthJSON(get3pid({})); + const [err, result] = await parseMatrixResp(response); + if (err) throw new Error(printMatrixError(err)); + + const emailAddresses = result.threepids + .filter((id) => id.medium === 'email') + .map((id) => id.address); if (emailAddresses.length === 0) return { enabled: false, }; - const pushers = (await mx.getPushers())?.pushers; - const emailPusher = pushers.find( + const resp = await ct.requestAuthJSON(getPushers({})); + const [e, r] = await parseMatrixResp(resp); + if (e) throw new Error(printMatrixError(e)); + + const emailPusher = r.pushers.find( (pusher) => pusher.app_id === 'm.email' && emailAddresses.includes(pusher.pushkey) ); @@ -40,7 +51,7 @@ export const useEmailNotifications = (): [ enabled: false, email: emailAddresses[0], }; - }, [mx]) + }, [ct]) ); if (emailState.status === AsyncStatus.Success) { diff --git a/src/app/hooks/useIgnoredUsers.ts b/src/app/hooks/useIgnoredUsers.ts index baf2327a..0ac634cb 100644 --- a/src/app/hooks/useIgnoredUsers.ts +++ b/src/app/hooks/useIgnoredUsers.ts @@ -1,18 +1,39 @@ import { useMemo } from 'react'; -import { useAccountData } from './useAccountData'; -import { AccountDataEvent } from '../../types/matrix/accountData'; +import { useAccountData } from './storage/useAccountData'; +import { AccountDataType } from '../../types/matrix/accountData'; export type IgnoredUserListContent = { ignored_users?: Record; }; +export const getIgnoredUsersContent = (userList: string[]): IgnoredUserListContent => { + const users: Record = {}; + userList.forEach((userId) => { + users[userId] = {}; + }); + + return { + ignored_users: users, + }; +}; + +export const parseIgnoreUsersContent = (content?: object): string[] => { + if ( + content && + 'ignored_users' in content && + typeof content.ignored_users === 'object' && + content.ignored_users + ) { + return Object.keys(content.ignored_users); + } + return []; +}; + export const useIgnoredUsers = (): string[] => { - const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList); - const ignoredUsers = useMemo(() => { - const ignoredUsersRecord = - ignoredUserListEvt?.getContent().ignored_users ?? {}; - return Object.keys(ignoredUsersRecord); - }, [ignoredUserListEvt]); + const ignoreEventObjResult = useAccountData(AccountDataType.IgnoredUserList); + const content = ignoreEventObjResult.value?.content; + + const ignoredUsers = useMemo(() => parseIgnoreUsersContent(content), [content]); return ignoredUsers; }; diff --git a/src/app/hooks/useImagePackRooms.ts b/src/app/hooks/useImagePackRooms.ts index f04d2f22..25636589 100644 --- a/src/app/hooks/useImagePackRooms.ts +++ b/src/app/hooks/useImagePackRooms.ts @@ -1,22 +1,20 @@ -import { Room } from 'matrix-js-sdk'; +import { useAtomValue } from 'jotai'; import { useMemo } from 'react'; import { getAllParents } from '../utils/room'; -import { useMatrixClient } from './useMatrixClient'; +import { useAllJoinedRoomsSet } from './useGetRoom'; +import { roomToParentsAtom } from '../state/room/roomToParents'; -export const useImagePackRooms = ( - roomId: string, - roomToParents: Map> -): Room[] => { - const mx = useMatrixClient(); +export const useImagePackRooms = (roomId: string): string[] => { + const roomToParents = useAtomValue(roomToParentsAtom); + const allJoinedRooms = useAllJoinedRoomsSet(); - const imagePackRooms: Room[] = useMemo(() => { + const imagePackRooms: string[] = useMemo(() => { const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId))); - return allParentSpaces.reduce((list, rId) => { - const r = mx.getRoom(rId); - if (r) list.push(r); + return allParentSpaces.reduce((list, rId) => { + if (allJoinedRooms.has(rId)) list.push(rId); return list; }, []); - }, [mx, roomId, roomToParents]); + }, [roomId, roomToParents, allJoinedRooms]); return imagePackRooms; }; diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 318d6cba..6f3d52fc 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -1,161 +1,188 @@ -import { Room } from 'matrix-js-sdk'; -import { useCallback, useMemo, useState } from 'react'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { StateEvent } from '../../types/matrix/room'; +import { useCallback, useMemo } from 'react'; +import { ObservableValue, PendingObservableResult } from 'neurone/lib/utils/observable'; +import { AccountDataType } from '../../types/matrix/accountData'; +import { StateEventType } from '../../types/matrix/room'; +import { getGlobalPackRoomToStateKey, ImagePack, ImageUsage } from '../plugins/custom-emoji'; +import { useClient } from './useClient'; import { - getGlobalImagePacks, - getRoomImagePack, - getRoomImagePacks, - getUserImagePack, - ImagePack, - ImageUsage, -} from '../plugins/custom-emoji'; -import { useMatrixClient } from './useMatrixClient'; -import { useAccountDataCallback } from './useAccountDataCallback'; -import { useStateEventCallback } from './useStateEventCallback'; + useComputedPendingObservableResult, + useObservableValuePromise, +} from './useObservableValue'; +import { getJoinRoomStateId, JoinRoomStateObject } from '../../client/storage/collections'; +import { useRoomStateEvent, useRoomStateEvents } from './storage/useRoomStateEvent'; -export const useUserImagePack = (): ImagePack | undefined => { - const mx = useMatrixClient(); - const [userPack, setUserPack] = useState(() => getUserImagePack(mx)); - - useAccountDataCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.PoniesUserEmotes) { - setUserPack(getUserImagePack(mx)); - } - }, - [mx] - ) +export const useUserImagePack = (): PendingObservableResult => { + const ct = useClient(); + const userPackObjPromise = useMemo( + () => ct.storage.accountData.observableRead.get(AccountDataType.PoniesUserEmotes), + [ct] ); - return userPack; + const userPackObjResult = useObservableValuePromise(userPackObjPromise); + + return useComputedPendingObservableResult( + userPackObjResult, + useCallback( + (userPackObj) => { + if (!userPackObj) return undefined; + return ImagePack.fromAccountData(ct.session.userId, userPackObj.event); + }, + [ct] + ) + ); }; -export const useGlobalImagePacks = (): ImagePack[] => { - const mx = useMatrixClient(); - const [globalPacks, setGlobalPacks] = useState(() => getGlobalImagePacks(mx)); +export const useGlobalImagePacks = (): PendingObservableResult => { + const ct = useClient(); + const emoteRoomsObjPromise = useMemo( + () => ct.storage.accountData.observableRead.get(AccountDataType.PoniesEmoteRooms), + [ct] + ); + const emoteRoomsObjResult = useObservableValuePromise(emoteRoomsObjPromise); - useAccountDataCallback( - mx, + const roomToStateKeysResult = useComputedPendingObservableResult( + emoteRoomsObjResult, + useCallback((emoteRoomsObj) => getGlobalPackRoomToStateKey(emoteRoomsObj?.event.content), []) + ); + const roomToStateKeys = roomToStateKeysResult.value; + + const roomPackEventsPromise = useMemo(() => { + const keys = roomToStateKeys + ? Object.keys(roomToStateKeys).flatMap((roomId) => + roomToStateKeys[roomId].map((stateKey) => + getJoinRoomStateId(roomId, StateEventType.PoniesRoomEmotes, stateKey) + ) + ) + : []; + + return ct.storage.joinRoomState.observableRead.getMultiple(keys); + }, [ct, roomToStateKeys]); + + const roomPackEventsResult = useObservableValuePromise(roomPackEventsPromise); + + return useComputedPendingObservableResult( + roomPackEventsResult, useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.PoniesEmoteRooms) { - setGlobalPacks(getGlobalImagePacks(mx)); - } - }, - [mx] + (roomPackEvents) => + roomPackEvents + .filter((p) => !!p) + .map((packEvent) => ImagePack.fromStateEvent(packEvent.roomId, packEvent.event)), + [] ) ); - - useStateEventCallback( - mx, - useCallback( - (mEvent) => { - const eventType = mEvent.getType(); - const roomId = mEvent.getRoomId(); - const stateKey = mEvent.getStateKey(); - if (eventType === StateEvent.PoniesRoomEmotes && roomId && typeof stateKey === 'string') { - const global = !!globalPacks.find( - (pack) => - pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey - ); - if (global) { - setGlobalPacks(getGlobalImagePacks(mx)); - } - } - }, - [mx, globalPacks] - ) - ); - - return globalPacks; }; -export const useRoomImagePack = (room: Room, stateKey: string): ImagePack | undefined => { - const mx = useMatrixClient(); - const [roomPack, setRoomPack] = useState(() => getRoomImagePack(room, stateKey)); +export const useRoomImagePack = ( + roomId: string, + stateKey: string +): PendingObservableResult => { + const roomPackEventResult = useRoomStateEvent(roomId, StateEventType.PoniesRoomEmotes, stateKey); - useStateEventCallback( - mx, + return useComputedPendingObservableResult( + roomPackEventResult, useCallback( - (mEvent) => { - if ( - mEvent.getRoomId() === room.roomId && - mEvent.getType() === StateEvent.PoniesRoomEmotes && - mEvent.getStateKey() === stateKey - ) { - setRoomPack(getRoomImagePack(room, stateKey)); - } - }, - [room, stateKey] + (roomPackEvent) => + roomPackEvent + ? ImagePack.fromStateEvent(roomPackEvent.roomId, roomPackEvent.event) + : undefined, + [] ) ); - - return roomPack; }; -export const useRoomImagePacks = (room: Room): ImagePack[] => { - const mx = useMatrixClient(); - const [roomPacks, setRoomPacks] = useState(() => getRoomImagePacks(room)); +export const useRoomImagePacks = (roomId: string): PendingObservableResult => { + const roomPackEventsResult = useRoomStateEvents(roomId, StateEventType.PoniesRoomEmotes); - useStateEventCallback( - mx, + return useComputedPendingObservableResult( + roomPackEventsResult, useCallback( - (mEvent) => { - if ( - mEvent.getRoomId() === room.roomId && - mEvent.getType() === StateEvent.PoniesRoomEmotes - ) { - setRoomPacks(getRoomImagePacks(room)); - } - }, - [room] + (roomPackEvents) => + roomPackEvents.map((roomPackEvent) => + ImagePack.fromStateEvent(roomPackEvent.roomId, roomPackEvent.event) + ), + [] ) ); - - return roomPacks; }; -export const useRoomsImagePacks = (rooms: Room[]) => { - const mx = useMatrixClient(); - const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomImagePacks)); +export const useRoomsImagePacks = (rooms: string[]): PendingObservableResult => { + const ct = useClient(); + const idsPromise = useMemo( + () => + ct.storage.joinRoomState.observableRead.queryIndexKeys( + 'type', + StateEventType.PoniesRoomEmotes + ), + [ct] + ); + const idsResult = useObservableValuePromise(idsPromise); - useStateEventCallback( - mx, + const packKeysResult = useComputedPendingObservableResult( + idsResult, useCallback( - (mEvent) => { - if ( - rooms.find((room) => room.roomId === mEvent.getRoomId()) && - mEvent.getType() === StateEvent.PoniesRoomEmotes - ) { - setRoomPacks(rooms.flatMap(getRoomImagePacks)); - } - }, + (ids) => + ids.filter((id) => + rooms.find((roomId) => + id.startsWith(getJoinRoomStateId(roomId, StateEventType.PoniesRoomEmotes, '')) + ) + ), [rooms] ) ); - return roomPacks; + const roomPackEventsResult = useObservableValuePromise( + useMemo(() => { + if (!packKeysResult.value) { + return new Promise>( + (resolve, reject) => { + if (packKeysResult.error) { + reject(packKeysResult.error); + } + } + ); + } + return ct.storage.joinRoomState.observableRead.getMultiple(packKeysResult.value); + }, [ct, packKeysResult]) + ); + + return useComputedPendingObservableResult( + roomPackEventsResult, + useCallback( + (roomPackEvents) => + roomPackEvents + .filter((p) => !!p) + .map((roomPackEvent) => + ImagePack.fromStateEvent(roomPackEvent.roomId, roomPackEvent.event) + ), + [] + ) + ); }; -export const useRelevantImagePacks = (usage: ImageUsage, rooms: Room[]): ImagePack[] => { - const userPack = useUserImagePack(); - const globalPacks = useGlobalImagePacks(); - const roomsPacks = useRoomsImagePacks(rooms); +export const useRelevantImagePacks = ( + usage: ImageUsage, + rooms: string[] +): [ImagePack[], boolean | undefined, Error | undefined] => { + const userPackResult = useUserImagePack(); + const globalPacksResult = useGlobalImagePacks(); + + const roomsPacksResult = useRoomsImagePacks(rooms); + const loading: boolean | undefined = + userPackResult.pending || globalPacksResult.pending || roomsPacksResult.pending; + const error: Error | undefined = + userPackResult.error || globalPacksResult.error || roomsPacksResult.error; const relevantPacks = useMemo(() => { - const packs = userPack ? [userPack] : []; - const globalPackIds = new Set(globalPacks.map((pack) => pack.id)); + const packs = userPackResult.value ? [userPackResult.value] : []; + const globalPackIds = new Set(globalPacksResult.value?.map((pack) => pack.id)); const relPacks = packs.concat( - globalPacks, - roomsPacks.filter((pack) => !globalPackIds.has(pack.id)) + globalPacksResult.value ?? [], + roomsPacksResult.value?.filter((pack) => !globalPackIds.has(pack.id)) ?? [] ); return relPacks.filter((pack) => pack.getImages(usage).length > 0); - }, [userPack, globalPacks, roomsPacks, usage]); + }, [userPackResult, globalPacksResult, roomsPacksResult, usage]); - return relevantPacks; + return [relevantPacks, loading, error]; }; diff --git a/src/app/hooks/useJoinedRoomId.ts b/src/app/hooks/useJoinedRoomId.ts index 857defa8..ada42fea 100644 --- a/src/app/hooks/useJoinedRoomId.ts +++ b/src/app/hooks/useJoinedRoomId.ts @@ -1,19 +1,14 @@ -import { useMemo } from 'react'; -import { useMatrixClient } from './useMatrixClient'; - -import { getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix'; +import { isRoomAlias } from '../utils/matrix'; +import { useClient } from './useClient'; export const useJoinedRoomId = (allRooms: string[], roomIdOrAlias: string): string | undefined => { - const mx = useMatrixClient(); + const ct = useClient(); - const joinedRoomId = useMemo(() => { - const roomId = isRoomAlias(roomIdOrAlias) - ? getCanonicalAliasRoomId(mx, roomIdOrAlias) - : roomIdOrAlias; + const roomId = isRoomAlias(roomIdOrAlias) + ? ct.rooms.join.read.find((rId) => ct.roomToAlias.read.get(rId) === roomIdOrAlias) + : roomIdOrAlias; - if (roomId && allRooms.includes(roomId)) return roomId; - return undefined; - }, [mx, allRooms, roomIdOrAlias]); + if (roomId && allRooms.includes(roomId)) return roomId; - return joinedRoomId; + return undefined; }; diff --git a/src/app/hooks/useLocalRoomSummary.ts b/src/app/hooks/useLocalRoomSummary.ts index 46f81569..4802a3cf 100644 --- a/src/app/hooks/useLocalRoomSummary.ts +++ b/src/app/hooks/useLocalRoomSummary.ts @@ -1,44 +1,40 @@ -import { GuestAccess, HistoryVisibility, JoinRule, Room } from 'matrix-js-sdk'; -import { getStateEvent } from '../utils/room'; -import { StateEvent } from '../../types/matrix/room'; +import { JoinRule } from '../../types/matrix/room'; +import { useRoomHeroes } from './storage/useRoomHeros'; +import { useGetRoomName } from './storage/useGetRoomName'; +import { useGetRoomAvatar } from './storage/useGetRoomAvatar'; +import { useRoomTopic } from './storage/useRoomTopic'; +import { useObservableValue } from './useObservableValue'; +import { useClient } from './useClient'; export type LocalRoomSummary = { roomId: string; - name: string; + name?: string; topic?: string; avatarUrl?: string; canonicalAlias?: string; - worldReadable?: boolean; - guestCanJoin?: boolean; memberCount?: number; - roomType?: string; + space?: boolean; 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; +export const useLocalRoomSummary = (roomId: string): LocalRoomSummary => { + const ct = useClient(); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(roomId); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + const roomTopic = useRoomTopic(roomId); + const roomToAlias = useObservableValue(ct.roomToAlias); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const roomToSummary = useObservableValue(ct.roomToSummary); + const spaceRooms = useObservableValue(ct.spaceRooms); 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(), + roomId, + name: getRoomName(roomId), + topic: roomTopic, + avatarUrl: getRoomAvatar(roomId), + canonicalAlias: roomToAlias.get(roomId), + memberCount: roomToSummary.get(roomId)?.['m.joined_member_count'], + space: spaceRooms.has(roomId), + joinRule: roomToJoinRule.get(roomId), }; }; diff --git a/src/app/hooks/useMediaAuthentication.ts b/src/app/hooks/useMediaAuthentication.ts index 6e4d06aa..7967cdce 100644 --- a/src/app/hooks/useMediaAuthentication.ts +++ b/src/app/hooks/useMediaAuthentication.ts @@ -1,11 +1,11 @@ -import { useSpecVersions } from './useSpecVersions'; +import { useSpecVersionSupport, useUnstableFeatureSupport } from './useSpecVersions'; export const useMediaAuthentication = (): boolean => { - const { versions, unstable_features: unstableFeatures } = useSpecVersions(); + const versionSupport = useSpecVersionSupport('v1.11'); + const unstableSupport = useUnstableFeatureSupport()('org.matrix.msc3916.stable'); // Media authentication is introduced in spec version 1.11 - const authenticatedMedia = - unstableFeatures?.['org.matrix.msc3916.stable'] || versions.includes('v1.11'); + const authenticatedMedia = unstableSupport || versionSupport; return authenticatedMedia; }; diff --git a/src/app/hooks/useMemberFilter.ts b/src/app/hooks/useMemberFilter.ts index 2c33c7c8..8d7a9b7e 100644 --- a/src/app/hooks/useMemberFilter.ts +++ b/src/app/hooks/useMemberFilter.ts @@ -1,52 +1,55 @@ import { useMemo } from 'react'; -import { RoomMember } from 'matrix-js-sdk'; -import { Membership } from '../../types/matrix/room'; +import { GetRoomMemberMembershipCallback } from './useRoomMembers'; +import { JoinRoomMemberObject } from '../../client/storage/collections'; -export const MembershipFilter = { - 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 const getMembershipFilter = (getMembership: GetRoomMemberMembershipCallback) => ({ + filterJoined: (m: JoinRoomMemberObject) => getMembership(m.userId) === 'join', + filterInvited: (m: JoinRoomMemberObject) => getMembership(m.userId) === 'invite', + filterLeaved: (m: JoinRoomMemberObject) => + getMembership(m.userId) === 'leave' && m.event.state_key === m.event.sender, + filterKicked: (m: JoinRoomMemberObject) => + getMembership(m.userId) === 'leave' && m.event.state_key !== m.event.sender, + filterBanned: (m: JoinRoomMemberObject) => getMembership(m.userId) === 'ban', +}); -export type MembershipFilterFn = (m: RoomMember) => boolean; +export type MembershipFilterFn = (m: JoinRoomMemberObject) => boolean; export type MembershipFilterItem = { name: string; filterFn: MembershipFilterFn; }; -export const useMembershipFilterMenu = (): MembershipFilterItem[] => - useMemo( +export const useMembershipFilterMenu = ( + getMembership: GetRoomMemberMembershipCallback +): MembershipFilterItem[] => { + const filterFn = useMemo(() => getMembershipFilter(getMembership), [getMembership]); + + return useMemo( () => [ { name: 'Joined', - filterFn: MembershipFilter.filterJoined, + filterFn: filterFn.filterJoined, }, { name: 'Invited', - filterFn: MembershipFilter.filterInvited, + filterFn: filterFn.filterInvited, }, { name: 'Left', - filterFn: MembershipFilter.filterLeaved, + filterFn: filterFn.filterLeaved, }, { name: 'Kicked', - filterFn: MembershipFilter.filterKicked, + filterFn: filterFn.filterKicked, }, { name: 'Banned', - filterFn: MembershipFilter.filterBanned, + filterFn: filterFn.filterBanned, }, ], - [] + [filterFn] ); +}; export const useMembershipFilter = ( index: number, diff --git a/src/app/hooks/useMemberPowerTag.ts b/src/app/hooks/useMemberPowerTag.ts index 31e52aa0..2ccc4f20 100644 --- a/src/app/hooks/useMemberPowerTag.ts +++ b/src/app/hooks/useMemberPowerTag.ts @@ -1,25 +1,26 @@ import { useCallback, useMemo } from 'react'; -import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; -import { getPowerLevelTag, PowerLevelTags, usePowerLevelTags } from './usePowerLevelTags'; +import { getPowerLevelTag, PowerLevelTags, usePowerLevelTagsContext } from './usePowerLevelTags'; import { IPowerLevels, readPowerLevel } from './usePowerLevels'; import { MemberPowerTag, MemberPowerTagIcon } from '../../types/matrix/room'; import { useRoomCreatorsTag } from './useRoomCreatorsTag'; import { ThemeKind } from './useTheme'; import { accessibleColor } from '../plugins/color'; +import { ClientContext } from '../../client/context'; +import { mxcToHttp } from '../../client/mxcToHttp'; +import { JoinRoomMemberObject } from '../../client/storage/collections'; export type GetMemberPowerTag = (userId: string) => MemberPowerTag; export const useGetMemberPowerTag = ( - room: Room, - creators: Set, + creators: Set | undefined, powerLevels: IPowerLevels ) => { const creatorsTag = useRoomCreatorsTag(); - const powerLevelTags = usePowerLevelTags(room, powerLevels); + const powerLevelTags = usePowerLevelTagsContext(); const getMemberPowerTag: GetMemberPowerTag = useCallback( (userId) => { - if (creators.has(userId)) { + if (creators?.has(userId)) { return creatorsTag; } @@ -33,12 +34,16 @@ export const useGetMemberPowerTag = ( }; export const getPowerTagIconSrc = ( - mx: MatrixClient, + ct: ClientContext, useAuthentication: boolean, icon: MemberPowerTagIcon ): string | undefined => icon?.key?.startsWith('mxc://') - ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻' + ? mxcToHttp(ct.session.baseUrl, icon.key, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'scale', + }) ?? '🌻' : icon?.key; export const useAccessiblePowerTagColors = ( @@ -66,12 +71,12 @@ export const useAccessiblePowerTagColors = ( }; export const useFlattenPowerTagMembers = ( - members: RoomMember[], + members: JoinRoomMemberObject[], getTag: GetMemberPowerTag -): Array => { +): Array => { const PLTagOrRoomMember = useMemo(() => { let prevTag: MemberPowerTag | undefined; - const tagOrMember: Array = []; + const tagOrMember: Array = []; members.forEach((member) => { const tag = getTag(member.userId); if (tag !== prevTag) { diff --git a/src/app/hooks/useMemberSort.ts b/src/app/hooks/useMemberSort.ts index 48d2f605..a711f42c 100644 --- a/src/app/hooks/useMemberSort.ts +++ b/src/app/hooks/useMemberSort.ts @@ -1,46 +1,50 @@ -import { RoomMember } from 'matrix-js-sdk'; import { useCallback, useMemo } from 'react'; +import { JoinRoomMemberObject } from '../../client/storage/collections'; +import { GetRoomMemberNameCallback } from './useRoomMembers'; -export const MemberSort = { - Ascending: (a: RoomMember, b: RoomMember) => - a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1, - Descending: (a: RoomMember, b: RoomMember) => - a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1, - NewestFirst: (a: RoomMember, b: RoomMember) => - (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0), - Oldest: (a: RoomMember, b: RoomMember) => - (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0), -}; +export const getMemberSort = (getName: GetRoomMemberNameCallback) => ({ + Ascending: (a: JoinRoomMemberObject, b: JoinRoomMemberObject) => + getName(a.userId).toLowerCase() < getName(b.userId).toLowerCase() ? -1 : 1, + Descending: (a: JoinRoomMemberObject, b: JoinRoomMemberObject) => + getName(a.userId).toLowerCase() > getName(b.userId).toLowerCase() ? -1 : 1, + NewestFirst: (a: JoinRoomMemberObject, b: JoinRoomMemberObject) => + b.event.origin_server_ts - a.event.origin_server_ts, + Oldest: (a: JoinRoomMemberObject, b: JoinRoomMemberObject) => + a.event.origin_server_ts - b.event.origin_server_ts, +}); -export type MemberSortFn = (a: RoomMember, b: RoomMember) => number; +export type MemberSortFn = (a: JoinRoomMemberObject, b: JoinRoomMemberObject) => number; export type MemberSortItem = { name: string; sortFn: MemberSortFn; }; -export const useMemberSortMenu = (): MemberSortItem[] => - useMemo( +export const useMemberSortMenu = (getName: GetRoomMemberNameCallback): MemberSortItem[] => { + const sortFun = useMemo(() => getMemberSort(getName), [getName]); + + return useMemo( () => [ { name: 'A to Z', - sortFn: MemberSort.Ascending, + sortFn: sortFun.Ascending, }, { name: 'Z to A', - sortFn: MemberSort.Descending, + sortFn: sortFun.Descending, }, { name: 'Newest', - sortFn: MemberSort.NewestFirst, + sortFn: sortFun.NewestFirst, }, { name: 'Oldest', - sortFn: MemberSort.Oldest, + sortFn: sortFun.Oldest, }, ], - [] + [sortFun] ); +}; export const useMemberSort = (index: number, memberSort: MemberSortItem[]): MemberSortItem => { const item = memberSort[index] ?? memberSort[0]; @@ -48,20 +52,20 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb }; export const useMemberPowerSort = ( - creators: Set, + creators: Set | undefined, getPowerLevel: (userId: string) => number ): MemberSortFn => { const sort: MemberSortFn = useCallback( (a, b) => { - if (creators.has(a.userId) && creators.has(b.userId)) { + if (creators?.has(a.userId) && creators?.has(b.userId)) { return 0; } - if (creators.has(a.userId)) return -1; - if (creators.has(b.userId)) return 1; + if (creators?.has(a.userId)) return -1; + if (creators?.has(b.userId)) return 1; return getPowerLevel(b.userId) - getPowerLevel(a.userId); }, - [creators] + [creators, getPowerLevel] ); return sort; diff --git a/src/app/hooks/useMentionClickHandler.ts b/src/app/hooks/useMentionClickHandler.ts index 9dc81d4e..d86c1b3d 100644 --- a/src/app/hooks/useMentionClickHandler.ts +++ b/src/app/hooks/useMentionClickHandler.ts @@ -1,19 +1,19 @@ import { ReactEventHandler, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRoomNavigate } from './useRoomNavigate'; -import { useMatrixClient } from './useMatrixClient'; import { isRoomId, isUserId } from '../utils/matrix'; import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils'; import { _RoomSearchParams } from '../pages/paths'; import { useOpenUserRoomProfile } from '../state/hooks/userRoomProfile'; import { useSpaceOptionally } from './useSpace'; +import { useClient } from './useClient'; export const useMentionClickHandler = (roomId: string): ReactEventHandler => { - const mx = useMatrixClient(); + const ct = useClient(); const { navigateRoom, navigateSpace } = useRoomNavigate(); const navigate = useNavigate(); const openProfile = useOpenUserRoomProfile(); - const space = useSpaceOptionally(); + const spaceId = useSpaceOptionally(); const handleClick: ReactEventHandler = useCallback( (evt) => { @@ -24,13 +24,13 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler(path, { viaServers }) : path); }, - [mx, navigate, navigateRoom, navigateSpace, roomId, space, openProfile] + [ct, navigate, navigateRoom, navigateSpace, roomId, spaceId, openProfile] ); return handleClick; diff --git a/src/app/hooks/useMutualRooms.ts b/src/app/hooks/useMutualRooms.ts index a7b38938..34606b4e 100644 --- a/src/app/hooks/useMutualRooms.ts +++ b/src/app/hooks/useMutualRooms.ts @@ -1,15 +1,15 @@ import { useCallback } from 'react'; import { useMatrixClient } from './useMatrixClient'; import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback'; -import { useSpecVersions } from './useSpecVersions'; +import { useUnstableFeatureSupport } from './useSpecVersions'; export const useMutualRoomsSupport = (): boolean => { - const { unstable_features: unstableFeatures } = useSpecVersions(); + const checkSupport = useUnstableFeatureSupport(); const supported = - unstableFeatures?.['uk.half-shot.msc2666'] || - unstableFeatures?.['uk.half-shot.msc2666.mutual_rooms'] || - unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms']; + checkSupport('uk.half-shot.msc2666') || + checkSupport('uk.half-shot.msc2666.mutual_rooms') || + checkSupport('uk.half-shot.msc2666.query_mutual_rooms'); return !!supported; }; diff --git a/src/app/hooks/useNetwork.ts b/src/app/hooks/useNetwork.ts new file mode 100644 index 00000000..bf3e6b15 --- /dev/null +++ b/src/app/hooks/useNetwork.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +export const useNetworkOnlineCallback = (callback: () => void) => { + useEffect(() => { + const handleNetwork = () => callback(); + + window.addEventListener('online', handleNetwork); + return () => { + window.removeEventListener('online', handleNetwork); + }; + }, [callback]); +}; + +export const useNetworkOfflineCallback = (callback: () => void) => { + useEffect(() => { + const handleNetwork = () => callback(); + + window.addEventListener('offline', handleNetwork); + return () => { + window.removeEventListener('offline', handleNetwork); + }; + }, [callback]); +}; diff --git a/src/app/hooks/useNotificationMode.ts b/src/app/hooks/useNotificationMode.ts index df90f647..484bf1cf 100644 --- a/src/app/hooks/useNotificationMode.ts +++ b/src/app/hooks/useNotificationMode.ts @@ -1,4 +1,4 @@ -import { PushRuleAction, PushRuleActionName, TweakName } from 'matrix-js-sdk'; +import { PushRuleAction } from 'neurone/lib/api/types'; import { useCallback, useMemo } from 'react'; export enum NotificationMode { @@ -7,9 +7,20 @@ export enum NotificationMode { NotifyLoud = 'NotifyLoud', } +export enum PushRuleActionName { + DontNotify = 'dont_notify', + Notify = 'notify', +} + +export enum TweakName { + Highlight = 'highlight', + Sound = 'sound', +} + export const getNotificationMode = (actions: PushRuleAction[]): NotificationMode => { const soundTweak = actions.find( - (action) => typeof action === 'object' && action.set_tweak === TweakName.Sound + (action) => + typeof action === 'object' && 'set_tweak' in action && action.set_tweak === TweakName.Sound ); const notify = actions.find( (action) => typeof action === 'string' && action === PushRuleActionName.Notify diff --git a/src/app/hooks/useObservableValue.ts b/src/app/hooks/useObservableValue.ts new file mode 100644 index 00000000..79f80cea --- /dev/null +++ b/src/app/hooks/useObservableValue.ts @@ -0,0 +1,85 @@ +import { + ObservableValue, + PendingObservableValue, + PendingObservableResult, + ComputedObservableValue, +} from 'neurone/lib/utils/observable'; +import { useMemo, useSyncExternalStore, useCallback, useEffect } from 'react'; + +export function useObservableValue(observableValue: ObservableValue): T { + const value = useSyncExternalStore( + useCallback((c) => observableValue.subscribe(c), [observableValue]), + useCallback(() => observableValue.read, [observableValue]) + ); + + return value; +} + +export function useObservableValueOptionally( + observableValue?: ObservableValue +): T | undefined { + const value = useSyncExternalStore( + useCallback( + (c) => { + const unsubscribe = observableValue?.subscribe(c); + return () => { + unsubscribe?.(); + }; + }, + [observableValue] + ), + useCallback(() => observableValue?.read, [observableValue]) + ); + + return value; +} + +export function useObservableValueListener( + observableValue: ObservableValue, + listener: (value: T) => void +): void { + useEffect(() => { + const unsub = observableValue.subscribe(listener); + return unsub; + }, [observableValue, listener]); +} + +export function useObservableDispose(observableValue: ObservableValue) { + useEffect(() => () => observableValue.dispose(), [observableValue]); +} + +export function useComputedObservable(observable: ComputedObservableValue): T { + const value = useObservableValue(observable); + useObservableDispose(observable); + + return value; +} + +export const useObservableValuePromise = ( + observableValuePromise: Promise> +): PendingObservableResult => { + const observable: ObservableValue> = useMemo( + () => new PendingObservableValue(observableValuePromise), + [observableValuePromise] + ); + + const value = useObservableValue(observable); + + useObservableDispose(observable); + + return value; +}; + +export const useComputedPendingObservableResult = ( + result: PendingObservableResult, + compute: (value: T) => U +): PendingObservableResult => { + const computedResult: PendingObservableResult = useMemo(() => { + const { value, error } = result; + if (value) return { value: compute(value) }; + if (error) return { error }; + return { pending: true }; + }, [result, compute]); + + return computedResult; +}; diff --git a/src/app/hooks/useParsedLoginFlows.ts b/src/app/hooks/useParsedLoginFlows.ts index 088a514e..17276574 100644 --- a/src/app/hooks/useParsedLoginFlows.ts +++ b/src/app/hooks/useParsedLoginFlows.ts @@ -1,22 +1,19 @@ import { useMemo } from 'react'; -import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth'; +import { LoginFlow } from 'neurone/lib/api/types'; -export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined => - loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as - | ISSOFlow - | undefined; +const getSSOFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined => + loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas'); -export const getPasswordFlow = (loginFlows: LoginFlow[]): IPasswordFlow | undefined => - loginFlows.find((flow) => flow.type === 'm.login.password') as IPasswordFlow; -export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined => - loginFlows.find((flow) => flow.type === 'm.login.token') as ILoginFlow & { - type: 'm.login.token'; - }; +const getPasswordFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined => + loginFlows.find((flow) => flow.type === 'm.login.password'); + +const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined => + loginFlows.find((flow) => flow.type === 'm.login.token'); export type ParsedLoginFlows = { password?: LoginFlow; token?: LoginFlow; - sso?: ISSOFlow; + sso?: LoginFlow; }; export const useParsedLoginFlows = (loginFlows: LoginFlow[]) => { const parsedFlow: ParsedLoginFlows = useMemo( diff --git a/src/app/hooks/usePasswordEmail.ts b/src/app/hooks/usePasswordEmail.ts index 37e96433..cf79786e 100644 --- a/src/app/hooks/usePasswordEmail.ts +++ b/src/app/hooks/usePasswordEmail.ts @@ -1,30 +1,54 @@ -import { MatrixClient, MatrixError } from 'matrix-js-sdk'; -import { useCallback, useRef } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; +import { + postPasswordEmailRequestToken, + PostPasswordEmailRequestTokenOK, +} from 'neurone/lib/api/matrix/client/v3/account'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncState, useAsyncCallback } from './useAsyncCallback'; -import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types'; +import { RequestEmailTokenCallback, RequestEmailTokenResult } from './types'; +import { getJSONRequest } from '../../client/request'; export const usePasswordEmail = ( - mx: MatrixClient -): [AsyncState, RequestEmailTokenCallback] => { + baseUrl: string +): [AsyncState, RequestEmailTokenCallback] => { const sendAttemptRef = useRef(1); + const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]); const passwordEmailCallback: RequestEmailTokenCallback = useCallback( async (email, clientSecret, nextLink) => { const sendAttempt = sendAttemptRef.current; sendAttemptRef.current += 1; - const result = await mx.requestPasswordEmailToken(email, clientSecret, sendAttempt, nextLink); - return { - email, - clientSecret, - result, - }; + + const response = await requestJSON( + postPasswordEmailRequestToken({ + body: { + email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + }) + ); + + const [error, result] = await parseMatrixResp(response); + + if (error) return [error, undefined]; + + return [ + undefined, + { + email, + clientSecret, + result, + }, + ]; }, - [mx] + [requestJSON] ); const [passwordEmailState, passwordEmail] = useAsyncCallback< - RequestEmailTokenResponse, - MatrixError, + RequestEmailTokenResult, + Error, Parameters >(passwordEmailCallback); diff --git a/src/app/hooks/usePendingMembers.ts b/src/app/hooks/usePendingMembers.ts new file mode 100644 index 00000000..d533c8c9 --- /dev/null +++ b/src/app/hooks/usePendingMembers.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { getMembers, GetMembersOK } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { StateEventWithoutRoomId } from 'neurone/lib/api/types'; +import { useClient } from './useClient'; +import { useObservableValue } from './useObservableValue'; +import { + getJoinRoomMemberId, + JoinRoomMemberObject, + sessionValue, +} from '../../client/storage/collections'; +import { useAlive } from './useAlive'; + +export const usePendingMembers = (roomId: string): boolean => { + const ct = useClient(); + const pendingMemberRooms = useObservableValue(ct.pendingMemberRooms); + const hasPendingMembers = useMemo( + () => pendingMemberRooms.includes(roomId), + [pendingMemberRooms, roomId] + ); + + return hasPendingMembers; +}; + +export const usePendingMemberLoader = (roomId: string, pending: boolean) => { + const ct = useClient(); + const alive = useAlive(); + + const loadMembers = useCallback( + async (signal?: AbortSignal) => { + const response = await ct.requestAuthJSON(getMembers({ pathParams: { roomId } }), signal); + const [err, result] = await parseMatrixResp(response); + if (err) { + throw new Error(printMatrixError(err)); + } + + const memberObjects: JoinRoomMemberObject[] = + result?.chunk.map((event) => { + const eventWithoutRoomId: StateEventWithoutRoomId = event; + if ('room_id' in eventWithoutRoomId) delete eventWithoutRoomId.room_id; + + const obj: JoinRoomMemberObject = { + id: getJoinRoomMemberId(roomId, eventWithoutRoomId.state_key), + roomId, + userId: eventWithoutRoomId.state_key, + event: eventWithoutRoomId, + }; + + return obj; + }) ?? []; + + if (alive()) { + await ct.storage.joinRoomMember.write.putMultiple(memberObjects); + await ct.storage.sessionValue.write.put({ + id: sessionValue.pendingMemberRooms.id, + value: ct.pendingMemberRooms.read.filter((rId) => rId !== roomId), + }); + } + }, + [ct, roomId, alive] + ); + + useEffect(() => { + const abortController = new AbortController(); + + if (pending) { + loadMembers(abortController.signal); + } + + return () => { + abortController.abort(); + }; + }, [pending, loadMembers]); +}; diff --git a/src/app/hooks/usePowerLevelTags.ts b/src/app/hooks/usePowerLevelTags.ts index 0a6cca50..c90ddaf4 100644 --- a/src/app/hooks/usePowerLevelTags.ts +++ b/src/app/hooks/usePowerLevelTags.ts @@ -1,8 +1,10 @@ -import { Room } from 'matrix-js-sdk'; -import { useMemo } from 'react'; +import { createContext, useContext, useMemo } from 'react'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; import { IPowerLevels } from './usePowerLevels'; -import { useStateEvent } from './useStateEvent'; -import { MemberPowerTag, StateEvent } from '../../types/matrix/room'; +import { MemberPowerTag, StateEventType } from '../../types/matrix/room'; +import { getJoinRoomStateId, JoinRoomStateObject } from '../../client/storage/collections'; +import { useClient } from './useClient'; +import { useObservableValuePromise } from './useObservableValue'; export type PowerLevelTags = Record; @@ -87,11 +89,38 @@ const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): Mem }; }; -export const usePowerLevelTags = (room: Room, powerLevels: IPowerLevels): PowerLevelTags => { - const tagsEvent = useStateEvent(room, StateEvent.PowerLevelTags); +export function usePowerLevelTagsState( + roomId: string +): PendingObservableResult { + const ct = useClient(); + const eventObservablePromise = useMemo( + () => + ct.storage.joinRoomState.observableRead.get( + getJoinRoomStateId(roomId, StateEventType.PowerLevelTags, '') + ), + [ct, roomId] + ); + + return useObservableValuePromise(eventObservablePromise); +} + +const PowerLevelTagsContext = createContext(null); + +export const PowerLevelTagsContextProvider = PowerLevelTagsContext.Provider; + +export const usePowerLevelTagsContext = (): PowerLevelTags => { + const tags = useContext(PowerLevelTagsContext); + if (!tags) throw new Error('PowerLevelTagsContext is not initialized!'); + return tags; +}; + +export const usePowerLevelTags = ( + powerLevels: IPowerLevels, + eventObj: JoinRoomStateObject | undefined +): PowerLevelTags => { const powerLevelTags: PowerLevelTags = useMemo(() => { - const content = tagsEvent?.getContent(); + const { content } = eventObj?.event ?? {}; const powerToTags: PowerLevelTags = { ...content }; const powers = getUsedPowers(powerLevels); @@ -102,7 +131,7 @@ export const usePowerLevelTags = (room: Room, powerLevels: IPowerLevels): PowerL }); return powerToTags; - }, [powerLevels, tagsEvent]); + }, [powerLevels, eventObj]); return powerLevelTags; }; diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index 0281b23c..80ef3fdb 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -1,11 +1,14 @@ import { MatrixEvent, Room } from 'matrix-js-sdk'; import { createContext, useCallback, useContext, useMemo, useState } from 'react'; import produce from 'immer'; -import { useStateEvent } from './useStateEvent'; -import { StateEvent } from '../../types/matrix/room'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; +import { StateEvent, StateEventType } from '../../types/matrix/room'; import { useStateEventCallback } from './useStateEventCallback'; import { useMatrixClient } from './useMatrixClient'; import { getStateEvent } from '../utils/room'; +import { getJoinRoomStateId, JoinRoomStateObject } from '../../client/storage/collections'; +import { useClient } from './useClient'; +import { useObservableValuePromise } from './useObservableValue'; export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical'; export type PowerLevelNotificationsAction = 'room'; @@ -65,17 +68,33 @@ const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => { return powerLevels; }; -export function usePowerLevels(room: Room): IPowerLevels { - const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels); - const powerLevels: IPowerLevels = useMemo( - () => getPowersLevelFromMatrixEvent(powerLevelsEvent), - [powerLevelsEvent] +export function usePowerLevelsState( + roomId: string +): PendingObservableResult { + const ct = useClient(); + + const eventObservablePromise = useMemo( + () => + ct.storage.joinRoomState.observableRead.get( + getJoinRoomStateId(roomId, StateEventType.RoomPowerLevels, '') + ), + [ct, roomId] ); + return useObservableValuePromise(eventObservablePromise); +} + +export function usePowerLevels(eventObj: JoinRoomStateObject | undefined): IPowerLevels { + const powerLevels: IPowerLevels = useMemo(() => { + const content: IPowerLevels | undefined = eventObj?.event.content; + + return !content ? DEFAULT_POWER_LEVELS : fillMissingPowers(content); + }, [eventObj]); + return powerLevels; } -export const PowerLevelsContext = createContext(null); +const PowerLevelsContext = createContext(null); export const PowerLevelsContextProvider = PowerLevelsContext.Provider; diff --git a/src/app/hooks/usePushRule.ts b/src/app/hooks/usePushRule.ts index ec87c217..48ee655d 100644 --- a/src/app/hooks/usePushRule.ts +++ b/src/app/hooks/usePushRule.ts @@ -1,21 +1,21 @@ import { - IPushRule, - IPushRules, + PushRule, PushRuleAction, PushRuleCondition, PushRuleKind, - RuleId, -} from 'matrix-js-sdk'; + PushRules, +} from 'neurone/lib/api/types'; import { useMemo } from 'react'; +import { PushRuleId } from '../../types/matrix/accountData'; export type PushRuleData = { kind: PushRuleKind; - pushRule: IPushRule; + pushRule: PushRule; }; export const makePushRuleData = ( kind: PushRuleKind, - ruleId: RuleId, + ruleId: PushRuleId, actions: PushRuleAction[], conditions?: PushRuleCondition[], pattern?: string, @@ -34,16 +34,16 @@ export const makePushRuleData = ( }); export const orderedPushRuleKinds: PushRuleKind[] = [ - PushRuleKind.Override, - PushRuleKind.ContentSpecific, - PushRuleKind.RoomSpecific, - PushRuleKind.SenderSpecific, - PushRuleKind.Underride, + 'override', + 'content', + 'room', + 'sender', + 'underride', ]; export const getPushRule = ( - pushRules: IPushRules, - ruleId: RuleId | string + pushRules: PushRules, + ruleId: PushRuleId | string ): PushRuleData | undefined => { const { global } = pushRules; @@ -66,6 +66,6 @@ export const getPushRule = ( }; export const usePushRule = ( - pushRules: IPushRules, - ruleId: RuleId | string + pushRules: PushRules, + ruleId: PushRuleId | string ): PushRuleData | undefined => useMemo(() => getPushRule(pushRules, ruleId), [pushRules, ruleId]); diff --git a/src/app/hooks/useRecentEmoji.ts b/src/app/hooks/useRecentEmoji.ts index 926075f5..abc40a6f 100644 --- a/src/app/hooks/useRecentEmoji.ts +++ b/src/app/hooks/useRecentEmoji.ts @@ -1,23 +1,28 @@ -import { useEffect, useState } from 'react'; -import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { useCallback, useMemo } from 'react'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; import { getRecentEmojis } from '../plugins/recent-emoji'; -import { AccountDataEvent } from '../../types/matrix/accountData'; +import { AccountDataType } from '../../types/matrix/accountData'; import { IEmoji } from '../plugins/emoji'; +import { + useComputedPendingObservableResult, + useObservableValuePromise, +} from './useObservableValue'; +import { useClient } from './useClient'; -export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => { - const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit)); +export const useRecentEmoji = (limit?: number): PendingObservableResult => { + const ct = useClient(); + const recentEmojiObjResult = useObservableValuePromise( + useMemo( + () => ct.storage.accountData.observableRead.get(AccountDataType.ElementRecentEmoji), + [ct] + ) + ); - useEffect(() => { - const handleAccountData = (event: MatrixEvent) => { - if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return; - setRecentEmoji(getRecentEmojis(mx, limit)); - }; - - mx.on(ClientEvent.AccountData, handleAccountData); - return () => { - mx.removeListener(ClientEvent.AccountData, handleAccountData); - }; - }, [mx, limit]); - - return recentEmoji; + return useComputedPendingObservableResult( + recentEmojiObjResult, + useCallback( + (recentEmojiObj) => (recentEmojiObj ? getRecentEmojis(recentEmojiObj.event, limit) : []), + [limit] + ) + ); }; diff --git a/src/app/hooks/useRecoveryAttempt.ts b/src/app/hooks/useRecoveryAttempt.ts new file mode 100644 index 00000000..f8505c92 --- /dev/null +++ b/src/app/hooks/useRecoveryAttempt.ts @@ -0,0 +1,26 @@ +import { useRef } from 'react'; + +export const getAttemptDelaySec = (attempt: number): number => { + if (attempt > 5) return 60; + if (attempt === 5) return 45; + if (attempt === 4) return 30; + if (attempt === 3) return 15; + if (attempt === 2) return 10; + if (attempt === 1) return 5; + + return 0; +}; + +export const useRecoveryAttempt = (hasError: boolean, hasRecovered: boolean): number => { + const recoverAttemptRef = useRef(0); + + if (hasError) { + recoverAttemptRef.current += 1; + } + + if (hasRecovered) { + recoverAttemptRef.current = 0; + } + + return recoverAttemptRef.current; +}; diff --git a/src/app/hooks/useRegisterEmail.ts b/src/app/hooks/useRegisterEmail.ts index d29c9e6d..e504ce7c 100644 --- a/src/app/hooks/useRegisterEmail.ts +++ b/src/app/hooks/useRegisterEmail.ts @@ -1,30 +1,54 @@ -import { MatrixClient, MatrixError } from 'matrix-js-sdk'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useMemo } from 'react'; +import { + PostEmailRegisterTokenOK, + postEmailRequestToken, +} from 'neurone/lib/api/matrix/client/v3/register'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncState, useAsyncCallback } from './useAsyncCallback'; -import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types'; +import { RequestEmailTokenCallback, RequestEmailTokenResult } from './types'; +import { getJSONRequest } from '../../client/request'; export const useRegisterEmail = ( - mx: MatrixClient -): [AsyncState, RequestEmailTokenCallback] => { + baseUrl: string +): [AsyncState, RequestEmailTokenCallback] => { + const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]); const sendAttemptRef = useRef(1); const registerEmailCallback: RequestEmailTokenCallback = useCallback( async (email, clientSecret, nextLink) => { const sendAttempt = sendAttemptRef.current; sendAttemptRef.current += 1; - const result = await mx.requestRegisterEmailToken(email, clientSecret, sendAttempt, nextLink); - return { - email, - clientSecret, - result, - }; + + const response = await requestJSON( + postEmailRequestToken({ + body: { + email, + client_secret: clientSecret, + next_link: nextLink, + send_attempt: sendAttempt, + }, + }) + ); + + const [error, result] = await parseMatrixResp(response); + + if (error) return [error, undefined]; + + return [ + undefined, + { + email, + clientSecret, + result, + }, + ]; }, - [mx] + [requestJSON] ); const [registerEmailState, registerEmail] = useAsyncCallback< - RequestEmailTokenResponse, - MatrixError, + RequestEmailTokenResult, + Error, Parameters >(registerEmailCallback); diff --git a/src/app/hooks/useReportRoomSupported.ts b/src/app/hooks/useReportRoomSupported.ts index 198172cd..3d5baa55 100644 --- a/src/app/hooks/useReportRoomSupported.ts +++ b/src/app/hooks/useReportRoomSupported.ts @@ -1,10 +1,11 @@ -import { useSpecVersions } from './useSpecVersions'; +import { useSpecVersionSupport, useUnstableFeatureSupport } from './useSpecVersions'; export const useReportRoomSupported = (): boolean => { - const { versions, unstable_features: unstableFeatures } = useSpecVersions(); + const versionSupport = useSpecVersionSupport('v1.13'); + const unstableSupport = useUnstableFeatureSupport()('org.matrix.msc4151'); // report room is introduced in spec version 1.13 - const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13'); + const supported = unstableSupport || versionSupport; return supported; }; diff --git a/src/app/hooks/useRequest.ts b/src/app/hooks/useRequest.ts new file mode 100644 index 00000000..c08a1d34 --- /dev/null +++ b/src/app/hooks/useRequest.ts @@ -0,0 +1,22 @@ +import { MatrixRequest } from 'neurone/lib/matrix-request'; +import { createContext, useContext } from 'react'; + +const JSONRequestContext = createContext(null); +export const JSONRequestProvider = JSONRequestContext.Provider; +export const useJSONRequest = (): MatrixRequest => { + const request = useContext(JSONRequestContext); + + if (!request) throw new Error('JSON request is not provided!'); + + return request; +}; + +const AuthJSONRequestContext = createContext(null); +export const AuthJSONRequestProvider = AuthJSONRequestContext.Provider; +export const useAuthJSONRequest = (): MatrixRequest => { + const request = useContext(AuthJSONRequestContext); + + if (!request) throw new Error('Auth JSON request is not provided!'); + + return request; +}; diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts index 4041887d..398c962f 100644 --- a/src/app/hooks/useRoom.ts +++ b/src/app/hooks/useRoom.ts @@ -1,11 +1,10 @@ -import { Room } from 'matrix-js-sdk'; import { createContext, useContext } from 'react'; -const RoomContext = createContext(null); +const RoomContext = createContext(null); export const RoomProvider = RoomContext.Provider; -export function useRoom(): Room { +export function useRoom(): string { const room = useContext(RoomContext); if (!room) throw new Error('Room not provided!'); return room; diff --git a/src/app/hooks/useRoomCreators.ts b/src/app/hooks/useRoomCreators.ts index 269d11af..911f9b2e 100644 --- a/src/app/hooks/useRoomCreators.ts +++ b/src/app/hooks/useRoomCreators.ts @@ -1,9 +1,9 @@ -import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; -import { useMemo } from 'react'; -import { useStateEvent } from './useStateEvent'; +import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { IRoomCreateContent, StateEvent } from '../../types/matrix/room'; import { creatorsSupported } from '../utils/matrix'; import { getStateEvent } from '../utils/room'; +import { useObservableValue } from './useObservableValue'; +import { useClient } from './useClient'; export const getRoomCreators = (createEvent: MatrixEvent): Set => { const createContent = createEvent.getContent(); @@ -27,15 +27,15 @@ export const getRoomCreators = (createEvent: MatrixEvent): Set => { return creators; }; -export const useRoomCreators = (room: Room): Set => { - const createEvent = useStateEvent(room, StateEvent.RoomCreate); +export const useRoomCreators = (roomId: string): Set | undefined => { + const ct = useClient(); + const roomToCreators = useObservableValue(ct.roomToCreators); - const creators = useMemo( - () => (createEvent ? getRoomCreators(createEvent) : new Set()), - [createEvent] - ); + if (!creatorsSupported(ct.roomToVersion.read.get(roomId) ?? '1')) { + return undefined; + } - return creators; + return roomToCreators.get(roomId); }; export const getRoomCreatorsForRoomId = (mx: MatrixClient, roomId: string): Set => { diff --git a/src/app/hooks/useRoomEvent.ts b/src/app/hooks/useRoomEvent.ts index c2715f1f..93fdf02e 100644 --- a/src/app/hooks/useRoomEvent.ts +++ b/src/app/hooks/useRoomEvent.ts @@ -1,29 +1,23 @@ -import { IEvent, MatrixEvent, Room } from 'matrix-js-sdk'; import { useCallback, useMemo } from 'react'; -import to from 'await-to-js'; -import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; +import { getEvent, GetEventOK } from 'neurone/lib/api/matrix/client/v3/rooms'; import { useQuery } from '@tanstack/react-query'; -import { useMatrixClient } from './useMatrixClient'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { ClientEvent, MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { useClient } from './useClient'; -const useFetchEvent = (room: Room, eventId: string) => { - const mx = useMatrixClient(); +const useFetchEvent = (roomId: string, eventId: string) => { + const ct = useClient(); - const fetchEventCallback = useCallback(async () => { - const evt = await mx.fetchRoomEvent(room.roomId, eventId); - const mEvent = new MatrixEvent(evt); + const fetchEventCallback = useCallback(async (): Promise => { + const resp = await ct.requestAuthJSON(getEvent({ pathParams: { roomId, eventId } })); - if (evt.unsigned?.['m.relations'] && evt.unsigned?.['m.relations']['m.replace']) { - const replaceEvt = evt.unsigned?.['m.relations']['m.replace'] as IEvent; - const replaceEvent = new MatrixEvent(replaceEvt); - mEvent.makeReplaced(replaceEvent); + const [err, result] = await parseMatrixResp(resp); + if (err) { + throw new Error(printMatrixError(err)); } - if (mEvent.isEncrypted() && mx.getCrypto()) { - await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend)); - } - - return mEvent; - }, [mx, room.roomId, eventId]); + return result; + }, [ct, roomId, eventId]); return fetchEventCallback; }; @@ -35,20 +29,20 @@ const useFetchEvent = (room: Room, eventId: string) => { * @returns `MatrixEvent`, `undefined` means loading, `null` means failure */ export const useRoomEvent = ( - room: Room, + roomId: string, eventId: string, - getLocally?: () => MatrixEvent | undefined + getLocally?: () => MessageEventWithoutRoomId | undefined ) => { const event = useMemo(() => { if (getLocally) return getLocally(); - return room.findEventById(eventId); - }, [room, eventId, getLocally]); + return undefined; + }, [getLocally]); - const fetchEvent = useFetchEvent(room, eventId); + const fetchEvent = useFetchEvent(roomId, eventId); const { data, error } = useQuery({ enabled: event === undefined, - queryKey: [room.roomId, eventId], + queryKey: [roomId, eventId], queryFn: fetchEvent, staleTime: Infinity, gcTime: 60 * 60 * 1000, // 1hour diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index df369011..e30d83e5 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -1,37 +1,108 @@ -import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk'; -import { useEffect, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { PendingObservableResult } from 'neurone/lib/utils/observable'; +import { RoomMembership } from 'neurone/lib/api/types'; +import { useClient } from './useClient'; +import { useObservableValuePromise } from './useObservableValue'; +import { JoinRoomMemberObject } from '../../client/storage/collections'; +import { getMxIdLocalPart } from '../utils/matrix'; +import { + roomMemberContentAvatarUrl, + roomMemberContentDisplayname, + roomMemberContentMembership, +} from '../utils/room'; -export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { - const [members, setMembers] = useState([]); - - useEffect(() => { - const room = mx.getRoom(roomId); - let loadingMembers = true; - let disposed = false; - - const updateMemberList = (event?: MatrixEvent) => { - if (!room || disposed || (event && event.getRoomId() !== roomId)) return; - if (loadingMembers) return; - setMembers(room.getMembers()); - }; - - if (room) { - setMembers(room.getMembers()); - room.loadMembersIfNeeded().then(() => { - loadingMembers = false; - if (disposed) return; - updateMemberList(); - }); - } - - mx.on(RoomMemberEvent.Membership, updateMemberList); - mx.on(RoomMemberEvent.PowerLevel, updateMemberList); - return () => { - disposed = true; - mx.removeListener(RoomMemberEvent.Membership, updateMemberList); - mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); - }; - }, [mx, roomId]); - - return members; +export type GetRoomMemberCallback = (userId: string) => JoinRoomMemberObject | undefined; +export type GetRoomMemberNameCallback = (userId: string) => string; +export type GetRoomMemberAvatarCallback = (userId: string) => string | undefined; +export type GetRoomMemberMembershipCallback = (userId: string) => RoomMembership | undefined; +export type RoomMembersAPI = { + getMember: GetRoomMemberCallback; + getName: GetRoomMemberNameCallback; + getAvatar: GetRoomMemberAvatarCallback; + getMembership: GetRoomMemberMembershipCallback; + memberEventsResult: PendingObservableResult; +}; + +// FIXME: remove after usage over +export const useRoomMembers = () => undefined; + +export const useCreateRoomMembersAPI = (roomId: string): RoomMembersAPI => { + const ct = useClient(); + const memberEventsPromise = useMemo( + () => ct.storage.joinRoomMember.observableRead.queryIndex('roomId', roomId), + [ct, roomId] + ); + + const memberEventsResult = useObservableValuePromise(memberEventsPromise); + const memberEvents = memberEventsResult.value; + + const userIdToMemberEvent = useMemo(() => { + const uToM: Record = {}; + memberEvents?.forEach((member) => { + uToM[member.userId] = member; + }); + return uToM; + }, [memberEvents]); + + const getMember: GetRoomMemberCallback = useCallback( + (userId) => { + const member = userIdToMemberEvent[userId]; + return member; + }, + [userIdToMemberEvent] + ); + + const getName: GetRoomMemberNameCallback = useCallback( + (userId) => { + const member = getMember(userId); + + const content = member?.event.content; + const displayName = content ? roomMemberContentDisplayname(content) : undefined; + + return displayName || getMxIdLocalPart(userId) || userId; + }, + [getMember] + ); + + const getAvatar: GetRoomMemberAvatarCallback = useCallback( + (userId) => { + const member = getMember(userId); + + const content = member?.event.content; + if (!content) return undefined; + + return roomMemberContentAvatarUrl(content); + }, + [getMember] + ); + + const getMembership: GetRoomMemberMembershipCallback = useCallback( + (userId) => { + const member = getMember(userId); + + const content = member?.event.content; + if (!content) return undefined; + + return roomMemberContentMembership(content); + }, + [getMember] + ); + + const api: RoomMembersAPI = useMemo( + () => ({ memberEventsResult, getMember, getName, getAvatar, getMembership }), + [memberEventsResult, getMember, getName, getAvatar, getMembership] + ); + return api; +}; + +const RoomMembersAPIContext = createContext(null); +export const RoomMembersAPIProvider = RoomMembersAPIContext.Provider; + +export const useRoomMembersAPI = (): RoomMembersAPI => { + const api = useContext(RoomMembersAPIContext); + if (!api) { + throw new Error('Room Members context is not provided!'); + } + + return api; }; diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index b2d7a91a..67912b4c 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -1,24 +1,25 @@ import { useCallback } from 'react'; import { NavigateOptions, 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, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { mDirectAtom } from '../state/mDirectList'; import { useSelectedSpace } from './router/useSelectedSpace'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; +import { useClient } from './useClient'; +import { getJoinRoomStateId } from '../../client/storage/collections'; +import { StateEventType } from '../../types/matrix/room'; export const useRoomNavigate = () => { + const ct = useClient(); const navigate = useNavigate(); - const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); const spaceSelectedId = useSelectedSpace(); @@ -26,16 +27,17 @@ export const useRoomNavigate = () => { const navigateSpace = useCallback( (roomId: string) => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + const roomIdOrAlias = ct.roomToAlias.read.get(roomId) ?? roomId; navigate(getSpacePath(roomIdOrAlias)); }, - [mx, navigate] + [ct, navigate] ); const navigateRoom = useCallback( - (roomId: string, eventId?: string, opts?: NavigateOptions) => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); - const openSpaceTimeline = developerTools && spaceSelectedId === roomId; + async (roomId: string, eventId?: string, opts?: NavigateOptions) => { + const roomIdOrAlias = ct.roomToAlias.read.get(roomId) ?? roomId; + const openSpaceTimeline = + developerTools && ct.spaceRooms.read.has(roomId) && spaceSelectedId === roomId; const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { @@ -43,10 +45,22 @@ export const useRoomNavigate = () => { if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { parentSpace = spaceSelectedId; } else { - parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0]; + const powerEventIds = [roomId] + .concat(orphanParents) + .map((rId) => getJoinRoomStateId(rId, StateEventType.RoomPowerLevels, '')); + const powerLevelEventObjs = await ct.storage.joinRoomState.read.getMultiple( + powerEventIds + ); + parentSpace = + guessPerfectParent( + roomId, + orphanParents, + (rId) => ct.roomToCreators.read.get(rId), + (rId) => powerLevelEventObjs.find((obj) => obj?.roomId === rId)?.event.content + ) ?? orphanParents[0]; } - const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); + const pSpaceIdOrAlias = ct.roomToAlias.read.get(parentSpace) ?? parentSpace; navigate( getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), @@ -62,7 +76,7 @@ export const useRoomNavigate = () => { navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); }, - [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] + [ct, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); return { diff --git a/src/app/hooks/useRoomPermissions.ts b/src/app/hooks/useRoomPermissions.ts index cb6f69a2..0ad17989 100644 --- a/src/app/hooks/useRoomPermissions.ts +++ b/src/app/hooks/useRoomPermissions.ts @@ -14,30 +14,30 @@ export type RoomPermissionsAPI = { }; export const getRoomPermissionsAPI = ( - creators: Set, + creators: Set | undefined, powerLevels: IPowerLevels ): RoomPermissionsAPI => { const api: RoomPermissionsAPI = { event: (type, userId) => { - if (creators.has(userId)) return true; + if (creators?.has(userId)) return true; const userPower = readPowerLevel.user(powerLevels, userId); const requiredPL = readPowerLevel.event(powerLevels, type); return userPower >= requiredPL; }, stateEvent: (type, userId) => { - if (creators.has(userId)) return true; + if (creators?.has(userId)) return true; const userPower = readPowerLevel.user(powerLevels, userId); const requiredPL = readPowerLevel.state(powerLevels, type); return userPower >= requiredPL; }, action: (action, userId) => { - if (creators.has(userId)) return true; + if (creators?.has(userId)) return true; const userPower = readPowerLevel.user(powerLevels, userId); const requiredPL = readPowerLevel.action(powerLevels, action); return userPower >= requiredPL; }, notificationAction: (action, userId) => { - if (creators.has(userId)) return true; + if (creators?.has(userId)) return true; const userPower = readPowerLevel.user(powerLevels, userId); const requiredPL = readPowerLevel.notification(powerLevels, action); return userPower >= requiredPL; @@ -48,7 +48,7 @@ export const getRoomPermissionsAPI = ( }; export const useRoomPermissions = ( - creators: Set, + creators: Set | undefined, powerLevels: IPowerLevels ): RoomPermissionsAPI => { const api: RoomPermissionsAPI = useMemo( diff --git a/src/app/hooks/useRoomPinnedEvents.ts b/src/app/hooks/useRoomPinnedEvents.ts index 9ab1d6bd..42d88fa7 100644 --- a/src/app/hooks/useRoomPinnedEvents.ts +++ b/src/app/hooks/useRoomPinnedEvents.ts @@ -1,14 +1,16 @@ import { useMemo } from 'react'; -import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; -import { Room } from 'matrix-js-sdk'; -import { StateEvent } from '../../types/matrix/room'; -import { useStateEvent } from './useStateEvent'; +import { StateEventType } from '../../types/matrix/room'; +import { useRoomStateEvent } from './storage/useRoomStateEvent'; -export const useRoomPinnedEvents = (room: Room): string[] => { - const pinEvent = useStateEvent(room, StateEvent.RoomPinnedEvents); +export const useRoomPinnedEvents = (roomId: string): string[] => { + const pinEventResult = useRoomStateEvent(roomId, StateEventType.RoomPinnedEvents, ''); + const pinEvent = pinEventResult.value; const events = useMemo(() => { - const content = pinEvent?.getContent(); - return content?.pinned ?? []; + const { content } = pinEvent?.event ?? {}; + if (content && 'pinned' in content && Array.isArray(content.pinned)) { + return content.pinned; + } + return []; }, [pinEvent]); return events; diff --git a/src/app/hooks/useRoomTypingMembers.ts b/src/app/hooks/useRoomTypingMembers.ts index f526cbf1..28570fd5 100644 --- a/src/app/hooks/useRoomTypingMembers.ts +++ b/src/app/hooks/useRoomTypingMembers.ts @@ -1,18 +1,11 @@ import { useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import { useCallback } from 'react'; -import { - IRoomIdToTypingMembers, - TypingReceipt, - roomIdToTypingMembersAtom, -} from '../state/typingMembers'; +import { IRoomIdToTypingMembers, roomIdToTypingMembersAtom } from '../state/typingMembers'; -const typingReceiptEqual = (a: TypingReceipt, b: TypingReceipt): boolean => - a.userId === b.userId && a.ts === b.ts; - -const equalTypingMembers = (x: TypingReceipt[], y: TypingReceipt[]): boolean => { +const equalTypingMembers = (x: string[], y: string[]): boolean => { if (x.length !== y.length) return false; - return x.every((a, i) => typingReceiptEqual(a, y[i])); + return x.every((a, i) => a[1] === y[i]); }; export const useRoomTypingMember = (roomId: string) => { diff --git a/src/app/hooks/useRoomsNotificationPreferences.ts b/src/app/hooks/useRoomsNotificationPreferences.ts index ceea95f7..4897806a 100644 --- a/src/app/hooks/useRoomsNotificationPreferences.ts +++ b/src/app/hooks/useRoomsNotificationPreferences.ts @@ -1,8 +1,7 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; -import { ConditionKind, IPushRules, MatrixClient, PushRuleKind } from 'matrix-js-sdk'; +import { AccountDataEvent, PushRules } from 'neurone/lib/api/types'; +import { deletePushRule, putPushRule } from 'neurone/lib/api/matrix/client/v3/pushrules'; import { Icons, IconSrc } from 'folds'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { useAccountData } from './useAccountData'; import { isRoomId } from '../utils/matrix'; import { getNotificationMode, @@ -10,7 +9,10 @@ import { NotificationMode, } from './useNotificationMode'; import { useAsyncCallback } from './useAsyncCallback'; -import { useMatrixClient } from './useMatrixClient'; +import { useAccountData } from './storage/useAccountData'; +import { ClientContext } from '../../client/context'; +import { useClient } from './useClient'; +import { AccountDataType, ConditionKind } from '../../types/matrix/accountData'; export type RoomsNotificationPreferences = { mute: Set; @@ -34,7 +36,10 @@ export const useRoomsNotificationPreferencesContext = (): RoomsNotificationPrefe }; export const useRoomsNotificationPreferences = (): RoomsNotificationPreferences => { - const pushRules = useAccountData(AccountDataEvent.PushRules)?.getContent(); + const { value: pushRulesEvent } = useAccountData>( + AccountDataType.PushRules + ); + const pushRules = pushRulesEvent?.content; const preferences: RoomsNotificationPreferences = useMemo(() => { const global = pushRules?.global; @@ -108,7 +113,7 @@ export const getRoomNotificationModeIcon = (mode?: RoomNotificationMode): IconSr }; export const setRoomNotificationPreference = async ( - mx: MatrixClient, + ct: ClientContext, roomId: string, mode: RoomNotificationMode, previousMode: RoomNotificationMode @@ -118,10 +123,26 @@ export const setRoomNotificationPreference = async ( previousMode === RoomNotificationMode.AllMessages || previousMode === RoomNotificationMode.SpecialMessages ) { - await mx.deletePushRule('global', PushRuleKind.RoomSpecific, roomId); + await ct.requestAuthJSON( + deletePushRule({ + pathParams: { + scope: 'global', + kind: 'room', + ruleId: roomId, + }, + }) + ); } if (previousMode === RoomNotificationMode.Mute) { - await mx.deletePushRule('global', PushRuleKind.Override, roomId); + await ct.requestAuthJSON( + deletePushRule({ + pathParams: { + scope: 'global', + kind: 'override', + ruleId: roomId, + }, + }) + ); } // set new preference @@ -130,35 +151,53 @@ export const setRoomNotificationPreference = async ( } if (mode === RoomNotificationMode.Mute) { - await mx.addPushRule('global', PushRuleKind.Override, roomId, { - conditions: [ - { - kind: ConditionKind.EventMatch, - key: 'room_id', - pattern: roomId, + await ct.requestAuthJSON( + putPushRule({ + pathParams: { + scope: 'global', + kind: 'override', + ruleId: roomId, }, - ], - actions: getNotificationModeActions(NotificationMode.OFF), - }); + body: { + conditions: [ + { + kind: ConditionKind.EventMatch, + key: 'room_id', + pattern: roomId, + }, + ], + actions: getNotificationModeActions(NotificationMode.OFF), + }, + }) + ); return; } - await mx.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { - actions: - mode === RoomNotificationMode.AllMessages - ? getNotificationModeActions(NotificationMode.NotifyLoud) - : getNotificationModeActions(NotificationMode.OFF), - }); + await ct.requestAuthJSON( + putPushRule({ + pathParams: { + scope: 'global', + kind: 'room', + ruleId: roomId, + }, + body: { + actions: + mode === RoomNotificationMode.AllMessages + ? getNotificationModeActions(NotificationMode.NotifyLoud) + : getNotificationModeActions(NotificationMode.OFF), + }, + }) + ); }; export const useSetRoomNotificationPreference = (roomId: string) => { - const mx = useMatrixClient(); + const ct = useClient(); const [modeState, setMode] = useAsyncCallback( useCallback( (mode: RoomNotificationMode, previousMode: RoomNotificationMode) => - setRoomNotificationPreference(mx, roomId, mode, previousMode), - [mx, roomId] + setRoomNotificationPreference(ct, roomId, mode, previousMode), + [ct, roomId] ) ); diff --git a/src/app/hooks/useSecondsCountdown.ts b/src/app/hooks/useSecondsCountdown.ts new file mode 100644 index 00000000..a101e1f7 --- /dev/null +++ b/src/app/hooks/useSecondsCountdown.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +export const useSecondsCountdown = (seconds: number, onEnd?: () => void): number => { + const [countdown, setCountdown] = useState(seconds); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + if (countdown <= 1) { + onEnd?.(); + } else { + setCountdown(countdown - 1); + } + }, 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [countdown, onEnd]); + + return countdown; +}; diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts index 16aba225..375c40e7 100644 --- a/src/app/hooks/useSidebarItems.ts +++ b/src/app/hooks/useSidebarItems.ts @@ -1,10 +1,7 @@ -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'; +import { useMemo } from 'react'; +import { AccountDataEvent } from 'neurone/lib/api/types'; +import { useClient } from './useClient'; +import { ClientContext } from '../../client/context'; export type ISidebarFolder = { name?: string; @@ -20,7 +17,7 @@ export type InCinnySpacesContent = { }; export const parseSidebar = ( - mx: MatrixClient, + ct: ClientContext, orphanSpaces: string[], content?: InCinnySpacesContent ) => { @@ -31,9 +28,8 @@ export const parseSidebar = ( 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); + if (!ct.rooms.join.read.includes(spaceId)) return false; + return ct.spaceRooms.read.has(spaceId); }; sidebar.forEach((item) => { @@ -64,40 +60,17 @@ export const parseSidebar = ( }; export const useSidebarItems = ( - orphanSpaces: string[] -): [SidebarItems, Dispatch>] => { - const mx = useMatrixClient(); + orphanSpaces: string[], + cinnySpacesEvent?: AccountDataEvent +): SidebarItems => { + const ct = useClient(); - const [sidebarItems, setSidebarItems] = useState(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); - }); + const sidebarItems: SidebarItems = useMemo(() => { + const content = cinnySpacesEvent?.content; + return parseSidebar(ct, orphanSpaces, content); + }, [ct, cinnySpacesEvent, orphanSpaces]); - useEffect(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); - }, [mx, orphanSpaces]); - - useAccountDataCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.CinnySpaces) { - const newContent = mEvent.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); - } - }, - [mx, orphanSpaces] - ) - ); - - return [sidebarItems, setSidebarItems]; + return sidebarItems; }; export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { @@ -123,16 +96,24 @@ export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { }; export const makeCinnySpacesContent = ( - mx: MatrixClient, - items: SidebarItems + items: SidebarItems, + cinnySpacesEvent?: AccountDataEvent ): InCinnySpacesContent => { - const currentInSpaces = - getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; + const content = cinnySpacesEvent?.content ?? {}; const newSpacesContent: InCinnySpacesContent = { - ...currentInSpaces, + ...content, sidebar: items, }; return newSpacesContent; }; + +export const useSidebarRooms = (sidebarItems: SidebarItems): string[] => { + const rooms = useMemo(() => { + const ids = sidebarItems.flatMap((item) => (typeof item === 'string' ? [item] : item.content)); + return Array.from(new Set(ids)); + }, [sidebarItems]); + + return rooms; +}; diff --git a/src/app/hooks/useSpace.ts b/src/app/hooks/useSpace.ts index 8bec49e4..595c6d8d 100644 --- a/src/app/hooks/useSpace.ts +++ b/src/app/hooks/useSpace.ts @@ -1,17 +1,16 @@ -import { Room } from 'matrix-js-sdk'; import { createContext, useContext } from 'react'; -const SpaceContext = createContext(null); +const SpaceContext = createContext(null); export const SpaceProvider = SpaceContext.Provider; -export function useSpace(): Room { +export function useSpace(): string { const space = useContext(SpaceContext); if (!space) throw new Error('Space not provided!'); return space; } -export function useSpaceOptionally(): Room | null { +export function useSpaceOptionally(): string | undefined { const space = useContext(SpaceContext); - return space; + return space ?? undefined; } diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f4..8379fd86 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -1,20 +1,22 @@ -import { atom, useAtom, useAtomValue } from 'jotai'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; -import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { useAtomValue } from 'jotai'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { SpaceHierarchyRoomChunk, StateEventWithoutRoomId } from 'neurone/lib/api/types'; import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query'; -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 { getHierarchy, GetHierarchyOK } from 'neurone/lib/api/matrix/client/v1/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { SpaceChildContent } from '../../types/matrix/room'; import { isRoomId } from '../utils/matrix'; -import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; -import { useStateEventCallback } from './useStateEventCallback'; +import { SortFunc, byOrderKey, byTsOldToNew } from '../utils/sort'; import { ErrorCode } from '../cs-errorcode'; +import { useObservableValue } from './useObservableValue'; +import { useClient } from './useClient'; +import { useAllJoinedRoomsSet } from './useGetRoom'; +import { spaceRoomsAtom } from '../state/spaceRooms'; +import { rError } from '../../client/reader'; export type HierarchyItemSpace = { roomId: string; - content: MSpaceChildContent; + content: SpaceChildContent; ts: number; space: true; parentId?: string; @@ -22,23 +24,22 @@ export type HierarchyItemSpace = { export type HierarchyItemRoom = { roomId: string; - content: MSpaceChildContent; + content: SpaceChildContent; ts: number; parentId: string; }; export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom; -type GetRoomCallback = (roomId: string) => Room | undefined; - const hierarchyItemTs: SortFunc = (a, b) => byTsOldToNew(a.ts, b.ts); const hierarchyItemByOrder: SortFunc = (a, b) => byOrderKey(a.content.order, b.content.order); const getHierarchySpaces = ( rootSpaceId: string, - getRoom: GetRoomCallback, - spaceRooms: Set + roomToChildEvents: Map[]>, + spaceRooms: Set, + spaceRoomsCache: Set ): HierarchyItemSpace[] => { const rootSpaceItem: HierarchyItemSpace = { roomId: rootSpaceId, @@ -50,25 +51,22 @@ const getHierarchySpaces = ( const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { 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); + const childEvents = roomToChildEvents.get(spaceItem.roomId); - childEvents.forEach((childEvent) => { - if (!isValidChild(childEvent)) return; - const childId = childEvent.getStateKey(); - if (!childId || !isRoomId(childId)) return; + childEvents?.forEach((childEvent) => { + const childId = childEvent.state_key; + if (!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)) { + if (spaceRooms.has(childId) || spaceRoomsCache.has(childId)) { const childItem: HierarchyItemSpace = { roomId: childId, - content: childEvent.getContent(), - ts: childEvent.getTs(), + content: childEvent.content, + ts: childEvent.origin_server_ts, space: true, parentId: spaceItem.roomId, }; @@ -95,31 +93,30 @@ export type SpaceHierarchy = { }; const getSpaceHierarchy = ( rootSpaceId: string, + roomToChildEvents: Map[]>, spaceRooms: Set, - getRoom: (roomId: string) => Room | undefined, - closedCategory: (spaceId: string) => boolean + spaceRoomsCache: Set ): SpaceHierarchy[] => { - const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( + rootSpaceId, + roomToChildEvents, + spaceRooms, + spaceRoomsCache + ); const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => { - const space = getRoom(spaceItem.roomId); - if (!space || closedCategory(spaceItem.roomId)) { - return { - space: spaceItem, - }; - } - const childEvents = getStateEvents(space, StateEvent.SpaceChild); + const childEvents = roomToChildEvents.get(spaceItem.roomId); + const childItems: HierarchyItemRoom[] = []; - childEvents.forEach((childEvent) => { - if (!isValidChild(childEvent)) return; - const childId = childEvent.getStateKey(); + childEvents?.forEach((childEvent) => { + const childId = childEvent.state_key; if (!childId || !isRoomId(childId)) return; - if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return; + if (spaceRooms.has(childId) || spaceRoomsCache.has(childId)) return; const childItem: HierarchyItemRoom = { roomId: childId, - content: childEvent.getContent(), - ts: childEvent.getTs(), + content: childEvent.content, + ts: childEvent.origin_server_ts, parentId: spaceItem.roomId, }; childItems.push(childItem); @@ -134,38 +131,15 @@ const getSpaceHierarchy = ( return hierarchy; }; -export const useSpaceHierarchy = ( - spaceId: string, - spaceRooms: Set, - getRoom: (roomId: string) => Room | undefined, - closedCategory: (spaceId: string) => boolean -): SpaceHierarchy[] => { - const mx = useMatrixClient(); - const roomToParents = useAtomValue(roomToParentsAtom); +export const useSpaceHierarchy = (spaceId: string): SpaceHierarchy[] => { + const ct = useClient(); + const roomToChildEvents = useObservableValue(ct.roomToChildEvents); + const spaceRooms = useObservableValue(ct.spaceRooms); + const spaceRoomsCache = useAtomValue(spaceRoomsAtom); - 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] - ) + const hierarchy = useMemo( + () => getSpaceHierarchy(spaceId, roomToChildEvents, spaceRooms, spaceRoomsCache), + [spaceId, roomToChildEvents, spaceRooms, spaceRoomsCache] ); return hierarchy; @@ -173,94 +147,65 @@ export const useSpaceHierarchy = ( const getSpaceJoinedHierarchy = ( rootSpaceId: string, - getRoom: GetRoomCallback, - excludeRoom: (parentId: string, roomId: string) => boolean, + joinedRooms: Set, + roomToChildEvents: Map[]>, + spaceRooms: Set, sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[] ): HierarchyItem[] => { - const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( + rootSpaceId, + roomToChildEvents, + spaceRooms, + 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 childEvents = roomToChildEvents.get(spaceItem.roomId); const childItems: HierarchyItemRoom[] = []; - joinedRoomEvents.forEach((childEvent) => { - const childId = childEvent.getStateKey(); - if (!childId) return; - if (excludeRoom(space.roomId, childId)) return; + childEvents?.forEach((childEvent) => { + const childId = childEvent.state_key; + if (!childId || !isRoomId(childId)) return; + if (!joinedRooms.has(childId) || spaceRooms.has(childId)) return; const childItem: HierarchyItemRoom = { roomId: childId, - content: childEvent.getContent(), - ts: childEvent.getTs(), + content: childEvent.content, + ts: childEvent.origin_server_ts, parentId: spaceItem.roomId, }; childItems.push(childItem); }); + if (childItems.length === 0) return []; + 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); +export const useSpaceJoinedHierarchy = (spaceId: string): HierarchyItem[] => { + const ct = useClient(); + const roomToChildEvents = useObservableValue(ct.roomToChildEvents); + const allJoinedRooms = useAllJoinedRoomsSet(); + const spaceRooms = useObservableValue(ct.spaceRooms); - 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 sortRoomItems = useCallback((sId: string, items: HierarchyItem[]) => { + items.sort(hierarchyItemTs).sort(hierarchyItemByOrder); + return items; + }, []); - 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] - ) + const hierarchy = useMemo( + () => + getSpaceJoinedHierarchy( + spaceId, + allJoinedRooms, + roomToChildEvents, + spaceRooms, + sortRoomItems + ), + [spaceId, allJoinedRooms, roomToChildEvents, spaceRooms, sortRoomItems] ); return hierarchy; @@ -272,22 +217,36 @@ const MAX_AUTO_PAGE_COUNT = 50; export type FetchSpaceHierarchyLevelData = { fetching: boolean; error: Error | null; - rooms: Map; + rooms: Map; }; export const useFetchSpaceHierarchyLevel = ( roomId: string, enable: boolean ): FetchSpaceHierarchyLevelData => { - const mx = useMatrixClient(); + const ct = useClient(); const pageNoRef = useRef(0); - const fetchLevel: QueryFunction< - Awaited>, - string[], - string | undefined - > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), - [roomId, mx] + const fetchLevel: QueryFunction = useCallback( + async ({ pageParam }) => { + const response = await ct.requestAuthJSON( + getHierarchy({ + pathParams: { roomId }, + searchParams: { + from: pageParam, + max_depth: 1, + limit: PER_PAGE_COUNT, + suggested_only: false, + }, + }) + ); + const [err, result] = await parseMatrixResp(response); + if (err) { + throw err.data; + } + + return result; + }, + [roomId, ct] ); const queryResponse = useInfiniteQuery({ @@ -301,11 +260,8 @@ export const useFetchSpaceHierarchyLevel = ( }, retry: 5, retryDelay: (failureCount, error) => { - if (error instanceof MatrixError && error.errcode === ErrorCode.M_LIMIT_EXCEEDED) { - const { retry_after_ms: delay } = error.data; - if (typeof delay === 'number') { - return delay; - } + if (rError.errcode(error) === ErrorCode.M_LIMIT_EXCEEDED && rError.retryAfterMs(error)) { + return rError.retryAfterMs(error) ?? 500 * failureCount; } return 500 * failureCount; @@ -327,8 +283,8 @@ export const useFetchSpaceHierarchyLevel = ( } }, [fetchNextPage, hasNextPage, data, error]); - const rooms: Map = useMemo(() => { - const roomsMap: Map = new Map(); + const rooms: Map = useMemo(() => { + const roomsMap: Map = new Map(); if (!data) return roomsMap; const rms = data.pages.flatMap((result) => result.rooms); diff --git a/src/app/hooks/useSpecVersions.ts b/src/app/hooks/useSpecVersions.ts index 42403c61..7f6318d0 100644 --- a/src/app/hooks/useSpecVersions.ts +++ b/src/app/hooks/useSpecVersions.ts @@ -1,4 +1,4 @@ -import { createContext, useContext } from 'react'; +import { createContext, useCallback, useContext } from 'react'; import { SpecVersions } from '../cs-api'; const SpecVersionsContext = createContext(null); @@ -10,3 +10,20 @@ export function useSpecVersions(): SpecVersions { if (!versions) throw new Error('Server versions are not provided!'); return versions; } + +export function useSpecVersionSupport(specVersion: string): boolean { + const { versions } = useSpecVersions(); + + return versions.includes(specVersion); +} + +export function useUnstableFeatureSupport(): (unstablePrefix: string) => boolean | undefined { + const { unstable_features: unstableFeatures } = useSpecVersions(); + + const checkSupport = useCallback( + (unstablePrefix: string): boolean | undefined => unstableFeatures?.[unstablePrefix], + [unstableFeatures] + ); + + return checkSupport; +} diff --git a/src/app/hooks/useTypingStatusUpdater.ts b/src/app/hooks/useTypingStatusUpdater.ts index db8ceff1..b06684de 100644 --- a/src/app/hooks/useTypingStatusUpdater.ts +++ b/src/app/hooks/useTypingStatusUpdater.ts @@ -1,10 +1,14 @@ -import { MatrixClient } from 'matrix-js-sdk'; import { useMemo, useRef } from 'react'; -import { TYPING_TIMEOUT_MS } from '../state/typingMembers'; +import { putTyping } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { useClient } from './useClient'; + +const TYPING_TIMEOUT_MS = 5000; // 5 seconds type TypingStatusUpdater = (typing: boolean) => void; -export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => { +export const useTypingStatusUpdater = (roomId: string): TypingStatusUpdater => { + const ct = useClient(); + const { userId } = ct.session; const statusSentTsRef = useRef(0); const sendTypingStatus: TypingStatusUpdater = useMemo(() => { @@ -15,7 +19,16 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing return; } - mx.sendTyping(roomId, true, TYPING_TIMEOUT_MS); + ct.requestAuthJSON( + putTyping({ + pathParams: { roomId, userId }, + body: { + typing: true, + timeout: TYPING_TIMEOUT_MS, + }, + }) + ); + const sentTs = Date.now(); statusSentTsRef.current = sentTs; @@ -23,7 +36,12 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing // Clear typing status after timeout if already not; setTimeout(() => { if (statusSentTsRef.current === sentTs) { - mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS); + ct.requestAuthJSON( + putTyping({ + pathParams: { roomId, userId }, + body: { typing: false }, + }) + ); statusSentTsRef.current = 0; } }, TYPING_TIMEOUT_MS); @@ -31,11 +49,16 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing } if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) { - mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS); + ct.requestAuthJSON( + putTyping({ + pathParams: { roomId, userId }, + body: { typing: false }, + }) + ); } statusSentTsRef.current = 0; }; - }, [mx, roomId]); + }, [ct, roomId, userId]); return sendTypingStatus; }; diff --git a/src/app/hooks/useUIAFlows.ts b/src/app/hooks/useUIAFlows.ts index 0e6c0ddc..012298d4 100644 --- a/src/app/hooks/useUIAFlows.ts +++ b/src/app/hooks/useUIAFlows.ts @@ -1,12 +1,15 @@ -import { AuthType, IAuthData, MatrixError, UIAFlow } from 'matrix-js-sdk'; import { useCallback, useMemo } from 'react'; +import { MatrixError } from 'matrix-js-sdk'; import { + AuthResponse, + AuthType, getSupportedUIAFlows, getUIACompleted, getUIAError, getUIAErrorCode, getUIAParams, getUIASession, + UIAFlow, } from '../utils/matrix-uia'; export const SUPPORTED_FLOW_TYPES = [ @@ -21,19 +24,19 @@ export const SUPPORTED_FLOW_TYPES = [ export const useSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] => useMemo(() => getSupportedUIAFlows(uiaFlows, supportedStages), [uiaFlows, supportedStages]); -export const useUIACompleted = (authData: IAuthData): string[] => +export const useUIACompleted = (authData: AuthResponse): string[] => useMemo(() => getUIACompleted(authData), [authData]); -export const useUIAParams = (authData: IAuthData) => +export const useUIAParams = (authData: AuthResponse) => useMemo(() => getUIAParams(authData), [authData]); -export const useUIASession = (authData: IAuthData) => +export const useUIASession = (authData: AuthResponse) => useMemo(() => getUIASession(authData), [authData]); -export const useUIAErrorCode = (authData: IAuthData) => +export const useUIAErrorCode = (authData: AuthResponse) => useMemo(() => getUIAErrorCode(authData), [authData]); -export const useUIAError = (authData: IAuthData) => +export const useUIAError = (authData: AuthResponse) => useMemo(() => getUIAError(authData), [authData]); export type StageInfo = Record; @@ -51,7 +54,7 @@ export type UIAFlowInterface = { hasStage: (stageType: string) => boolean; getStageInfo: (stageType: string) => StageInfo | undefined; }; -export const useUIAFlow = (authData: IAuthData, uiaFlow: UIAFlow): UIAFlowInterface => { +export const useUIAFlow = (authData: AuthResponse, uiaFlow: UIAFlow): UIAFlowInterface => { const completed = useUIACompleted(authData); const params = useUIAParams(authData); const session = useUIASession(authData); @@ -97,9 +100,9 @@ export const useUIAFlow = (authData: IAuthData, uiaFlow: UIAFlow): UIAFlowInterf export const useUIAMatrixError = ( error?: MatrixError -): [undefined, undefined] | [IAuthData, undefined] | [undefined, MatrixError] => { +): [undefined, undefined] | [AuthResponse, undefined] | [undefined, MatrixError] => { if (!error) return [undefined, undefined]; - if (error.httpStatus === 401) return [error.data as IAuthData, undefined]; + if (error.httpStatus === 401) return [error.data as AuthResponse, undefined]; return [undefined, error]; }; diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index c7cb7487..0f8c46dd 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -1,51 +1,37 @@ -import { useEffect, useState } from 'react'; -import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; -import { useMatrixClient } from './useMatrixClient'; +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getProfile, GetProfileOK } from 'neurone/lib/api/matrix/client/v3/profile'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { useClient } from './useClient'; export type UserProfile = { avatarUrl?: string; displayName?: string; }; -export const useUserProfile = (userId: string): UserProfile => { - const mx = useMatrixClient(); +export const useUserProfile = (userId: string): UserProfile | undefined => { + const ct = useClient(); - const [profile, setProfile] = useState(() => { - const user = mx.getUser(userId); - return { - avatarUrl: user?.avatarUrl, - displayName: user?.displayName, - }; - }); - - useEffect(() => { - const user = mx.getUser(userId); - const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => { - setProfile((cp) => ({ - ...cp, - avatarUrl: myUser.avatarUrl, - })); - }; - const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => { - setProfile((cp) => ({ - ...cp, - displayName: myUser.displayName, - })); - }; - - mx.getProfileInfo(userId).then((info) => - setProfile({ - avatarUrl: info.avatar_url, - displayName: info.displayname, + const fetchProfile = useCallback(async (): Promise => { + const response = await ct.requestAuthJSON( + getProfile({ + pathParams: { userId }, }) ); - user?.on(UserEvent.AvatarUrl, onAvatarChange); - user?.on(UserEvent.DisplayName, onDisplayNameChange); - return () => { - user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); - user?.removeListener(UserEvent.DisplayName, onDisplayNameChange); + const [, profile] = await parseMatrixResp(response); + return { + avatarUrl: profile?.avatar_url || undefined, + displayName: profile?.displayname || undefined, }; - }, [mx, userId]); + }, [ct, userId]); - return profile; + const { data } = useQuery({ + queryKey: ['profile', userId], + queryFn: fetchProfile, + staleTime: 0, + gcTime: Infinity, + refetchOnMount: 'always', + }); + + return data; }; diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts index 5ad056a6..1200e153 100644 --- a/src/app/hooks/useVirtualPaginator.ts +++ b/src/app/hooks/useVirtualPaginator.ts @@ -209,13 +209,10 @@ export const useVirtualPaginator = ( let scrollTo = element.offsetTop; if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) { const scrollInfo = getScrollInfo(scrollElement); - scrollTo = - element.offsetTop - - Math.round(scrollInfo.viewHeight / 2) + - Math.round(element.clientHeight / 2); + scrollTo = element.offsetTop - scrollInfo.viewHeight / 2 + element.clientHeight / 2; } else if (opts?.align === 'end' && canFitInScrollView(scrollElement, element)) { const scrollInfo = getScrollInfo(scrollElement); - scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight; + scrollTo = element.offsetTop - scrollInfo.viewHeight + element.clientHeight; } scrollElement.scrollTo({ @@ -366,7 +363,7 @@ export const useVirtualPaginator = ( scrollEl.scrollTo({ top: restoreTop, - behavior: 'instant', + behavior: 'auto', }); restoreScrollRef.current = undefined; }, [range, getScrollElement, getItemElement]); @@ -378,7 +375,7 @@ export const useVirtualPaginator = ( const { index, opts } = scrollToItemRef.current; scrollToItem(index, { ...opts, - behavior: 'instant', + behavior: 'auto', }); scrollToItemRef.current = undefined; }, [range, scrollToItem]); diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 52ec7f20..a6b6437a 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -7,7 +7,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ClientConfigLoader } from '../components/ClientConfigLoader'; import { ClientConfigProvider } from '../hooks/useClientConfig'; -import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; +import { ClientConfigError, ClientConfigLoading } from './ClientConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; @@ -28,9 +28,9 @@ function App() { } + fallback={() => } error={(err, retry, ignore) => ( - + )} > {(clientConfig) => ( diff --git a/src/app/pages/ConfigConfig.tsx b/src/app/pages/ClientConfig.tsx similarity index 72% rename from src/app/pages/ConfigConfig.tsx rename to src/app/pages/ClientConfig.tsx index dbcdca73..d06673ae 100644 --- a/src/app/pages/ConfigConfig.tsx +++ b/src/app/pages/ClientConfig.tsx @@ -1,24 +1,17 @@ -import { Box, Button, Dialog, Spinner, Text, color, config } from 'folds'; +import { Box, Button, Dialog, Text, color, config } from 'folds'; import React from 'react'; import { SplashScreen } from '../components/splash-screen'; -export function ConfigConfigLoading() { - return ( - - - - Heating up - - - ); +export function ClientConfigLoading() { + return Loading Configurations} />; } -type ConfigConfigErrorProps = { +type ClientConfigErrorProps = { error: unknown; retry: () => void; ignore: () => void; }; -export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) { +export function ClientConfigError({ error, retry, ignore }: ClientConfigErrorProps) { return ( diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 18591f29..346838c0 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -121,27 +121,25 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - - - } - > - - - - - - - - - - - - + + + + + + } + > + + + {/* + + + + + + + */} @@ -165,7 +163,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) {mobile ? null : } />} } /> join

} /> - } /> + {/* } /> */} } /> )} - } /> - } /> + {/* } /> + } /> */} } > - + hello + {/* */} } > @@ -275,7 +274,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } > - + hello + {/* */} } > diff --git a/src/app/pages/auth/SSOLogin.tsx b/src/app/pages/auth/SSOLogin.tsx index 3ff1a229..45c72c14 100644 --- a/src/app/pages/auth/SSOLogin.tsx +++ b/src/app/pages/auth/SSOLogin.tsx @@ -1,10 +1,34 @@ import { Avatar, AvatarImage, Box, Button, Text } from 'folds'; -import { IIdentityProvider, SSOAction, createClient } from 'matrix-js-sdk'; -import React, { useMemo } from 'react'; +import { IdentityProvider } from 'neurone/lib/api/types'; +import React from 'react'; import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import { mxcToHttp } from '../../../client/mxcToHttp'; + +const UNSTABLE_ACTION_PARAM = 'org.matrix.msc3824.action'; +type SSOAction = 'login' | 'register'; + +function getSsoLoginUrl( + baseUrl: string, + redirectUrl: string, + idpId?: string, + action?: SSOAction +): string { + let path = '_matrix/client/v3/login/sso/redirect'; + if (idpId) { + path += `/${idpId}`; + } + const url = new URL(path, baseUrl); + + url.searchParams.append('redirectUrl', redirectUrl); + if (action) { + url.searchParams.append(UNSTABLE_ACTION_PARAM, action); + } + + return url.href; +} type SSOLoginProps = { - providers?: IIdentityProvider[]; + providers?: IdentityProvider[]; redirectUrl: string; action?: SSOAction; saveScreenSpace?: boolean; @@ -12,14 +36,15 @@ type SSOLoginProps = { export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); const getSSOIdUrl = (ssoId?: string): string => - mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action); + getSsoLoginUrl(baseUrl, redirectUrl, ssoId, action); const withoutIcon = providers ? providers.find( - (provider) => !provider.icon || !mx.mxcUrlToHttp(provider.icon, 96, 96, 'crop', false) + ({ icon }) => + !icon || + !mxcToHttp(baseUrl, icon, false).thumbnail({ width: 96, height: 96, method: 'crop' }) ) : true; @@ -30,7 +55,13 @@ export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SS {providers ? ( providers.map((provider) => { const { id, name, icon } = provider; - const iconUrl = icon && mx.mxcUrlToHttp(icon, 96, 96, 'crop', false); + const iconUrl = + icon && + mxcToHttp(baseUrl, icon, false).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }); const buttonTitle = `Continue with ${name}`; @@ -45,7 +76,7 @@ export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SS size="300" radii="300" > - + ); } diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 2f04a733..e6fad6a0 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; import { Box, Text, color } from 'folds'; import { Link, useSearchParams } from 'react-router-dom'; -import { SSOAction } from 'matrix-js-sdk'; import { useAuthFlows } from '../../../hooks/useAuthFlows'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows'; @@ -52,7 +51,7 @@ export function Login() { ); } - const parsedFlows = useParsedLoginFlows(loginFlows.flows); + const parsedFlows = useParsedLoginFlows(loginFlows.flows ?? []); return ( @@ -77,7 +76,7 @@ export function Login() { diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 62f46dd2..89204a89 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -19,24 +19,18 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { Link } from 'react-router-dom'; -import { MatrixError } from 'matrix-js-sdk'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; import { EMAIL_REGEX } from '../../../utils/regex'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { useClientConfig } from '../../../hooks/useClientConfig'; -import { - CustomLoginResponse, - LoginError, - factoryGetBaseUrl, - login, - useLoginComplete, -} from './loginUtil'; +import { LoginError, factoryGetBaseUrl, login, useLoginComplete } from './loginUtil'; import { PasswordInput } from '../../../components/password-input'; import { FieldError } from '../FiledError'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; +import { getInitialDeviceDisplayName } from '../../../utils/user-agent'; function UsernameHint({ server }: { server: string }) { const [anchor, setAnchor] = useState(); @@ -117,13 +111,11 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; - const [loginState, startLogin] = useAsyncCallback< - CustomLoginResponse, - MatrixError, - Parameters - >(useCallback(login, [])); + const [loginState, startLogin] = useAsyncCallback(useCallback(login, [])); + const [loginError, customLoginResponse] = + loginState.status === AsyncStatus.Success ? loginState.data : []; - useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); + useLoginComplete(customLoginResponse); const handleUsernameLogin = (username: string, password: string) => { startLogin(baseUrl, { @@ -133,7 +125,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: username, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: getInitialDeviceDisplayName(), }); }; @@ -151,7 +143,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: mxIdUsername, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: getInitialDeviceDisplayName(), }); }; const handleEmailLogin = (email: string, password: string) => { @@ -163,7 +155,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog address: email, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: getInitialDeviceDisplayName(), }); }; @@ -212,12 +204,12 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog outlined after={} /> - {loginState.status === AsyncStatus.Error && ( + {loginError && ( <> - {loginState.error.errcode === LoginError.ServerNotAllowed && ( + {loginError === LoginError.ServerNotAllowed && ( )} - {loginState.error.errcode === LoginError.InvalidServer && ( + {loginError === LoginError.InvalidServer && ( )} @@ -229,25 +221,34 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog - {loginState.status === AsyncStatus.Error && ( + {loginError && ( <> - {loginState.error.errcode === LoginError.Forbidden && ( + {loginError === LoginError.Forbidden && ( )} - {loginState.error.errcode === LoginError.UserDeactivated && ( + {loginError === LoginError.UserDeactivated && ( )} - {loginState.error.errcode === LoginError.InvalidRequest && ( + {loginError === LoginError.InvalidRequest && ( )} - {loginState.error.errcode === LoginError.RateLimited && ( + {loginError === LoginError.RateLimited && ( )} - {loginState.error.errcode === LoginError.Unknown && ( + {loginError === LoginError.Unknown && ( )} )} + {loginState.status === AsyncStatus.Error && ( + + )} Forget Password? @@ -262,9 +263,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog } > diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 761d5dc5..33f73e62 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -11,10 +11,10 @@ import { config, } from 'folds'; import React, { useCallback, useEffect } from 'react'; -import { MatrixError } from 'matrix-js-sdk'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil'; +import { LoginError, login, useLoginComplete } from './loginUtil'; +import { getInitialDeviceDisplayName } from '../../../utils/user-agent'; function LoginTokenError({ message }: { message: string }) { return ( @@ -47,44 +47,55 @@ export function TokenLogin({ token }: TokenLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; - const [loginState, startLogin] = useAsyncCallback< - CustomLoginResponse, - MatrixError, - Parameters - >(useCallback(login, [])); + const [loginState, startLogin] = useAsyncCallback(useCallback(login, [])); useEffect(() => { startLogin(baseUrl, { type: 'm.login.token', token, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: getInitialDeviceDisplayName(), }); }, [baseUrl, token, startLogin]); - useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); + const [loginError, customLoginResponse] = + loginState.status === AsyncStatus.Success ? loginState.data : []; + + useLoginComplete(customLoginResponse); return ( <> - {loginState.status === AsyncStatus.Error && ( + {loginError && ( <> - {loginState.error.errcode === LoginError.Forbidden && ( + {loginError === LoginError.Forbidden && ( )} - {loginState.error.errcode === LoginError.UserDeactivated && ( + {loginError === LoginError.UserDeactivated && ( )} - {loginState.error.errcode === LoginError.InvalidRequest && ( + {loginError === LoginError.InvalidRequest && ( )} - {loginState.error.errcode === LoginError.RateLimited && ( + {loginError === LoginError.RateLimited && ( )} - {loginState.error.errcode === LoginError.Unknown && ( + {loginError === LoginError.Unknown && ( )} )} - }> + {loginState.status === AsyncStatus.Error && ( + + )} + } + > diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index ba5b0bc9..0922572a 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -1,7 +1,8 @@ import to from 'await-to-js'; -import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk'; import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { postLogin, PostLoginBody, PostLoginOK } from 'neurone/lib/api/matrix/client/v3/login'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig'; import { autoDiscovery, specVersions } from '../../../cs-api'; import { ErrorCode } from '../../../cs-errorcode'; @@ -11,6 +12,7 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; +import { getJSONRequest } from '../../../../client/request'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -55,57 +57,53 @@ export enum LoginError { export type CustomLoginResponse = { baseUrl: string; - response: LoginResponse; + response: PostLoginOK; }; export const login = async ( serverBaseUrl: string | (() => Promise), - data: LoginRequest -): Promise => { + body: PostLoginBody +): Promise<[LoginError, undefined] | [undefined, CustomLoginResponse]> => { const [urlError, url] = typeof serverBaseUrl === 'function' ? await to(serverBaseUrl()) : [undefined, serverBaseUrl]; + if (urlError) { - throw new MatrixError({ - errcode: - urlError.message === GetBaseUrlError.NotAllow - ? LoginError.ServerNotAllowed - : LoginError.InvalidServer, - }); + return [ + urlError.message === GetBaseUrlError.NotAllow + ? LoginError.ServerNotAllowed + : LoginError.InvalidServer, + undefined, + ]; } - const mx = createClient({ baseUrl: url }); - const [err, res] = await to(mx.loginRequest(data)); + const requestJSON = getJSONRequest(url); + const postLoginEndpoint = postLogin({ body }); + const response = await requestJSON(postLoginEndpoint); + const [loginError, loginResult] = await parseMatrixResp(response); - if (err) { - if (err.httpStatus === 400) { - throw new MatrixError({ - errcode: LoginError.InvalidRequest, - }); + if (loginError) { + if (loginError.status === 400) { + return [LoginError.InvalidRequest, undefined]; } - if (err.httpStatus === 429) { - throw new MatrixError({ - errcode: LoginError.RateLimited, - }); + if (loginError.status === 429) { + return [LoginError.RateLimited, undefined]; } - if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { - throw new MatrixError({ - errcode: LoginError.UserDeactivated, - }); + if (loginError.data.errcode === ErrorCode.M_USER_DEACTIVATED) { + return [LoginError.UserDeactivated, undefined]; } - if (err.httpStatus === 403) { - throw new MatrixError({ - errcode: LoginError.Forbidden, - }); + if (loginError.status === 403) { + return [LoginError.Forbidden, undefined]; } - throw new MatrixError({ - errcode: LoginError.Unknown, - }); + return [LoginError.Unknown, undefined]; } - return { - baseUrl: url, - response: res, - }; + return [ + undefined, + { + baseUrl: url, + response: loginResult, + }, + ]; }; export const useLoginComplete = (data?: CustomLoginResponse) => { diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index 9f173422..30667963 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -10,22 +10,17 @@ import { Text, color, } from 'folds'; -import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; -import { - AuthDict, - AuthType, - IAuthData, - MatrixError, - RegisterRequest, - UIAFlow, - createClient, -} from 'matrix-js-sdk'; +import React, { ChangeEventHandler, useCallback, useState } from 'react'; +import { PostRegisterBody } from 'neurone/lib/api/matrix/client/v3/register'; import { PasswordInput } from '../../../components/password-input'; import { + AuthResponse, + AuthType, getLoginTermUrl, getUIAFlowForStages, hasStageInFlows, requiredStageInFlows, + UIAFlow, } from '../../../utils/matrix-uia'; import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows'; import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; @@ -42,7 +37,9 @@ import { import { useRegisterEmail } from '../../../hooks/useRegisterEmail'; import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch'; import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay'; -import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../../hooks/types'; +import { RequestEmailTokenCallback, RequestEmailTokenResult } from '../../../hooks/types'; +import { generateClientSecret } from '../../../utils/randomString'; +import { getInitialDeviceDisplayName } from '../../../utils/user-agent'; export const SUPPORTED_REGISTER_STAGES = [ AuthType.RegistrationToken, @@ -84,10 +81,10 @@ const pickStages = (uiaFlows: UIAFlow[], formData: FormData): string[] => { type RegisterUIAFlowProps = { formData: FormData; flow: UIAFlow; - authData: IAuthData; - registerEmailState: AsyncState; + authData: AuthResponse; + registerEmailState: AsyncState; registerEmail: RequestEmailTokenCallback; - onRegister: (registerReqData: RegisterRequest) => void; + onRegister: (registerReqData: PostRegisterBody) => void; }; function RegisterUIAFlow({ formData, @@ -103,13 +100,13 @@ function RegisterUIAFlow({ const stageToComplete = getStageToComplete(); const handleAuthDict = useCallback( - (authDict: AuthDict) => { + (authDict: object) => { const { password, username } = formData; onRegister({ auth: authDict, password, username, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: getInitialDeviceDisplayName(), }); }, [onRegister, formData] @@ -171,7 +168,7 @@ function RegisterUIAFlow({ } type PasswordRegisterFormProps = { - authData: IAuthData; + authData: AuthResponse; uiaFlows: UIAFlow[]; defaultUsername?: string; defaultEmail?: string; @@ -186,23 +183,24 @@ export function PasswordRegisterForm({ }: PasswordRegisterFormProps) { const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const params = useUIAParams(authData); const termUrl = getLoginTermUrl(params); const [formData, setFormData] = useState(); const [ongoingFlow, setOngoingFlow] = useState(); - const [registerEmailState, registerEmail] = useRegisterEmail(mx); + const [registerEmailState, registerEmail] = useRegisterEmail(baseUrl); const [registerState, handleRegister] = useAsyncCallback< RegisterResult, - MatrixError, - [RegisterRequest] - >(useCallback(async (registerReqData) => register(mx, registerReqData), [mx])); - const [ongoingAuthData, customRegisterResp] = + Error, + [PostRegisterBody] + >(useCallback(async (registerReqData) => register(baseUrl, registerReqData), [baseUrl])); + const [ongoingAuthData, registerError, customRegisterResp] = registerState.status === AsyncStatus.Success ? registerState.data : []; - const registerError = + + const registerRequestError = registerState.status === AsyncStatus.Error ? registerState.error : undefined; useRegisterComplete(customRegisterResp); @@ -238,8 +236,9 @@ export function PasswordRegisterForm({ token, email, terms, - clientSecret: mx.generateClientSecret(), + clientSecret: generateClientSecret(), }; + const pickedStages = pickStages(uiaFlows, fData); const pickedFlow = getUIAFlowForStages(uiaFlows, pickedStages); setOngoingFlow(pickedFlow); @@ -250,7 +249,7 @@ export function PasswordRegisterForm({ auth: { session: authData.session, }, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: getInitialDeviceDisplayName(), }); }; @@ -269,13 +268,13 @@ export function PasswordRegisterForm({ outlined required /> - {registerError?.errcode === RegisterError.UserTaken && ( + {registerError?.error === RegisterError.UserTaken && ( )} - {registerError?.errcode === RegisterError.UserInvalid && ( + {registerError?.error === RegisterError.UserInvalid && ( )} - {registerError?.errcode === RegisterError.UserExclusive && ( + {registerError?.error === RegisterError.UserExclusive && ( )} @@ -295,18 +294,18 @@ export function PasswordRegisterForm({ outlined required /> - {registerError?.errcode === RegisterError.PasswordWeak && ( + {registerError?.error === RegisterError.PasswordWeak && ( )} - {registerError?.errcode === RegisterError.PasswordShort && ( + {registerError?.error === RegisterError.PasswordShort && ( @@ -376,17 +375,22 @@ export function PasswordRegisterForm({ )} - {registerError?.errcode === RegisterError.RateLimited && ( + {registerError?.error === RegisterError.RateLimited && ( )} - {registerError?.errcode === RegisterError.Forbidden && ( + {registerError?.error === RegisterError.Forbidden && ( )} - {registerError?.errcode === RegisterError.InvalidRequest && ( + {registerError?.error === RegisterError.InvalidRequest && ( )} - {registerError?.errcode === RegisterError.Unknown && ( - + {registerError?.error === RegisterError.Unknown && ( + + )} + {registerRequestError && ( + )} + + +
+ )} +
+ ); +} + +export function InitialSyncSplashScreen({ + sync, + children, +}: { + sync: SyncContext; + children: ReactNode; +}) { + const initialSync = useObservableValue(sync.controller.initialSync); + + if (initialSync) { + return ; + } + + return children; +} diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index a2e1b682..b275f6f3 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -44,7 +44,7 @@ export function SidebarNav() { - + {/* */} diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx index 952885cd..1397ac98 100644 --- a/src/app/pages/client/SpecVersions.tsx +++ b/src/app/pages/client/SpecVersions.tsx @@ -1,5 +1,5 @@ import React, { ReactNode } from 'react'; -import { Box, Dialog, config, Text, Button, Spinner } from 'folds'; +import { Box, Dialog, config, Text, Button } from 'folds'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsProvider } from '../../hooks/useSpecVersions'; import { SplashScreen } from '../../components/splash-screen'; @@ -8,14 +8,7 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: return ( ( - - - - Connecting to server - - - )} + fallback={() => Looking for server...} />} error={(err, retry, ignore) => ( diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 9cd4b0b2..048da428 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,85 +1,130 @@ -import { MatrixClient, SyncState } from 'matrix-js-sdk'; -import React, { useCallback, useState } from 'react'; -import { Box, config, Line, Text } from 'folds'; -import { useSyncState } from '../../hooks/useSyncState'; +import React, { ReactNode, useCallback, useEffect, useState } from 'react'; +import { Box, config, ContainerColor as ContainerColorType, Line, Text } from 'folds'; import { ContainerColor } from '../../styles/ContainerColor.css'; +import { SyncValue } from '../../../client/sync'; +import { useClient } from '../../hooks/useClient'; +import { getAttemptDelaySec, useRecoveryAttempt } from '../../hooks/useRecoveryAttempt'; +import { useSecondsCountdown } from '../../hooks/useSecondsCountdown'; +import { useNetworkOnlineCallback } from '../../hooks/useNetwork'; +type StatusBarProps = { + variant: ContainerColorType; + children: ReactNode; +}; +function StatusBar({ variant, children }: StatusBarProps) { + return ( + + + {children} + + + + ); +} + +function RecoverCountdownMessage({ + afterSeconds, + onEnd, +}: { + afterSeconds: number; + onEnd: () => void; +}) { + const countdownSec = useSecondsCountdown(afterSeconds, onEnd); + + return Reconnecting after {countdownSec} seconds...; +} + +// undefined | OK | undefined | Error | undefined | OK | undefined | OK +// undefined | undefined | OK | undefined | Error | undefined | OK | undefined type StateData = { - current: SyncState | null; - previous: SyncState | null | undefined; + current: SyncValue; + previous: SyncValue; }; -type SyncStatusProps = { - mx: MatrixClient; -}; -export function SyncStatus({ mx }: SyncStatusProps) { +export function SyncStatus() { + const ct = useClient(); + const [stateData, setStateData] = useState({ - current: null, + current: ct.sync.observable.read, previous: undefined, }); - useSyncState( - mx, - useCallback((current, previous) => { - setStateData((s) => { - if (s.current === current && s.previous === previous) { - return s; - } - return { current, previous }; - }); - }, []) - ); + useEffect(() => { + const unsubscribe = ct.sync.observable.subscribe((syncValue) => { + setStateData((data) => ({ + current: syncValue, + previous: data.current, + })); + }); - if ( - (stateData.current === SyncState.Prepared || - stateData.current === SyncState.Syncing || - stateData.current === SyncState.Catchup) && - stateData.previous !== SyncState.Syncing - ) { + return () => { + unsubscribe(); + }; + }, [ct]); + + const [previousError] = stateData.previous ?? []; + const [currentError, currentResult] = stateData.current ?? []; + if (currentError) { + console.error(currentError); + } + + const connecting = stateData.previous === undefined && stateData.current === undefined; + const recovering = previousError && stateData.current === undefined; + + const recoverAttempt = useRecoveryAttempt(!!currentError, !!currentResult); + + const handleRecover = useCallback(() => ct.sync.controller.recover(), [ct]); + + useNetworkOnlineCallback(handleRecover); + + if (connecting) { return ( - - - Connecting... + + + + Connecting... + - - + ); } - if (stateData.current === SyncState.Reconnecting) { + if (recovering) { return ( - - - Connection Lost! Reconnecting... + + + + Connection Lost! Reconnecting... + - - + ); } - if (stateData.current === SyncState.Error) { + if (currentError) { return ( - - - Connection Lost! + + + + Connection Lost!{' '} + + + handleRecover()} + > + + Retry + + - - + ); } diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 04edf109..498faa7f 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; import { useNavigate } from 'react-router-dom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { factoryRoomIdByActivity } from '../../../utils/sort'; +import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; import { NavButton, NavCategory, @@ -30,18 +30,15 @@ import { NavItemContent, } from '../../../components/nav'; import { getDirectCreatePath, 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 { 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 { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useRoomsUnread } from '../../../state/hooks/unread'; import { markAsRead } from '../../../utils/notifications'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; @@ -51,19 +48,30 @@ import { useRoomsNotificationPreferencesContext, } from '../../../hooks/useRoomsNotificationPreferences'; import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; +import { roomToNotificationsAtom } from '../../../state/room/roomToNotifications'; +import { + getRoomTotalNotifications, + useRoomsNotifications, + useRoomsTotalNotifications, +} from '../../../state/hooks/roomNotifications'; +import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros'; +import { useGetRoomName } from '../../../hooks/storage/useGetRoomName'; +import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar'; +import { useObservableValue } from '../../../hooks/useObservableValue'; +import { useClient } from '../../../hooks/useClient'; type DirectMenuProps = { requestClose: () => void; }; const DirectMenu = forwardRef(({ requestClose }, ref) => { - const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const orphanRooms = useDirectRooms(); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const directRooms = useDirectRooms(); + const roomsNotifications = useRoomsNotifications(directRooms, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomsNotifications); const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + // if (!unread) return; + // directRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; @@ -75,7 +83,7 @@ const DirectMenu = forwardRef(({ requestClose } size="300" after={} radii="300" - aria-disabled={!unread} + aria-disabled={!notifications || notifications.count === 0} > Mark as Read @@ -169,12 +177,18 @@ function DirectEmpty() { const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); export function Direct() { - const mx = useMatrixClient(); + const ct = useClient(); useNavToActivePathMapper('direct'); const scrollRef = useRef(null); const directs = useDirectRooms(); + + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(directs); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const roomToNotifications = useRoomsNotifications(directs, roomToNotificationsAtom); + const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); const createDirectSelected = useDirectCreateSelected(); @@ -184,12 +198,20 @@ export function Direct() { const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const sortedDirects = useMemo(() => { - const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); + // TODO: sort by activity if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); + return directs + .filter((roomId) => { + if (roomId === selectedRoomId) return true; + const notification = roomToNotifications.get(roomId); + if (!notification) return false; + const total = getRoomTotalNotifications(notification); + return total.count > 0; + }) + .sort(factoryRoomIdByAtoZ(getRoomName)); } - return items; - }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); + return Array.from(directs).sort(factoryRoomIdByAtoZ(getRoomName)); + }, [directs, closedCategories, roomToNotifications, selectedRoomId, getRoomName]); const virtualizer = useVirtualizer({ count: sortedDirects.length, @@ -246,9 +268,9 @@ export function Direct() { > {virtualizer.getVirtualItems().map((vItem) => { const roomId = sortedDirects[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; const selected = selectedRoomId === roomId; + const n = roomToNotifications.get(roomId); + const notifications = n && getRoomTotalNotifications(n); return ( ); diff --git a/src/app/pages/client/direct/DirectCreate.tsx b/src/app/pages/client/direct/DirectCreate.tsx index 3deb0b6d..5ee4d4df 100644 --- a/src/app/pages/client/direct/DirectCreate.tsx +++ b/src/app/pages/client/direct/DirectCreate.tsx @@ -1,11 +1,9 @@ import React, { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Box, Icon, IconButton, Icons, Scroll } from 'folds'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getDirectCreateSearchParams } from '../../pathSearchParam'; import { getDirectRoomPath } from '../../pathUtils'; -import { getDMRoomFor } from '../../../utils/matrix'; -import { useDirectRooms } from './useDirectRooms'; +import { getUserDirectRooms } from '../../../utils/matrix'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { Page, @@ -17,25 +15,25 @@ import { } from '../../../components/page'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { CreateChat } from '../../../features/create-chat'; +import { useClient } from '../../../hooks/useClient'; export function DirectCreate() { - const mx = useMatrixClient(); + const ct = useClient(); const screenSize = useScreenSizeContext(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { userId } = getDirectCreateSearchParams(searchParams); - const directs = useDirectRooms(); - useEffect(() => { if (userId) { - const roomId = getDMRoomFor(mx, userId)?.roomId; - if (roomId && directs.includes(roomId)) { + const directRooms = getUserDirectRooms(ct, userId); + if (directRooms.length > 0) { + const roomId = directRooms[0]; navigate(getDirectRoomPath(roomId), { replace: true }); } } - }, [mx, navigate, directs, userId]); + }, [ct, navigate, userId]); return ( diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 7c26ec54..f839838c 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -2,24 +2,21 @@ import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useDirectRooms } from './useDirectRooms'; export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { - const mx = useMatrixClient(); const rooms = useDirectRooms(); const { roomIdOrAlias, eventId } = useParams(); const roomId = useSelectedRoom(); - const room = mx.getRoom(roomId); - if (!room || !rooms.includes(room.roomId)) { + if (!roomId || !rooms.includes(roomId)) { return ; } return ( - + {children} ); diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts index 00009927..abe8eb07 100644 --- a/src/app/pages/client/direct/useDirectRooms.ts +++ b/src/app/pages/client/direct/useDirectRooms.ts @@ -1,12 +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'; +import { useClient } from '../../../hooks/useClient'; export const useDirectRooms = () => { - const mx = useMatrixClient(); + const ct = useClient(); const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); + const directs = useDirects(ct, allRoomsAtom, mDirects); return directs; }; diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index dae83166..fb9b71c1 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -18,6 +18,8 @@ import { color, config, } from 'folds'; +import { getPublicRooms } from 'neurone/lib/api/matrix/client/v3/publicRooms'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { NavCategory, NavCategoryHeader, @@ -31,21 +33,38 @@ 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'; import { stopPropagation } from '../../../utils/keyboard'; +import { useClient } from '../../../hooks/useClient'; export function AddServer() { - const mx = useMatrixClient(); + const ct = useClient(); const navigate = useNavigate(); const [dialog, setDialog] = useState(false); const serverInputRef = useRef(null); const [exploreState] = useAsyncCallback( - useCallback((server: string) => mx.publicRooms({ server, limit: 1 }), [mx]) + useCallback( + async (server: string) => { + const response = await ct.requestAuthJSON( + getPublicRooms({ + searchParams: { + server, + limit: 1, + }, + }) + ); + const [err, result] = await parseMatrixResp(response); + if (err) { + throw new Error(printMatrixError(err)); + } + return result; + }, + [ct] + ) ); const getInputServer = (): string | undefined => { @@ -156,9 +175,9 @@ export function AddServer() { } export function Explore() { - const mx = useMatrixClient(); + const ct = useClient(); useNavToActivePathMapper('explore'); - const userId = mx.getUserId(); + const { userId } = ct.session; const clientConfig = useClientConfig(); const userServer = userId ? getMxIdServer(userId) : undefined; const servers = diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index d87db963..ca5119c9 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -18,7 +18,7 @@ import { import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import FocusTrap from 'focus-trap-react'; -import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; +import { factoryRoomIdByAtoZ } from '../../../utils/sort'; import { NavButton, NavCategory, @@ -37,23 +37,18 @@ import { getHomeSearchPath, withSearchParam, } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useHomeCreateSelected, 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 { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; @@ -65,6 +60,18 @@ import { import { UseStateProvider } from '../../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../../components/join-address-prompt'; import { _RoomSearchParams } from '../../paths'; +import { useClient } from '../../../hooks/useClient'; +import { useGetRoomName } from '../../../hooks/storage/useGetRoomName'; +import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros'; +import { useObservableValue } from '../../../hooks/useObservableValue'; +import { + getRoomTotalNotifications, + useRoomsNotifications, + useRoomsTotalNotifications, +} from '../../../state/hooks/roomNotifications'; +import { roomToNotificationsAtom } from '../../../state/room/roomToNotifications'; +import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar'; +import { unreadRoomsAtom } from '../../../state/room/roomToUnread'; type HomeMenuProps = { requestClose: () => void; @@ -72,12 +79,12 @@ type HomeMenuProps = { const HomeMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useHomeRooms(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); + const roomsNotifications = useRoomsNotifications(orphanRooms, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomsNotifications); const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + // if (!unread) return; + // orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; @@ -89,7 +96,7 @@ const HomeMenu = forwardRef(({ requestClose }, re size="300" after={} radii="300" - aria-disabled={!unread} + aria-disabled={!notifications || notifications.count === 0} > Mark as Read @@ -195,12 +202,19 @@ function HomeEmpty() { const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); export function Home() { - const mx = useMatrixClient(); + const ct = useClient(); useNavToActivePathMapper('home'); const scrollRef = useRef(null); const rooms = useHomeRooms(); + + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(rooms); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const roomToNotifications = useRoomsNotifications(rooms, roomToNotificationsAtom); + const unreadRooms = useAtomValue(unreadRoomsAtom); + const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); const selectedRoomId = useSelectedRoom(); @@ -210,16 +224,20 @@ export function Home() { 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); + // TODO: sort by activity + return rooms + .filter((roomId) => { + if (roomId === selectedRoomId) return true; + const notification = roomToNotifications.get(roomId); + if (!notification) return false; + const total = getRoomTotalNotifications(notification); + return total.count > 0; + }) + .sort(factoryRoomIdByAtoZ(getRoomName)); } - return items; - }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); + return Array.from(rooms).sort(factoryRoomIdByAtoZ(getRoomName)); + }, [getRoomName, rooms, closedCategories, roomToNotifications, selectedRoomId]); const virtualizer = useVirtualizer({ count: sortedRooms.length, @@ -330,9 +348,9 @@ export function Home() { > {virtualizer.getVirtualItems().map((vItem) => { const roomId = sortedRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; const selected = selectedRoomId === roomId; + const n = roomToNotifications.get(roomId); + const notifications = n && getRoomTotalNotifications(n); return ( ); diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 4e16f797..6195e607 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -2,21 +2,18 @@ import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useHomeRooms } from './useHomeRooms'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { - const mx = useMatrixClient(); const rooms = useHomeRooms(); const { roomIdOrAlias, eventId } = useParams(); const viaServers = useSearchParamsViaServers(); const roomId = useSelectedRoom(); - const room = mx.getRoom(roomId); - if (!room || !rooms.includes(room.roomId)) { + if (!roomId || !rooms.includes(roomId)) { return ( + {children} ); diff --git a/src/app/pages/client/home/useHomeRooms.ts b/src/app/pages/client/home/useHomeRooms.ts index b0181b08..3b1658ca 100644 --- a/src/app/pages/client/home/useHomeRooms.ts +++ b/src/app/pages/client/home/useHomeRooms.ts @@ -1,14 +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'; +import { useClient } from '../../../hooks/useClient'; export const useHomeRooms = () => { - const mx = useMatrixClient(); + const ct = useClient(); const mDirects = useAtomValue(mDirectAtom); const roomToParents = useAtomValue(roomToParentsAtom); - const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); + const rooms = useOrphanRooms(ct, allRoomsAtom, mDirects, roomToParents); return rooms; }; diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index dcd44e2f..c39a77f1 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -3,13 +3,7 @@ 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, @@ -25,50 +19,64 @@ import { markAsRead } from '../../../utils/notifications'; import { stopPropagation } from '../../../utils/keyboard'; import { settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; +import { + TotalNotifications, + useRoomsNotifications, + useRoomsTotalNotifications, +} from '../../../state/hooks/roomNotifications'; +import { + RoomToNotifications, + roomToNotificationsAtom, +} from '../../../state/room/roomToNotifications'; type DirectMenuProps = { + directRooms: string[]; + roomToNotifications: RoomToNotifications; + notifications: TotalNotifications; requestClose: () => void; }; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useDirectRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); +const DirectMenu = forwardRef( + ({ directRooms, roomToNotifications, notifications, requestClose }, ref) => { + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; + const handleMarkAsRead = () => { + directRooms.forEach((rId) => { + const n = roomToNotifications.get(rId); + if (n?.notification_count && n.notification_count > 0) { + // markAsRead(mx, rId, hideActivity) + } + }); + requestClose(); + }; - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); + return ( + + + } + radii="300" + aria-disabled={notifications.count === 0} + > + + Mark as Read + + + + + ); + } +); 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 directRooms = useDirectRooms(); + const roomsNotifications = useRoomsNotifications(directRooms, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomsNotifications); const [menuAnchor, setMenuAnchor] = useState(); const directSelected = useDirectSelected(); @@ -106,9 +114,9 @@ export function DirectTab() { )} - {directUnread && ( - 0}> - 0} count={directUnread.total} /> + {notifications.count > 0 && ( + 0}> + )} {menuAnchor && ( @@ -128,7 +136,12 @@ export function DirectTab() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} /> + setMenuAnchor(undefined)} + /> } /> diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx index a45b5830..0fe3b879 100644 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ b/src/app/pages/client/sidebar/ExploreTab.tsx @@ -11,13 +11,13 @@ import { 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'; +import { useClient } from '../../../hooks/useClient'; export function ExploreTab() { - const mx = useMatrixClient(); + const ct = useClient(); const screenSize = useScreenSizeContext(); const clientConfig = useClientConfig(); const navigate = useNavigate(); @@ -41,7 +41,7 @@ export function ExploreTab() { navigate(getExploreFeaturedPath()); return; } - const userId = mx.getUserId(); + const { userId } = ct.session; const userServer = userId ? getMxIdServer(userId) : undefined; if (userServer) { navigate(getExploreServerPath(userServer)); diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index 778b729e..5b7b1a46 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -3,14 +3,7 @@ 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, @@ -26,51 +19,64 @@ import { markAsRead } from '../../../utils/notifications'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { + TotalNotifications, + useRoomsNotifications, + useRoomsTotalNotifications, +} from '../../../state/hooks/roomNotifications'; +import { + RoomToNotifications, + roomToNotificationsAtom, +} from '../../../state/room/roomToNotifications'; type HomeMenuProps = { + homeRooms: string[]; + roomToNotifications: RoomToNotifications; + notifications: TotalNotifications; requestClose: () => void; }; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); +const HomeMenu = forwardRef( + ({ homeRooms, roomToNotifications, notifications, requestClose }, ref) => { + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; + const handleMarkAsRead = () => { + homeRooms.forEach((rId) => { + const n = roomToNotifications.get(rId); + if (n?.notification_count && n.notification_count > 0) { + // markAsRead(mx, rId, hideActivity) + } + }); + requestClose(); + }; - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); + return ( + + + } + radii="300" + aria-disabled={notifications.count === 0} + > + + Mark as Read + + + + + ); + } +); 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 homeRooms = useHomeRooms(); + const roomsNotifications = useRoomsNotifications(homeRooms, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomsNotifications); const homeSelected = useHomeSelected(); const [menuAnchor, setMenuAnchor] = useState(); @@ -108,9 +114,9 @@ export function HomeTab() { )} - {homeUnread && ( - 0}> - 0} count={homeUnread.total} /> + {notifications.count > 0 && ( + 0}> + )} {menuAnchor && ( @@ -130,7 +136,12 @@ export function HomeTab() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} /> + setMenuAnchor(undefined)} + /> } /> diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index bb212184..f9c0ef6e 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -2,25 +2,30 @@ import React, { useState } from 'react'; import { Text } from 'folds'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { getMxIdLocalPart } from '../../../utils/matrix'; import { nameInitials } from '../../../utils/common'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { Settings } from '../../../features/settings'; import { useUserProfile } from '../../../hooks/useUserProfile'; import { Modal500 } from '../../../components/Modal500'; +import { useClient } from '../../../hooks/useClient'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; export function SettingsTab() { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const { userId, baseUrl } = ct.session; const profile = useUserProfile(userId); const [settings, setSettings] = useState(false); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl - ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined + const displayName = profile?.displayName ?? getMxIdLocalPart(userId) ?? userId; + const avatarUrl = profile?.avatarUrl + ? mxcToHttp(baseUrl, profile.avatarUrl, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; const openSettings = () => setSettings(true); diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 6fc528a1..18736eb5 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -25,7 +25,8 @@ import { toRem, } from 'folds'; import { useAtom, useAtomValue } from 'jotai'; -import { Room } from 'matrix-js-sdk'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; +import { AccountDataEvent } from 'neurone/lib/api/types'; import { draggable, dropTargetForElements, @@ -43,8 +44,8 @@ import { useOrphanSpaces, useRecursiveChildScopeFactory, useSpaceChildren, + useSpacesChildren, } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; @@ -58,34 +59,31 @@ import { 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, isRoomAlias } from '../../../utils/matrix'; import { RoomAvatar } from '../../../components/room-avatar'; import { nameInitials, randomStr } from '../../../utils/common'; import { ISidebarFolder, + InCinnySpacesContent, SidebarItems, TSidebarItem, makeCinnySpacesContent, parseSidebar, sidebarItemWithout, useSidebarItems, + useSidebarRooms, } from '../../../hooks/useSidebarItems'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { AccountDataType } from '../../../../types/matrix/accountData'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { usePowerLevels, usePowerLevelsState } from '../../../hooks/usePowerLevels'; import { markAsRead } from '../../../utils/notifications'; import { copyToClipboard } from '../../../utils/dom'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; -import { getRoomAvatarUrl } from '../../../utils/room'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -93,47 +91,53 @@ import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { useClient } from '../../../hooks/useClient'; +import { useAccountData } from '../../../hooks/storage/useAccountData'; +import { GetRoomNameCallback, useGetRoomName } from '../../../hooks/storage/useGetRoomName'; +import { + TotalNotifications, + useRoomsNotifications, + useRoomsTotalNotifications, +} from '../../../state/hooks/roomNotifications'; +import { roomToNotificationsAtom } from '../../../state/room/roomToNotifications'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; +import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros'; +import { GetRoomAvatarCallback, useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar'; type SpaceMenuProps = { - room: Room; + spaceId: string; requestClose: () => void; onUnpin?: (roomId: string) => void; + notifications?: TotalNotifications; }; const SpaceMenu = forwardRef( - ({ room, requestClose, onUnpin }, ref) => { - const mx = useMatrixClient(); + ({ spaceId, requestClose, onUnpin, notifications }, ref) => { + const ct = useClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); + const powerLevelState = usePowerLevelsState(spaceId); + const powerLevels = usePowerLevels(powerLevelState.value); + const creators = useRoomCreators(spaceId); const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); + const canInvite = permissions.action('invite', ct.session.userId); const openSpaceSettings = useOpenSpaceSettings(); const [invitePrompt, setInvitePrompt] = useState(false); - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + // allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; const handleUnpin = () => { - onUnpin?.(room.roomId); + onUnpin?.(spaceId); requestClose(); }; const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + const alias = ct.roomToAlias.read.get(spaceId); + const viaServers = alias ? undefined : getViaServers(creators, powerLevels); + copyToClipboard(getMatrixToRoom(alias ?? spaceId, viaServers)); requestClose(); }; @@ -142,15 +146,15 @@ const SpaceMenu = forwardRef( }; const handleRoomSettings = () => { - openSpaceSettings(room.roomId); + openSpaceSettings(spaceId); requestClose(); }; return ( - {invitePrompt && room && ( + {invitePrompt && ( { setInvitePrompt(false); requestClose(); @@ -163,7 +167,7 @@ const SpaceMenu = forwardRef( size="300" after={} radii="300" - disabled={!unread} + disabled={!notifications || notifications.count === 0} > Mark as Read @@ -383,25 +387,47 @@ const useDnDMonitor = ( }; type SpaceTabProps = { - space: Room; + spaceId: string; selected: boolean; onClick: MouseEventHandler; + getName: GetRoomNameCallback; + getAvatar: GetRoomAvatarCallback; folder?: ISidebarFolder; onDragging: (dragItem?: SidebarDraggable) => void; disabled?: boolean; onUnpin?: (roomId: string) => void; }; function SpaceTab({ - space, + spaceId, selected, + getName, + getAvatar, onClick, folder, onDragging, disabled, onUnpin, }: SpaceTabProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); + + const roomToParents = useAtomValue(roomToParentsAtom); + const childRooms = useSpaceChildren( + allRoomsAtom, + spaceId, + useRecursiveChildScopeFactory(ct, roomToParents) + ); + const roomsNotifications = useRoomsNotifications(childRooms, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomsNotifications); + + const spaceName = getName(spaceId) ?? spaceId; + const avatarMxc = getAvatar(spaceId); + const avatarUrl = + avatarMxc && + mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({ + height: 96, + width: 96, + }); const targetRef = useRef(null); const spaceDraggable: SidebarDraggable = useMemo( @@ -409,10 +435,10 @@ function SpaceTab({ folder ? { folder, - spaceId: space.roomId, + spaceId, } - : space.roomId, - [folder, space] + : spaceId, + [folder, spaceId] ); useDraggableItem(spaceDraggable, targetRef, onDragging); @@ -431,72 +457,69 @@ function SpaceTab({ }; return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } + + + {(triggerRef) => ( + + ( + {nameInitials(spaceName, 2)} + )} /> - )} - + + )} + + {notifications.count > 0 && ( + 0}> + + )} - + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + onUnpin={onUnpin} + notifications={notifications} + /> + + } + /> + )} + ); } @@ -535,6 +558,8 @@ function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps type ClosedSpaceFolderProps = { folder: ISidebarFolder; selected: boolean; + getName: GetRoomNameCallback; + getAvatar: GetRoomAvatarCallback; onOpen: MouseEventHandler; onDragging: (dragItem?: SidebarDraggable) => void; disabled?: boolean; @@ -542,12 +567,24 @@ type ClosedSpaceFolderProps = { function ClosedSpaceFolder({ folder, selected, + getName, + getAvatar, onOpen, onDragging, disabled, }: ClosedSpaceFolderProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); + + const roomToParents = useAtomValue(roomToParentsAtom); + const childRooms = useSpacesChildren( + allRoomsAtom, + folder.content, + useRecursiveChildScopeFactory(ct, roomToParents) + ); + const roomsNotifications = useRoomsNotifications(childRooms, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomsNotifications); + const handlerRef = useRef(null); const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); @@ -555,53 +592,54 @@ function ClosedSpaceFolder({ const dropState = useDropTarget(spaceDraggable, handlerRef); const dropType = dropState?.type; - const tooltipName = - folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; + const tooltipName = folder.name ?? folder.content.map((i) => getName(i) ?? i).join(', '); return ( - - {(unread) => ( - - - {(tooltipRef) => ( - - {folder.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; + + + {(tooltipRef) => ( + + {folder.content.map((spaceId) => { + const spaceName = getName(spaceId) ?? spaceId; + const avatarMxc = getAvatar(spaceId); + const avatarUrl = + avatarMxc && + mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({ + height: 96, + width: 96, + }); - return ( - - ( - - {nameInitials(space.name, 2)} - - )} - /> - - ); - })} - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - + return ( + + ( + + {nameInitials(spaceName, 2)} + + )} + /> + + ); + })} + + )} + + {notifications.count > 0 && ( + 0}> + + )} - + ); } @@ -610,15 +648,30 @@ type SpaceTabsProps = { }; export function SpaceTabs({ scrollRef }: SpaceTabsProps) { const navigate = useNavigate(); - const mx = useMatrixClient(); + const ct = useClient(); const screenSize = useScreenSizeContext(); + const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); + const orphanSpaces = useOrphanSpaces(ct, allRoomsAtom, roomToParents); + const { value: cinnySpacesEvent } = useAccountData>( + AccountDataType.CinnySpaces + ); + const originalSidebarItems = useSidebarItems(orphanSpaces, cinnySpacesEvent); + const [sidebarItems, localEchoSidebarItem] = useState(originalSidebarItems); const navToActivePath = useAtomValue(useNavToActivePathAtom()); const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); const [draggingItem, setDraggingItem] = useState(); + const sidebarRooms = useSidebarRooms(originalSidebarItems); + const [getRoomHeroes, roomToHeroResult] = useRoomHeroes(sidebarRooms); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroResult.value); + + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroResult.value); + + useEffect(() => { + localEchoSidebarItem(originalSidebarItems); + }, [originalSidebarItems]); + useDnDMonitor( scrollRef, setDraggingItem, @@ -744,11 +797,19 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { newItems.push(i); }); - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); + const newSpacesContent = makeCinnySpacesContent(newItems, cinnySpacesEvent); + localEchoSidebarItem(parseSidebar(ct, orphanSpaces, newSpacesContent)); + ct.requestAuthJSON( + putAccountData({ + pathParams: { + userId: ct.session.userId, + type: AccountDataType.CinnySpaces, + }, + body: newSpacesContent, + }) + ); }, - [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] + [ct, sidebarItems, cinnySpacesEvent, setOpenedFolder, localEchoSidebarItem, orphanSpaces] ) ); @@ -758,8 +819,9 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { const target = evt.currentTarget; const targetSpaceId = target.getAttribute('data-id'); if (!targetSpaceId) return; + const targetSpaceAlias = ct.roomToAlias.read.get(targetSpaceId); - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); + const spacePath = getSpacePath(targetSpaceAlias ?? targetSpaceId); if (screenSize === ScreenSize.Mobile) { navigate(spacePath); return; @@ -771,7 +833,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { return; } - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + navigate(getSpaceLobbyPath(targetSpaceAlias ?? targetSpaceId)); }; const handleFolderToggle: MouseEventHandler = (evt) => { @@ -790,11 +852,19 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { 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); + const newSpacesContent = makeCinnySpacesContent(newItems, cinnySpacesEvent); + localEchoSidebarItem(parseSidebar(ct, orphanSpaces, newSpacesContent)); + ct.requestAuthJSON( + putAccountData({ + pathParams: { + userId: ct.session.userId, + type: AccountDataType.CinnySpaces, + }, + body: newSpacesContent, + }) + ); }, - [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] + [ct, sidebarItems, orphanSpaces, cinnySpacesEvent, localEchoSidebarItem] ); if (sidebarItems.length === 0) return null; @@ -807,26 +877,22 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { if (openedFolder.has(item.id)) { return ( - {item.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - return ( - - ); - })} + {item.content.map((spaceId) => ( + + ))} ); } @@ -835,6 +901,8 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { ); })} diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index 0fd52ab6..702afeac 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -1,34 +1,27 @@ import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtomValue } from 'jotai'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useSpace } from '../../../hooks/useSpace'; -import { getAllParents, getSpaceChildren } from '../../../utils/room'; +import { getAllParents } from '../../../utils/room'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { mDirectAtom } from '../../../state/mDirectList'; -import { settingsAtom } from '../../../state/settings'; -import { useSetting } from '../../../state/hooks/settings'; export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { - const mx = useMatrixClient(); - const space = useSpace(); - const [developerTools] = useSetting(settingsAtom, 'developerTools'); - const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom); + const spaceId = useSpace(); + const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); const allRooms = useAtomValue(allRoomsAtom); const { roomIdOrAlias, eventId } = useParams(); const viaServers = useSearchParamsViaServers(); const roomId = useSelectedRoom(); - const room = mx.getRoom(roomId); - if (!room || !allRooms.includes(room.roomId)) { - // room is not joined + if (!roomId || !allRooms.includes(roomId)) { return ( - {children} - - ); - } - - if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) { - if (getSpaceChildren(space).includes(room.roomId)) { - // fill missing roomToParent mapping - setRoomToParents({ - type: 'PUT', - parent: space.roomId, - children: [room.roomId], - }); - } - + if (!getAllParents(roomToParents, roomId).has(spaceId)) { return ( - {children} + + {children} ); } diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 3f60d2a9..99e9b879 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -26,10 +26,9 @@ import { toRem, } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { JoinRule, Room } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { postJoin, PostJoinOK } from 'neurone/lib/api/matrix/client/v3/join'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { mDirectAtom } from '../../../state/mDirectList'; import { NavCategory, @@ -39,7 +38,7 @@ import { NavLink, } from '../../../components/nav'; import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; +import { getMxIdServer } from '../../../utils/matrix'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, @@ -49,24 +48,19 @@ import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; 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 } from '../../../hooks/usePowerLevels'; +import { usePowerLevels, usePowerLevelsState } from '../../../hooks/usePowerLevels'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { markAsRead } from '../../../utils/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 { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useStateEvent } from '../../../hooks/useStateEvent'; -import { Membership, StateEvent } from '../../../../types/matrix/room'; +import { StateEventType } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; @@ -84,21 +78,36 @@ import { ContainerColor } from '../../../styles/ContainerColor.css'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { BreakWord } from '../../../styles/Text.css'; import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { useObservableValue } from '../../../hooks/useObservableValue'; +import { useClient } from '../../../hooks/useClient'; +import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros'; +import { useGetRoomName } from '../../../hooks/storage/useGetRoomName'; +import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar'; +import { roomToNotificationsAtom } from '../../../state/room/roomToNotifications'; +import { + getRoomTotalNotifications, + useRoomsNotifications, + useRoomsTotalNotifications, +} from '../../../state/hooks/roomNotifications'; +import { getJoinRoomStateId } from '../../../../client/storage/collections'; +import { useAllJoinedRoomsSet } from '../../../hooks/useGetRoom'; type SpaceMenuProps = { - room: Room; requestClose: () => void; }; -const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { - const mx = useMatrixClient(); +const SpaceMenu = forwardRef(({ requestClose }, ref) => { + const ct = useClient(); + const { userId } = ct.session; + const spaceId = useSpace(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [developerTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); + const powerLevelsStateResult = usePowerLevelsState(spaceId); + const powerLevels = usePowerLevels(powerLevelsStateResult.value); + const creators = useRoomCreators(spaceId); const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); + const canInvite = permissions.action('invite', userId); const openSpaceSettings = useOpenSpaceSettings(); const { navigateRoom } = useRoomNavigate(); @@ -106,20 +115,21 @@ const SpaceMenu = forwardRef(({ room, requestClo const allChild = useSpaceChildren( allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) + spaceId, + useRecursiveChildScopeFactory(ct, roomToParents) ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); + const roomToNotifications = useRoomsNotifications(allChild, roomToNotificationsAtom); + const notifications = useRoomsTotalNotifications(roomToNotifications); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + // allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + const alias = ct.roomToAlias.read.get(spaceId); + const viaServers = alias ? undefined : getViaServers(creators, powerLevels); + copyToClipboard(getMatrixToRoom(alias ?? spaceId, viaServers)); requestClose(); }; @@ -128,21 +138,21 @@ const SpaceMenu = forwardRef(({ room, requestClo }; const handleRoomSettings = () => { - openSpaceSettings(room.roomId); + openSpaceSettings(spaceId); requestClose(); }; const handleOpenTimeline = () => { - navigateRoom(room.roomId); + navigateRoom(spaceId); requestClose(); }; return ( - {invitePrompt && room && ( + {invitePrompt && ( { setInvitePrompt(false); requestClose(); @@ -154,7 +164,7 @@ const SpaceMenu = forwardRef(({ room, requestClo size="300" after={} radii="300" - disabled={!unread} + disabled={!notifications || notifications.count === 0} > Mark as Read @@ -230,7 +240,7 @@ const SpaceMenu = forwardRef(({ room, requestClo {promptLeave && ( setPromptLeave(false)} /> @@ -243,15 +253,15 @@ const SpaceMenu = forwardRef(({ room, requestClo ); }); -function SpaceHeader() { - const space = useSpace(); - const spaceName = useRoomName(space); +type SpaceHeaderProps = { + spaceName: string; +}; +function SpaceHeader({ spaceName }: SpaceHeaderProps) { + const ct = useClient(); + const spaceId = useSpace(); const [menuAnchor, setMenuAnchor] = useState(); - - const joinRules = useStateEvent( - space, - StateEvent.RoomJoinRules - )?.getContent(); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const joinRule = roomToJoinRule.get(spaceId); const handleOpenMenu: MouseEventHandler = (evt) => { const cords = evt.currentTarget.getBoundingClientRect(); @@ -269,7 +279,7 @@ function SpaceHeader() { {spaceName} - {joinRules?.join_rule !== JoinRule.Public && } + {joinRule !== 'public' && } @@ -296,7 +306,7 @@ function SpaceHeader() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} /> + setMenuAnchor(undefined)} /> } /> @@ -307,23 +317,35 @@ function SpaceHeader() { type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); + const ct = useClient(); const { navigateSpace } = useRoomNavigate(); + const allJoinedRooms = useAllJoinedRoomsSet(); - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) + const [joinState, handleJoin] = useAsyncCallback( + useCallback(async () => { + const tombstoneEvent = await ct.storage.joinRoomState.read.get( + getJoinRoomStateId(roomId, StateEventType.RoomTombstone, '') + ); + const sender = tombstoneEvent?.event.sender; + const senderServer = sender && getMxIdServer(sender); + const via = senderServer ? [senderServer] : undefined; + const response = await ct.requestAuthJSON( + postJoin({ + pathParams: { roomIdOrAlias: replacementRoomId }, + searchParams: { via, server_name: via }, + }) + ); + + const [err, result] = await parseMatrixResp(response); + if (err) { + throw new Error(printMatrixError(err)); + } + return result; + }, [ct, roomId, replacementRoomId]) ); - const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); + navigateSpace(replacementRoomId); }; return ( @@ -347,8 +369,7 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp )} - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( + {allJoinedRooms.has(replacementRoomId) || joinState.status === AsyncStatus.Success ? ( @@ -375,18 +396,19 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp } export function Space() { - const mx = useMatrixClient(); - const space = useSpace(); - useNavToActivePathMapper(space.roomId); - const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - const scrollRef = useRef(null); - const mDirects = useAtomValue(mDirectAtom); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - const notificationPreferences = useRoomsNotificationPreferencesContext(); + const ct = useClient(); + const spaceId = useSpace(); + useNavToActivePathMapper(spaceId); + const spaceRooms = useObservableValue(ct.spaceRooms); + const roomToAlias = useObservableValue(ct.roomToAlias); + const roomToReplacement = useObservableValue(ct.roomToReplacement); + const replacementRoomId = roomToReplacement.get(spaceId); - const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); + const spaceIdOrAlias = roomToAlias.get(spaceId) ?? spaceId; + const scrollRef = useRef(null); + + const mDirects = useAtomValue(mDirectAtom); + const notificationPreferences = useRoomsNotificationPreferencesContext(); const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); @@ -394,35 +416,14 @@ export function Space() { 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(spaceId); - 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 rooms = useMemo(() => hierarchy.map((item) => item.roomId), [hierarchy]); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(rooms); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const roomToNotifications = useRoomsNotifications(rooms, roomToNotificationsAtom); const virtualizer = useVirtualizer({ count: hierarchy.length, @@ -436,22 +437,19 @@ export function Space() { ); const getToLink = (roomId: string) => - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + getSpaceRoomPath(spaceIdOrAlias, roomToAlias.get(roomId) ?? roomId); return ( - + - {tombstoneEvent && ( - + {replacementRoomId && ( + )} - + @@ -467,7 +465,7 @@ export function Space() { - + @@ -490,12 +488,12 @@ export function Space() { }} > {virtualizer.getVirtualItems().map((vItem) => { - const { roomId } = hierarchy[vItem.index] ?? {}; - const room = mx.getRoom(roomId); - if (!room) return null; + const item = hierarchy[vItem.index] ?? {}; + if (!item) return null; + const { roomId } = item; - if (room.isSpaceRoom()) { - const categoryId = makeNavCategoryId(space.roomId, roomId); + if (spaceRooms.has(roomId)) { + const categoryId = makeNavCategoryId(spaceId, roomId); return ( - {roomId === space.roomId ? 'Rooms' : room?.name} + {roomId === spaceId ? 'Rooms' : getRoomName(roomId) ?? 'Unknown'} @@ -518,15 +516,21 @@ export function Space() { ); } + // TODO: closedCategories.has(makeNavCategoryId(spaceId, parentId) + const n = roomToNotifications.get(roomId); + const notifications = n && getRoomTotalNotifications(n); return ( ); diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx index 2e0f79a2..70a8c740 100644 --- a/src/app/pages/client/space/SpaceProvider.tsx +++ b/src/app/pages/client/space/SpaceProvider.tsx @@ -1,32 +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'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; +import { useClient } from '../../../hooks/useClient'; type RouteSpaceProviderProps = { children: ReactNode; }; export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) { - const mx = useMatrixClient(); - const joinedSpaces = useSpaces(mx, allRoomsAtom); + const ct = useClient(); + const joinedSpaces = useSpaces(ct, allRoomsAtom); + const selectedSpaceId = useSelectedSpace(); const { spaceIdOrAlias } = useParams(); const viaServers = useSearchParamsViaServers(); - const selectedSpaceId = useSelectedSpace(); - const space = mx.getRoom(selectedSpaceId); - - if (!space || !joinedSpaces.includes(space.roomId)) { + if (!selectedSpaceId || !joinedSpaces.includes(selectedSpaceId)) { return ; } return ( - + {children} ); diff --git a/src/app/plugins/custom-emoji/ImagePack.ts b/src/app/plugins/custom-emoji/ImagePack.ts index 1685362e..7d8714f2 100644 --- a/src/app/plugins/custom-emoji/ImagePack.ts +++ b/src/app/plugins/custom-emoji/ImagePack.ts @@ -1,4 +1,4 @@ -import { MatrixEvent } from 'matrix-js-sdk'; +import { AccountDataEvent, StateEventWithoutRoomId } from 'neurone/lib/api/types'; import { PackAddress } from './PackAddress'; import { PackImageReader } from './PackImageReader'; import { PackImagesReader } from './PackImagesReader'; @@ -31,16 +31,15 @@ export class ImagePack { this.images = new PackImagesReader(content.images ?? {}); } - static fromMatrixEvent(id: string, matrixEvent: MatrixEvent) { - const roomId = matrixEvent.getRoomId(); - const stateKey = matrixEvent.getStateKey(); + static fromAccountData(userId: string, accountDataEvent: AccountDataEvent) { + const imagePack: ImagePack = new ImagePack(userId, accountDataEvent.content, undefined); - const address = - roomId && typeof stateKey === 'string' ? new PackAddress(roomId, stateKey) : undefined; + return imagePack; + } - const content = matrixEvent.getContent(); - - const imagePack: ImagePack = new ImagePack(id, content, address); + static fromStateEvent(roomId: string, stateEvent: StateEventWithoutRoomId) { + const address = new PackAddress(roomId, stateEvent.state_key); + const imagePack: ImagePack = new ImagePack(stateEvent.event_id, stateEvent.content, address); return imagePack; } diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts index cb80bb1c..987b602f 100644 --- a/src/app/plugins/custom-emoji/utils.ts +++ b/src/app/plugins/custom-emoji/utils.ts @@ -1,9 +1,4 @@ -import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; -import { ImagePack } from './ImagePack'; -import { EmoteRoomsContent, ImageUsage } from './types'; -import { StateEvent } from '../../../types/matrix/room'; -import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room'; -import { AccountDataEvent } from '../../../types/matrix/accountData'; +import { ImageUsage } from './types'; import { PackMetaReader } from './PackMetaReader'; import { PackAddress } from './PackAddress'; @@ -26,63 +21,29 @@ export function packMetaEqual(a: PackMetaReader, b: PackMetaReader): boolean { ); } -export function makeImagePacks(packEvents: MatrixEvent[]): ImagePack[] { - return packEvents.reduce((imagePacks, packEvent) => { - const packId = packEvent.getId(); - if (!packId) return imagePacks; - imagePacks.push(ImagePack.fromMatrixEvent(packId, packEvent)); - return imagePacks; - }, []); -} +export function getGlobalPackRoomToStateKey(content?: object): Record { + if (typeof content !== 'object') return {}; -export function getRoomImagePack(room: Room, stateKey: string): ImagePack | undefined { - const packEvent = getStateEvent(room, StateEvent.PoniesRoomEmotes, stateKey); - if (!packEvent) return undefined; - const packId = packEvent.getId(); - if (!packId) return undefined; - return ImagePack.fromMatrixEvent(packId, packEvent); -} + if (!('rooms' in content) || typeof content.rooms !== 'object' || content.rooms === null) + return {}; -export function getRoomImagePacks(room: Room): ImagePack[] { - const packEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes); - return makeImagePacks(packEvents); -} + const { rooms } = content; + const rToKeys: Record = {}; -export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] { - const emoteRoomsContent = getAccountData(mx, AccountDataEvent.PoniesEmoteRooms)?.getContent() as - | EmoteRoomsContent - | undefined; - if (typeof emoteRoomsContent !== 'object') return []; + Object.keys(rooms).forEach((roomId) => { + const keys = rToKeys[roomId] ?? []; - const { rooms: roomIdToPackInfo } = emoteRoomsContent; - if (typeof roomIdToPackInfo !== 'object') return []; + const stateKeys = (rooms as Record)[roomId]; + if (typeof stateKeys !== 'object' || stateKeys === null) return; - const roomIds = Object.keys(roomIdToPackInfo); - - const packs = roomIds.flatMap((roomId) => { - if (typeof roomIdToPackInfo[roomId] !== 'object') return []; - const room = mx.getRoom(roomId); - if (!room) return []; - const packStateKeyToUnknown = roomIdToPackInfo[roomId]; - const packEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes); - const globalPackEvents = packEvents.filter((mE) => { - const stateKey = mE.getStateKey(); - if (typeof stateKey === 'string') return !!packStateKeyToUnknown[stateKey]; - return false; + Object.keys(stateKeys).forEach((stateKey) => { + const result = (stateKeys as Record)[stateKey]; + if (typeof result !== 'object' || result === null) return; + keys.push(stateKey); }); - return makeImagePacks(globalPackEvents); + + rToKeys[roomId] = keys; }); - return packs; -} - -export function getUserImagePack(mx: MatrixClient): ImagePack | undefined { - const packEvent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes); - const userId = mx.getUserId(); - if (!packEvent || !userId) { - return undefined; - } - - const userImagePack = ImagePack.fromMatrixEvent(userId, packEvent); - return userImagePack; + return rToKeys; } diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index ba40c978..a6c18231 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -14,7 +14,6 @@ import { attributesToProps, domToReact, } from 'html-react-parser'; -import { MatrixClient } from 'matrix-js-sdk'; import classNames from 'classnames'; import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds'; import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; @@ -22,13 +21,7 @@ import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; -import { - getMxIdLocalPart, - getCanonicalAliasRoomId, - isRoomAlias, - mxcUrlToHttp, -} from '../utils/matrix'; -import { getMemberDisplayName } from '../utils/room'; +import { isRoomAlias } from '../utils/matrix'; import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { findAndReplace } from '../utils/findAndReplace'; @@ -41,6 +34,10 @@ import { import { onEnterOrSpace } from '../utils/keyboard'; import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom'; import { useTimeoutToggle } from '../hooks/useTimeoutToggle'; +import { ClientContext } from '../../client/context'; +import { GetRoomMemberNameCallback } from '../hooks/useRoomMembers'; +import { GetRoomNameCallback } from '../hooks/storage/useGetRoomName'; +import { mxcToHttp } from '../../client/mxcToHttp'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); @@ -72,25 +69,23 @@ export const makeMentionCustomProps = ( }); export const renderMatrixMention = ( - mx: MatrixClient, + ct: ClientContext, currentRoomId: string | undefined, href: string, - customProps: ComponentPropsWithoutRef<'a'> + customProps: ComponentPropsWithoutRef<'a'>, + getMemberName: GetRoomMemberNameCallback, + getRoomName: GetRoomNameCallback ) => { const userId = parseMatrixToUser(href); if (userId) { - const currentRoom = mx.getRoom(currentRoomId); - return ( - {`@${ - (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId) - }`} + {`@${getMemberName(userId)}`} ); } @@ -98,20 +93,21 @@ export const renderMatrixMention = ( const matrixToRoom = parseMatrixToRoom(href); if (matrixToRoom) { const { roomIdOrAlias, viaServers } = matrixToRoom; - const mentionRoom = mx.getRoom( - isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias - ); - const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias; + const mentionRoomId = isRoomAlias(roomIdOrAlias) + ? ct.rooms.join.read.find((roomId) => ct.roomToAlias.read.get(roomId) === roomIdOrAlias) + : undefined; + + const fallbackContent = mentionRoomId ? `#${getRoomName(mentionRoomId)}` : roomIdOrAlias; return ( {customProps.children ? customProps.children : fallbackContent} @@ -122,24 +118,24 @@ export const renderMatrixMention = ( const matrixToRoomEvent = parseMatrixToRoomEvent(href); if (matrixToRoomEvent) { const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent; - const mentionRoom = mx.getRoom( - isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias - ); + const mentionRoomId = isRoomAlias(roomIdOrAlias) + ? ct.rooms.join.read.find((roomId) => ct.roomToAlias.read.get(roomId) === roomIdOrAlias) + : undefined; return ( {customProps.children ? customProps.children - : `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`} + : `Message: ${mentionRoomId ? `#${getRoomName(mentionRoomId)}` : roomIdOrAlias}`} ); } @@ -310,7 +306,7 @@ export function CodeBlock({ } export const getReactCustomHtmlParser = ( - mx: MatrixClient, + ct: ClientContext, roomId: string | undefined, params: { linkifyOpts: LinkifyOpts; @@ -318,6 +314,8 @@ export const getReactCustomHtmlParser = ( handleSpoilerClick?: ReactEventHandler; handleMentionClick?: ReactEventHandler; useAuthentication?: boolean; + getMemberName: GetRoomMemberNameCallback; + getRoomName: GetRoomNameCallback; } ): HTMLReactParserOptions => { const opts: HTMLReactParserOptions = { @@ -446,10 +444,12 @@ export const getReactCustomHtmlParser = ( : children.map((c) => (c instanceof DOMText ? c.data : '')).join(); const mention = renderMatrixMention( - mx, + ct, roomId, tryDecodeURIComponent(props.href), - makeMentionCustomProps(params.handleMentionClick, content) + makeMentionCustomProps(params.handleMentionClick, content), + params.getMemberName, + params.getRoomName ); if (mention) return mention; @@ -473,7 +473,7 @@ export const getReactCustomHtmlParser = ( } if (name === 'img') { - const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication); + const htmlSrc = mxcToHttp(ct.session.baseUrl, props.src, params.useAuthentication).url(); if (htmlSrc && props.src.startsWith('mxc://') === false) { return ( diff --git a/src/app/plugins/recent-emoji.ts b/src/app/plugins/recent-emoji.ts index 3634538f..d20f848b 100644 --- a/src/app/plugins/recent-emoji.ts +++ b/src/app/plugins/recent-emoji.ts @@ -1,22 +1,32 @@ -import { MatrixClient } from 'matrix-js-sdk'; -import { getAccountData } from '../utils/room'; +import { AccountDataEvent } from 'neurone/lib/api/types'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; import { IEmoji, emojis } from './emoji'; -import { AccountDataEvent } from '../../types/matrix/accountData'; +import { ClientContext } from '../../client/context'; +import { AccountDataType } from '../../types/matrix/accountData'; type EmojiUnicode = string; type EmojiUsageCount = number; export type IRecentEmojiContent = { - recent_emoji?: [EmojiUnicode, EmojiUsageCount][]; + recent_emoji?: Array<[EmojiUnicode, EmojiUsageCount]>; }; -export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => { - const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji); - const recentEmoji = recentEmojiEvent?.getContent().recent_emoji; +const parseRecentEmojis = (content: object): Array<[EmojiUnicode, EmojiUsageCount]> => { + if (!('recent_emoji' in content)) return []; + const { recent_emoji: recentEmoji } = content; if (!Array.isArray(recentEmoji)) return []; + return recentEmoji.filter( + (i) => + Array.isArray(i) && i.length === 2 && typeof i[0] === 'string' && typeof i[1] === 'number' + ); +}; + +export const getRecentEmojis = (event: AccountDataEvent, limit?: number): IEmoji[] => { + const recentEmoji = parseRecentEmojis(event.content); + return recentEmoji - .sort((e1, e2) => e2[1] - e1[1]) + .sort((i1, i2) => i2[1] - i1[1]) .slice(0, limit) .reduce((list, [unicode]) => { const emoji = emojis.find((e) => e.unicode === unicode); @@ -25,9 +35,9 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => { }, []); }; -export function addRecentEmoji(mx: MatrixClient, unicode: string) { - const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji); - const recentEmoji = recentEmojiEvent?.getContent().recent_emoji ?? []; +export async function addRecentEmoji(ct: ClientContext, unicode: string) { + const recentEmojiObj = await ct.storage.accountData.read.get(AccountDataType.ElementRecentEmoji); + const recentEmoji = parseRecentEmojis(recentEmojiObj?.event.content ?? {}); const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode); let entry: [EmojiUnicode, EmojiUsageCount]; @@ -38,7 +48,18 @@ export function addRecentEmoji(mx: MatrixClient, unicode: string) { entry[1] += 1; } recentEmoji.unshift(entry); - mx.setAccountData(AccountDataEvent.ElementRecentEmoji, { + + const newContent: IRecentEmojiContent = { recent_emoji: recentEmoji.slice(0, 100), - }); + }; + + await ct.requestAuthJSON( + putAccountData({ + pathParams: { + userId: ct.session.userId, + type: AccountDataType.ElementRecentEmoji, + }, + body: newContent, + }) + ); } diff --git a/src/app/plugins/via-servers.ts b/src/app/plugins/via-servers.ts index d825a1fd..bbd35746 100644 --- a/src/app/plugins/via-servers.ts +++ b/src/app/plugins/via-servers.ts @@ -1,73 +1,52 @@ -import { Room } from 'matrix-js-sdk'; -import { IPowerLevels } from '../hooks/usePowerLevels'; -import { creatorsSupported, getMxIdServer } from '../utils/matrix'; -import { IRoomCreateContent, StateEvent } from '../../types/matrix/room'; -import { getStateEvent } from '../utils/room'; +import { IPowerLevels, readPowerLevel } from '../hooks/usePowerLevels'; +import { getMxIdServer } from '../utils/matrix'; -export const getViaServers = (room: Room): string[] => { - const getHighestPowerUserId = (): string | undefined => { - const creatorEvent = getStateEvent(room, StateEvent.RoomCreate); - if ( - creatorEvent && - creatorsSupported(creatorEvent.getContent().room_version) - ) { - return creatorEvent.getSender(); +export const getViaServers = ( + creators?: Set, + powerLevels?: IPowerLevels, + users?: string[] +): string[] => { + const servers = new Set(); + + Array.from(creators ?? new Set()).forEach((creator) => { + if (powerLevels?.users?.[creator] === undefined) { + const server = getMxIdServer(creator); + if (server) servers.add(server); } + }); - const powerLevels = getStateEvent(room, StateEvent.RoomPowerLevels)?.getContent(); - - if (!powerLevels) return undefined; - const userIdToPower = powerLevels.users; - if (!userIdToPower) return undefined; - let powerUserId: string | undefined; - - Object.keys(userIdToPower).forEach((userId) => { - if (userIdToPower[userId] <= (powerLevels.users_default ?? 0)) return; - - if (!powerUserId) { - powerUserId = userId; - return; - } - if (userIdToPower[userId] > userIdToPower[powerUserId]) { - powerUserId = userId; - } - }); - return powerUserId; - }; - - const getServerToPopulation = (): Record => { - const members = room.getMembers(); - const serverToPop: Record = {}; - - members?.forEach((member) => { - const { userId } = member; - const server = getMxIdServer(userId); - if (!server) return; - const serverPop = serverToPop[server]; - if (serverPop === undefined) { - serverToPop[server] = 1; - return; - } - serverToPop[server] = serverPop + 1; + const getTwoPopularServers = (us: string[]) => { + const serverToMemberCount = new Map(); + us.forEach((user) => { + const server = getMxIdServer(user); + if (server === undefined) return; + const count = serverToMemberCount.get(server) ?? 0; + serverToMemberCount.set(server, count + 1); }); - return serverToPop; + const sortedUsers = Array.from(serverToMemberCount.keys()).sort((s1, s2) => { + const c1 = serverToMemberCount.get(s1) ?? 0; + const c2 = serverToMemberCount.get(s2) ?? 0; + + return c2 - c1; + }); + + return sortedUsers.slice(0, 2); }; - const via: string[] = []; - const userId = getHighestPowerUserId(); - if (userId) { - const server = getMxIdServer(userId); - if (server) via.push(server); + if (powerLevels && powerLevels.users) { + const userDefaultPower = readPowerLevel.user(powerLevels, undefined); + + const powerUsers = Object.keys(powerLevels.users).filter( + (userId) => readPowerLevel.user(powerLevels, userId) > userDefaultPower + ); + + getTwoPopularServers(powerUsers).forEach((server) => servers.add(server)); } - const serverToPop = getServerToPopulation(); - const sortedServers = Object.keys(serverToPop).sort( - (svrA, svrB) => serverToPop[svrB] - serverToPop[svrA] - ); - const mostPop3 = sortedServers.slice(0, 3); - if (via.length === 0) return mostPop3; - if (mostPop3.includes(via[0])) { - mostPop3.splice(mostPop3.indexOf(via[0]), 1); + + if (users) { + getTwoPopularServers(users).forEach((server) => servers.add(server)); } - return via.concat(mostPop3.slice(0, 2)); + + return Array.from(servers).slice(0, 3); }; diff --git a/src/app/state/hooks/roomList.ts b/src/app/state/hooks/roomList.ts index d64ecd5b..916b8b34 100644 --- a/src/app/state/hooks/roomList.ts +++ b/src/app/state/hooks/roomList.ts @@ -1,114 +1,132 @@ import { Atom, useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; -import { MatrixClient } from 'matrix-js-sdk'; import { useCallback, useMemo } from 'react'; -import { getAllParents, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room'; +import { getAllParents } from '../../utils/room'; import { compareRoomsEqual } from '../room-list/utils'; import { RoomToParents } from '../../../types/matrix/room'; +import { ClientContext } from '../../../client/context'; +import { useObservableValue } from '../../hooks/useObservableValue'; -export type RoomsAtom = Atom; -export type RoomSelector = (roomId: string) => boolean | undefined; +type RoomsAtom = Atom; +type RoomSelector = (roomId: string) => boolean | undefined; -export const selectedRoomsAtom = ( +const selectedRoomsAtom = ( roomsAtom: RoomsAtom, selector: (roomId: string) => boolean | undefined ) => selectAtom(roomsAtom, (rooms) => rooms.filter(selector), compareRoomsEqual); -export const useSelectedRooms = (roomsAtom: RoomsAtom, selector: RoomSelector) => { +const useSelectedRooms = (roomsAtom: RoomsAtom, selector: RoomSelector) => { const anAtom = useMemo(() => selectedRoomsAtom(roomsAtom, selector), [roomsAtom, selector]); return useAtomValue(anAtom); }; -export type SpaceChildSelectorFactory = (parentId: string) => RoomSelector; +type SpaceChildSelectorFactory = (parentId: string) => RoomSelector; export const useRecursiveChildScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isRoom(mx.getRoom(roomId)) && + !spaceRooms.has(roomId) && roomToParents.has(roomId) && getAllParents(roomToParents, roomId).has(parentId), - [mx, roomToParents] + [spaceRooms, roomToParents] ); +}; export const useChildSpaceScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isSpace(mx.getRoom(roomId)) && roomToParents.get(roomId)?.has(parentId), - [mx, roomToParents] + spaceRooms.has(roomId) && roomToParents.get(roomId)?.has(parentId), + [spaceRooms, roomToParents] ); +}; export const useRecursiveChildSpaceScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isSpace(mx.getRoom(roomId)) && + spaceRooms.has(roomId) && roomToParents.has(roomId) && getAllParents(roomToParents, roomId).has(parentId), - [mx, roomToParents] + [spaceRooms, roomToParents] ); +}; export const useChildRoomScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, mDirects: Set, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isRoom(mx.getRoom(roomId)) && - !mDirects.has(roomId) && - roomToParents.get(roomId)?.has(parentId), - [mx, mDirects, roomToParents] + !spaceRooms.has(roomId) && !mDirects.has(roomId) && roomToParents.get(roomId)?.has(parentId), + [spaceRooms, mDirects, roomToParents] ); +}; export const useRecursiveChildRoomScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, mDirects: Set, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isRoom(mx.getRoom(roomId)) && + !spaceRooms.has(roomId) && !mDirects.has(roomId) && roomToParents.has(roomId) && getAllParents(roomToParents, roomId).has(parentId), - [mx, mDirects, roomToParents] + [spaceRooms, mDirects, roomToParents] ); +}; export const useChildDirectScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, mDirects: Set, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isRoom(mx.getRoom(roomId)) && - mDirects.has(roomId) && - roomToParents.get(roomId)?.has(parentId), - [mx, mDirects, roomToParents] + !spaceRooms.has(roomId) && mDirects.has(roomId) && roomToParents.get(roomId)?.has(parentId), + [spaceRooms, mDirects, roomToParents] ); +}; export const useRecursiveChildDirectScopeFactory = ( - mx: MatrixClient, + ct: ClientContext, mDirects: Set, roomToParents: RoomToParents -): SpaceChildSelectorFactory => - useCallback( +): SpaceChildSelectorFactory => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + return useCallback( (parentId: string) => (roomId) => - isRoom(mx.getRoom(roomId)) && + !spaceRooms.has(roomId) && mDirects.has(roomId) && roomToParents.has(roomId) && getAllParents(roomToParents, roomId).has(parentId), - [mx, mDirects, roomToParents] + [spaceRooms, mDirects, roomToParents] ); +}; export const useSpaceChildren = ( roomsAtom: RoomsAtom, @@ -122,56 +140,71 @@ export const useSpaceChildren = ( return useSelectedRooms(roomsAtom, recursiveChildRoomSelector); }; -export const useSpaces = (mx: MatrixClient, roomsAtom: RoomsAtom) => { - const selector: RoomSelector = useCallback((roomId) => isSpace(mx.getRoom(roomId)), [mx]); +export const useSpacesChildren = ( + roomsAtom: RoomsAtom, + spaces: string[], + selectorFactory: SpaceChildSelectorFactory +) => { + const recursiveChildRoomSelector = useMemo(() => { + const selector: RoomSelector = (roomId) => + spaces.some((spaceId) => selectorFactory(spaceId)(roomId)); + return selector; + }, [selectorFactory, spaces]); + return useSelectedRooms(roomsAtom, recursiveChildRoomSelector); +}; + +export const useSpaces = (ct: ClientContext, roomsAtom: RoomsAtom) => { + const spaceRooms = useObservableValue(ct.spaceRooms); + + const selector: RoomSelector = useCallback((roomId) => spaceRooms.has(roomId), [spaceRooms]); return useSelectedRooms(roomsAtom, selector); }; export const useOrphanSpaces = ( - mx: MatrixClient, + ct: ClientContext, roomsAtom: RoomsAtom, roomToParents: RoomToParents ) => { + const spaceRooms = useObservableValue(ct.spaceRooms); + const selector: RoomSelector = useCallback( - (roomId) => isSpace(mx.getRoom(roomId)) && !roomToParents.has(roomId), - [mx, roomToParents] + (roomId) => spaceRooms.has(roomId) && !roomToParents.has(roomId), + [spaceRooms, roomToParents] ); return useSelectedRooms(roomsAtom, selector); }; -export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set) => { +export const useRooms = (ct: ClientContext, roomsAtom: RoomsAtom, mDirects: Set) => { + const spaceRooms = useObservableValue(ct.spaceRooms); + const selector: RoomSelector = useCallback( - (roomId: string) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId), - [mx, mDirects] + (roomId: string) => !spaceRooms.has(roomId) && !mDirects.has(roomId), + [spaceRooms, mDirects] ); return useSelectedRooms(roomsAtom, selector); }; export const useOrphanRooms = ( - mx: MatrixClient, + ct: ClientContext, roomsAtom: RoomsAtom, mDirects: Set, roomToParents: RoomToParents ) => { + const spaceRooms = useObservableValue(ct.spaceRooms); + const selector: RoomSelector = useCallback( - (roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId) && !roomToParents.has(roomId), - [mx, mDirects, roomToParents] + (roomId) => !spaceRooms.has(roomId) && !mDirects.has(roomId) && !roomToParents.has(roomId), + [spaceRooms, mDirects, roomToParents] ); return useSelectedRooms(roomsAtom, selector); }; -export const useDirects = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set) => { - const selector: RoomSelector = useCallback( - (roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId), - [mx, mDirects] - ); - return useSelectedRooms(roomsAtom, selector); -}; +export const useDirects = (ct: ClientContext, roomsAtom: RoomsAtom, mDirects: Set) => { + const spaceRooms = useObservableValue(ct.spaceRooms); -export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: RoomsAtom) => { const selector: RoomSelector = useCallback( - (roomId) => isUnsupportedRoom(mx.getRoom(roomId)), - [mx] + (roomId) => !spaceRooms.has(roomId) && mDirects.has(roomId), + [mDirects, spaceRooms] ); return useSelectedRooms(roomsAtom, selector); }; diff --git a/src/app/state/hooks/roomNotifications.ts b/src/app/state/hooks/roomNotifications.ts new file mode 100644 index 00000000..0e591f31 --- /dev/null +++ b/src/app/state/hooks/roomNotifications.ts @@ -0,0 +1,134 @@ +import { useCallback, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { UnreadNotificationCounts } from 'neurone/lib/api/types'; +import { + Notifications, + RoomToNotifications, + roomToNotificationsAtom, +} from '../room/roomToNotifications'; + +const compareUnreadNotifications = ( + n1?: UnreadNotificationCounts, + n2?: UnreadNotificationCounts +): boolean => + n1?.highlight_count === n2?.highlight_count && n1?.notification_count === n2?.notification_count; + +const compareNotifications = (n1?: Notifications, n2?: Notifications): boolean => { + if (n1 && n2) { + return ( + compareUnreadNotifications(n1, n2) && + n1.threads.size === n2.threads.size && + Array.from(n1.threads.keys()).every((threadId) => + compareUnreadNotifications(n1.threads.get(threadId), n2.threads.get(threadId)) + ) + ); + } + + return n1 === n2; +}; + +const compareRoomsNotifications = (rn1: RoomToNotifications, rn2: RoomToNotifications): boolean => + rn1.size === rn2.size && + Array.from(rn1.keys()).every((roomId) => compareNotifications(rn1.get(roomId), rn2.get(roomId))); + +export const useRoomsNotifications = ( + rooms: string[], + notificationsAtom: typeof roomToNotificationsAtom +): RoomToNotifications => { + const selector = useCallback( + (roomToNotifications: RoomToNotifications) => { + const selectedNotifications: RoomToNotifications = new Map(); + rooms.forEach((roomId) => { + const n = roomToNotifications.get(roomId); + if (n) selectedNotifications.set(roomId, n); + }); + + return selectedNotifications; + }, + [rooms] + ); + return useAtomValue(selectAtom(notificationsAtom, selector, compareRoomsNotifications)); +}; + +export const useRoomNotifications = ( + roomId: string, + notificationsAtom: typeof roomToNotificationsAtom +): Notifications | undefined => { + const selector = useCallback( + (roomToNotifications: RoomToNotifications) => roomToNotifications.get(roomId), + [roomId] + ); + return useAtomValue(selectAtom(notificationsAtom, selector, compareNotifications)); +}; + +export type TotalNotifications = { + count: number; + highlight: boolean; +}; + +const getTotalCount = (n: UnreadNotificationCounts): number => { + const highlight = n.highlight_count ?? 0; + const total = n.notification_count ?? 0; + + return total >= highlight ? total : highlight; +}; + +const setTotal = (total: TotalNotifications, n: UnreadNotificationCounts) => { + // eslint-disable-next-line no-param-reassign + total.count += getTotalCount(n); + + if (!total.highlight && n.highlight_count && n.highlight_count > 0) { + // eslint-disable-next-line no-param-reassign + total.highlight = true; + } +}; + +export const getRoomsTotalNotifications = ( + roomsToNotifications: RoomToNotifications +): TotalNotifications => { + const total: TotalNotifications = { + count: 0, + highlight: false, + }; + + roomsToNotifications.forEach((n) => { + setTotal(total, n); + n.threads.forEach((tn) => setTotal(total, tn)); + }); + + return total; +}; + +export const useRoomsTotalNotifications = (roomsToNotifications: RoomToNotifications) => + useMemo(() => getRoomsTotalNotifications(roomsToNotifications), [roomsToNotifications]); + +export const getRoomTotalNotifications = (notifications?: Notifications): TotalNotifications => { + const total: TotalNotifications = { + count: 0, + highlight: false, + }; + + setTotal(total, notifications ?? {}); + notifications?.threads.forEach((tn) => setTotal(total, tn)); + + return total; +}; + +export const useRoomTotalNotifications = (notifications?: Notifications) => + useMemo(() => getRoomTotalNotifications(notifications), [notifications]); + +export const getThreadsTotalNotifications = ( + threadsNotifications: Map +): TotalNotifications => { + const total: TotalNotifications = { + count: 0, + highlight: false, + }; + threadsNotifications.forEach((tn) => setTotal(total, tn)); + + return total; +}; +export const useThreadsTotalNotifications = ( + threadsNotifications: Map +) => useMemo(() => getThreadsTotalNotifications(threadsNotifications), [threadsNotifications]); diff --git a/src/app/state/hooks/unread.ts b/src/app/state/hooks/unread.ts index 62386d02..bcd42693 100644 --- a/src/app/state/hooks/unread.ts +++ b/src/app/state/hooks/unread.ts @@ -1,46 +1,12 @@ -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((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; -}; +import { Unread } from '../../../types/matrix/room'; +import { roomToUnreadAtom } from '../room/roomToUnread'; 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)); -}; +): Unread | undefined => undefined; 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)); -}; +): Unread | undefined => undefined; diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts index d4572ff4..eed9e68c 100644 --- a/src/app/state/hooks/useBindAtoms.ts +++ b/src/app/state/hooks/useBindAtoms.ts @@ -1,17 +1,22 @@ -import { MatrixClient } from 'matrix-js-sdk'; import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList'; import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList'; import { mDirectAtom, useBindMDirectAtom } from '../mDirectList'; -import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; +import { + roomToNotificationsAtom, + useBindRoomToNotificationsAtom, +} from '../room/roomToNotifications'; +import { ClientContext } from '../../../client/context'; +import { unreadRoomsAtom, useBindUnreadRoomsAtom } from '../room/roomToUnread'; -export const useBindAtoms = (mx: MatrixClient) => { - useBindMDirectAtom(mx, mDirectAtom); - useBindAllInvitesAtom(mx, allInvitesAtom); - useBindAllRoomsAtom(mx, allRoomsAtom); - useBindRoomToParentsAtom(mx, roomToParentsAtom); - useBindRoomToUnreadAtom(mx, roomToUnreadAtom); +export const useBindAtoms = (ct: ClientContext) => { + useBindMDirectAtom(ct, mDirectAtom); + useBindAllInvitesAtom(ct, allInvitesAtom); + useBindAllRoomsAtom(ct, allRoomsAtom); + useBindRoomToParentsAtom(ct, roomToParentsAtom); + useBindRoomToNotificationsAtom(ct, roomToNotificationsAtom); + useBindUnreadRoomsAtom(ct, unreadRoomsAtom); - useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom); + useBindRoomIdToTypingMembersAtom(ct, roomIdToTypingMembersAtom); }; diff --git a/src/app/state/list.ts b/src/app/state/list.ts index 4377e532..98336e88 100644 --- a/src/app/state/list.ts +++ b/src/app/state/list.ts @@ -6,9 +6,9 @@ export type ListAction = item: T | T[]; } | { - type: 'REPLACE'; - item: T; - replacement: T; + type: 'REPLACE'; + item: T; + replacement: T; } | { type: 'DELETE'; @@ -17,7 +17,7 @@ export type ListAction = export const createListAtom = () => { const baseListAtom = atom([]); - return atom], undefined>( + return atom], void>( (get) => get(baseListAtom), (get, set, action) => { const items = get(baseListAtom); @@ -34,9 +34,12 @@ export const createListAtom = () => { return; } if (action.type === 'REPLACE') { - set(baseListAtom, items.map((item) => item === action.item ? action.replacement : item)); + set( + baseListAtom, + items.map((item) => (item === action.item ? action.replacement : item)) + ); } } ); }; -export type TListAtom = ReturnType>; \ No newline at end of file +export type TListAtom = ReturnType>; diff --git a/src/app/state/mDirectList.ts b/src/app/state/mDirectList.ts index 95165255..4f9b58ea 100644 --- a/src/app/state/mDirectList.ts +++ b/src/app/state/mDirectList.ts @@ -1,46 +1,19 @@ import { atom, useSetAtom } from 'jotai'; -import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { useEffect } from 'react'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getAccountData, getMDirects } from '../utils/room'; +import { ClientContext } from '../../client/context'; -export type MDirectAction = { - type: 'INITIALIZE' | 'UPDATE'; - rooms: Set; -}; +export const mDirectAtom = atom(new Set()); -const baseMDirectAtom = atom(new Set()); -export const mDirectAtom = atom, [MDirectAction], undefined>( - (get) => get(baseMDirectAtom), - (get, set, action) => { - set(baseMDirectAtom, action.rooms); - } -); - -export const useBindMDirectAtom = (mx: MatrixClient, mDirect: typeof mDirectAtom) => { +export const useBindMDirectAtom = (ct: ClientContext, mDirect: typeof mDirectAtom) => { const setMDirect = useSetAtom(mDirect); useEffect(() => { - const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct); - if (mDirectEvent) { - setMDirect({ - type: 'INITIALIZE', - rooms: getMDirects(mDirectEvent), - }); - } + setMDirect(ct.directRooms.read); - const handleAccountData = (event: MatrixEvent) => { - if (event.getType() === AccountDataEvent.Direct) { - setMDirect({ - type: 'UPDATE', - rooms: getMDirects(event), - }); - } - }; + const unsubscribe = ct.directRooms.subscribe((rooms) => { + setMDirect(rooms); + }); - mx.on(ClientEvent.AccountData, handleAccountData); - return () => { - mx.removeListener(ClientEvent.AccountData, handleAccountData); - }; - }, [mx, setMDirect]); + return unsubscribe; + }, [ct, setMDirect]); }; diff --git a/src/app/state/room-list/inviteList.ts b/src/app/state/room-list/inviteList.ts index 4fa97be4..0b41f8dc 100644 --- a/src/app/state/room-list/inviteList.ts +++ b/src/app/state/room-list/inviteList.ts @@ -1,32 +1,19 @@ -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'; +import { atom, useSetAtom } from 'jotai'; +import { useEffect } from 'react'; +import { ClientContext } from '../../../client/context'; -const baseRoomsAtom = atom([]); -export const allInvitesAtom = atom( - (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 allInvitesAtom = atom([]); + +export const useBindAllInvitesAtom = (ct: ClientContext, allRooms: typeof allInvitesAtom) => { + const setRoomsAtom = useSetAtom(allRooms); + + useEffect(() => { + setRoomsAtom(ct.rooms.invite.read); + + const unsubscribe = ct.rooms.invite.subscribe((rooms) => { + setRoomsAtom(rooms); }); - } -); -export const useBindAllInvitesAtom = ( - mx: MatrixClient, - allRooms: WritableAtom -) => { - useBindRoomsWithMembershipsAtom( - mx, - allRooms, - useMemo(() => [Membership.Invite], []) - ); + return unsubscribe; + }, [ct, setRoomsAtom]); }; diff --git a/src/app/state/room-list/roomList.ts b/src/app/state/room-list/roomList.ts index 736a0bc2..fbc60cc1 100644 --- a/src/app/state/room-list/roomList.ts +++ b/src/app/state/room-list/roomList.ts @@ -1,28 +1,19 @@ -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'; +import { atom, useSetAtom } from 'jotai'; +import { useEffect } from 'react'; +import { ClientContext } from '../../../client/context'; -const baseRoomsAtom = atom([]); -export const allRoomsAtom = atom( - (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 allRoomsAtom = atom([]); + +export const useBindAllRoomsAtom = (ct: ClientContext, allRooms: typeof allRoomsAtom) => { + const setRoomsAtom = useSetAtom(allRooms); + + useEffect(() => { + setRoomsAtom(ct.rooms.join.read); + + const unsubscribe = ct.rooms.join.subscribe((rooms) => { + setRoomsAtom(rooms); }); - } -); -export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => { - useBindRoomsWithMembershipsAtom( - mx, - allRooms, - useMemo(() => [Membership.Join], []) - ); + + return unsubscribe; + }, [ct, setRoomsAtom]); }; diff --git a/src/app/state/room-list/utils.ts b/src/app/state/room-list/utils.ts index 1ca7e7de..c8f20289 100644 --- a/src/app/state/room-list/utils.ts +++ b/src/app/state/room-list/utils.ts @@ -1,65 +1,3 @@ -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, - 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 index 5d8ec8c7..f4d57415 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -2,29 +2,26 @@ import { atom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { Descendant } from 'slate'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import { IEventRelation } from 'matrix-js-sdk'; -import { createUploadAtomFamily } from '../upload'; -import { TUploadContent } from '../../utils/matrix'; +import { createUploadContentToUploadAtom } from '../upload'; +import { UploadContent } from '../../utils/matrix'; import { createListAtom } from '../list'; -export type TUploadMetadata = { +export const getComposerId = (roomId: string, threadId?: string) => `${roomId}${threadId ?? ''}`; + +export type UploadMetadata = { markedAsSpoiler: boolean; }; -export type TUploadItem = { - file: TUploadContent; - originalFile: TUploadContent; - metadata: TUploadMetadata; +export type UploadItem = { + file: UploadContent; + originalFile: UploadContent; + metadata: UploadMetadata; encInfo: EncryptedAttachmentInfo | undefined; }; +export type UploadListAtom = ReturnType>; +export const composerToUploadItemsAtom = atomFamily(createListAtom); -export type TUploadListAtom = ReturnType>; - -export const roomIdToUploadItemsAtomFamily = atomFamily( - createListAtom -); - -export const roomUploadAtomFamily = createUploadAtomFamily(); +export const roomUploadContentToUploadAtom = createUploadContentToUploadAtom(); export type RoomIdToMsgAction = | { @@ -37,21 +34,16 @@ export type RoomIdToMsgAction = roomId: string; }; -const createMsgDraftAtom = () => atom([]); -export type TMsgDraftAtom = ReturnType; -export const roomIdToMsgDraftAtomFamily = atomFamily(() => - createMsgDraftAtom() -); +const createDraftAtom = () => atom([]); +export type DraftAtom = ReturnType; +export const composerToDraftAtom = atomFamily(() => createDraftAtom()); -export type IReplyDraft = { +export type ReplyDraft = { userId: string; eventId: string; - body: string; - formattedBody?: string | undefined; - relation?: IEventRelation | undefined; }; -const createReplyDraftAtom = () => atom(undefined); -export type TReplyDraftAtom = ReturnType; -export const roomIdToReplyDraftAtomFamily = atomFamily(() => +const createReplyDraftAtom = () => atom(undefined); +export type ReplyDraftAtom = ReturnType; +export const composerToReplyDraftAtom = atomFamily(() => createReplyDraftAtom() ); diff --git a/src/app/state/room/roomToNotifications.ts b/src/app/state/room/roomToNotifications.ts new file mode 100644 index 00000000..8da2c82d --- /dev/null +++ b/src/app/state/room/roomToNotifications.ts @@ -0,0 +1,84 @@ +import { atom, useSetAtom } from 'jotai'; +import { useCallback, useEffect } from 'react'; +import { UnreadNotificationCounts } from 'neurone/lib/api/types'; +import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences'; +import { ClientContext } from '../../../client/context'; +import { AsyncStatus, useAsyncCallbackValue } from '../../hooks/useAsyncCallback'; + +export type Notifications = UnreadNotificationCounts & { + threads: Map; +}; +export type RoomToNotifications = Map; + +export const roomToNotificationsAtom = atom(new Map()); + +export const useBindRoomToNotificationsAtom = ( + ct: ClientContext, + notificationsAtom: typeof roomToNotificationsAtom +) => { + const setNotificationAtom = useSetAtom(notificationsAtom); + const roomsNotificationPreferences = useRoomsNotificationPreferencesContext(); + + const [unreadState] = useAsyncCallbackValue( + useCallback(async () => { + const notifications = await ct.storage.unreadNotifications.observableRead.getAll(); + const threadNotifications = + await ct.storage.unreadThreadNotifications.observableRead.getAll(); + + return { + notifications, + threadNotifications, + }; + }, [ct]) + ); + + const unread = unreadState.status === AsyncStatus.Success ? unreadState.data : undefined; + + const processNotifications = useCallback(() => { + console.log('roomToNotifications'); + const notifications = unread?.notifications.read ?? []; + const threadNotifications = unread?.threadNotifications.read ?? []; + const { mute } = roomsNotificationPreferences; + + const roomToThreadsNotifications = new Map>(); + threadNotifications.forEach((item) => { + if (mute.has(item.roomId)) return; + + const tn: Map = + roomToThreadsNotifications.get(item.roomId) ?? new Map(); + tn.set(item.threadId, item.unread); + roomToThreadsNotifications.set(item.roomId, tn); + }); + + const roomToNotifications: RoomToNotifications = new Map(); + notifications.forEach((item) => { + if (mute.has(item.id)) return; + + roomToNotifications.set(item.id, { + ...item.unread, + threads: new Map(), + }); + }); + + roomToThreadsNotifications.forEach((tn, roomId) => { + const n = roomToNotifications.get(roomId) ?? { threads: new Map() }; + n.threads = tn; + roomToNotifications.set(roomId, n); + }); + + setNotificationAtom(roomToNotifications); + }, [unread, setNotificationAtom, roomsNotificationPreferences]); + + useEffect(() => { + if (unread) { + processNotifications(); + } + + const unsubscribeN = unread?.notifications.subscribe(() => processNotifications()); + const unsubscribeTn = unread?.threadNotifications.subscribe(() => processNotifications()); + return () => { + unsubscribeN?.(); + unsubscribeTn?.(); + }; + }, [unread, processNotifications]); +}; diff --git a/src/app/state/room/roomToParents.ts b/src/app/state/room/roomToParents.ts index f66662ff..edc77cd4 100644 --- a/src/app/state/room/roomToParents.ts +++ b/src/app/state/room/roomToParents.ts @@ -1,124 +1,29 @@ -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; - }; +import { RoomToParents } from '../../../types/matrix/room'; +import { ClientContext } from '../../../client/context'; const baseRoomToParents = atom(new Map()); -export const roomToParentsAtom = atom( +export const roomToParentsAtom = atom( (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)); - }) - ); - } + (get, set, roomToParents) => { + set(baseRoomToParents, roomToParents); } ); export const useBindRoomToParentsAtom = ( - mx: MatrixClient, + ct: ClientContext, roomToParents: typeof roomToParentsAtom ) => { const setRoomToParents = useSetAtom(roomToParents); useEffect(() => { - setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) }); + setRoomToParents(ct.roomToParents.read); - const handleAddRoom = (room: Room) => { - if (isSpace(room) && room.getMyMembership() !== Membership.Invite) { - setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) }); - } - }; + const unsubscribe = ct.roomToParents.subscribe((data) => { + setRoomToParents(data); + }); - const handleMembershipChange = (room: Room, membership: string) => { - if (isSpace(room) && 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]); + return unsubscribe; + }, [ct, setRoomToParents]); }; diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index f7ef1b79..e500caf8 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -1,282 +1,42 @@ -import produce from 'immer'; import { atom, useSetAtom } 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 { - Membership, - NotificationType, - RoomToUnread, - UnreadInfo, - Unread, - StateEvent, -} from '../../../types/matrix/room'; -import { - getAllParents, - getNotificationType, - getUnreadInfo, - getUnreadInfos, - isNotificationEvent, -} from '../../utils/room'; -import { roomToParentsAtom } from './roomToParents'; -import { useStateEventCallback } from '../../hooks/useStateEventCallback'; -import { useSyncState } from '../../hooks/useSyncState'; -import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences'; +import { useEffect, useMemo } from 'react'; +import { ThreadId } from '../../../types/matrix/room'; +import { useObservableValue, useObservableValuePromise } from '../../hooks/useObservableValue'; +import { getReadReceiptId } from '../../../client/storage/collections'; +import { ClientContext } from '../../../client/context'; -export type RoomToUnreadAction = - | { - type: 'RESET'; - unreadInfos: UnreadInfo[]; - } - | { - type: 'PUT'; - unreadInfo: UnreadInfo; - } - | { - type: 'DELETE'; - roomId: string; - }; +export const roomToUnreadAtom = atom(new Map()); -export const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({ - highlight: unreadInfo.highlight, - total: unreadInfo.total, - from: null, -}); +export const unreadRoomsAtom = atom>(new Set()); -const putUnreadInfo = ( - roomToUnread: RoomToUnread, - allParents: Set, - unreadInfo: UnreadInfo -) => { - const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null }; - roomToUnread.set(unreadInfo.roomId, unreadInfoToUnread(unreadInfo)); +export const useBindUnreadRoomsAtom = (ct: ClientContext, unreadRooms: typeof unreadRoomsAtom) => { + const setUnreadRooms = useSetAtom(unreadRooms); + const roomToLatestEvent = useObservableValue(ct.roomToLatestEvent); - 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 receiptKeys: string[] = useMemo(() => { + const keys: string[] = []; + roomToLatestEvent.forEach((_, roomId) => { + keys.push(getReadReceiptId(roomId, ct.session.userId, ThreadId.Main)); }); - }); -}; -const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set, roomId: string) => { - const oldUnread = roomToUnread.get(roomId); - if (!oldUnread) return; - roomToUnread.delete(roomId); + return keys; + }, [roomToLatestEvent, ct]); - 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 receiptsResult = useObservableValuePromise( + useMemo(() => ct.storage.readReceipt.observableRead.getMultiple(receiptKeys), [ct, receiptKeys]) + ); -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(new Map()); -export const roomToUnreadAtom = atom( - (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 - ); + useEffect(() => { + const receipts = receiptsResult.value; + if (receipts) { + const rooms: string[] = []; + Array.from(roomToLatestEvent.entries()).forEach(([roomId, event], index) => { + const receipt = receipts[index]; + if (!receipt || receipt.ts > event.origin_server_ts) return; + rooms.push(roomId); }); - set(baseRoomToUnread, draftRoomToUnread); - return; + + setUnreadRooms(new Set(rooms)); } - 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) => { - const setUnreadAtom = useSetAtom(unreadAtom); - const roomsNotificationPreferences = useRoomsNotificationPreferencesContext(); - - useEffect(() => { - setUnreadAtom({ - type: 'RESET', - unreadInfos: getUnreadInfos(mx), - }); - }, [mx, setUnreadAtom]); - - useSyncState( - mx, - useCallback( - (state, prevState) => { - if ( - (state === SyncState.Prepared && prevState === null) || - (state === SyncState.Syncing && prevState !== SyncState.Syncing) - ) { - 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) => { - const myUserId = mx.getUserId(); - if (!myUserId) return; - if (room.isSpaceRoom()) return; - const content = mEvent.getContent(); - - const isMyReceipt = Object.keys(content).find((eventId) => - (Object.keys(content[eventId]) as ReceiptType[]).find( - (receiptType) => content[eventId][receiptType][myUserId] - ) - ); - if (isMyReceipt) { - setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); - } - }; - mx.on(RoomEvent.Receipt, handleReceipt); - return () => { - mx.removeListener(RoomEvent.Receipt, handleReceipt); - }; - }, [mx, setUnreadAtom]); - - useEffect(() => { - setUnreadAtom({ - type: 'RESET', - unreadInfos: getUnreadInfos(mx), - }); - }, [mx, setUnreadAtom, roomsNotificationPreferences]); - - 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] - ) - ); + }, [setUnreadRooms, receiptsResult, roomToLatestEvent]); }; diff --git a/src/app/state/spaceRooms.ts b/src/app/state/spaceRooms.ts index 94abe2bc..ab6584e7 100644 --- a/src/app/state/spaceRooms.ts +++ b/src/app/state/spaceRooms.ts @@ -30,7 +30,7 @@ type SpaceRoomsAction = roomIds: string[]; }; -export const spaceRoomsAtom = atom, [SpaceRoomsAction], undefined>( +export const spaceRoomsAtom = atom, [SpaceRoomsAction], void>( (get) => get(baseSpaceRoomsAtom), (get, set, action) => { const current = get(baseSpaceRoomsAtom); diff --git a/src/app/state/typingMembers.ts b/src/app/state/typingMembers.ts index e94ba972..5ff57af6 100644 --- a/src/app/state/typingMembers.ts +++ b/src/app/state/typingMembers.ts @@ -1,155 +1,46 @@ -import produce from 'immer'; import { atom, useSetAtom } from 'jotai'; -import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk'; import { useEffect } from 'react'; +import { roomTypingEphemeral } from 'neurone/lib/utils/sync/v3/join'; import { useSetting } from './hooks/settings'; import { settingsAtom } from './settings'; +import { ClientContext } from '../../client/context'; -export const TYPING_TIMEOUT_MS = 5000; // 5 seconds +export type IRoomIdToTypingMembers = Map; -export type TypingReceipt = { - userId: string; - ts: number; -}; -export type IRoomIdToTypingMembers = Map; - -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(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], - undefined ->( - (get) => get(baseRoomIdToTypingMembersAtom), - (get, set, action) => { - const rToTyping = get(baseRoomIdToTypingMembersAtom); - - if (action.type === 'PUT') { - 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)) - ); - } - } -); +export const roomIdToTypingMembersAtom = atom(new Map()); export const useBindRoomIdToTypingMembersAtom = ( - mx: MatrixClient, + ct: ClientContext, typingMembersAtom: typeof roomIdToTypingMembersAtom ) => { const setTypingMembers = useSetAtom(typingMembersAtom); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); useEffect(() => { - const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = ( - event, - member - ) => { + const unsubscribe = ct.sync.observable.subscribe((syncValue) => { if (hideActivity) { + setTypingMembers(new Map()); return; } - setTypingMembers({ - type: member.typing ? 'PUT' : 'DELETE', - roomId: member.roomId, - userId: member.userId, - ts: Date.now(), - }); - }; - mx.on(RoomMemberEvent.Typing, handleTypingEvent); - return () => { - mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent); - }; - }, [mx, setTypingMembers, hideActivity]); + const [, data] = syncValue ?? []; + const { join } = data?.rooms ?? {}; + if (!join) return; + + const roomToUsers: IRoomIdToTypingMembers = new Map(); + + Object.keys(join).forEach((roomId) => { + const ephemeralEvents = join[roomId].ephemeral?.events; + const typingEvent = ephemeralEvents && roomTypingEphemeral(ephemeralEvents); + const users = typingEvent?.content.user_ids ?? []; + if (users.length > 0) { + roomToUsers.set(roomId, users); + } + }); + + setTypingMembers(roomToUsers); + }); + + return unsubscribe; + }, [ct, setTypingMembers, hideActivity]); }; diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts index 810c0d2e..35d599a7 100644 --- a/src/app/state/upload.ts +++ b/src/app/state/upload.ts @@ -1,9 +1,12 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; -import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; +import { MatrixError } from 'neurone/lib/matrix-request'; +import { postUpload, PostUploadOK } from 'neurone/lib/api/matrix/media/v3/upload'; import { useCallback } from 'react'; import { useThrottle } from '../hooks/useThrottle'; -import { uploadContent, TUploadContent } from '../utils/matrix'; +import { UploadContent } from '../utils/matrix'; +import { getAuthUploadRequest, UploadProgress } from '../../client/request'; +import { useClient } from '../hooks/useClient'; export enum UploadStatus { Idle = 'idle', @@ -13,34 +16,34 @@ export enum UploadStatus { } export type UploadIdle = { - file: TUploadContent; + file: UploadContent; status: UploadStatus.Idle; }; export type UploadLoading = { - file: TUploadContent; + file: UploadContent; status: UploadStatus.Loading; - promise: Promise; + abortController: AbortController; progress: UploadProgress; }; export type UploadSuccess = { - file: TUploadContent; + file: UploadContent; status: UploadStatus.Success; mxc: string; }; export type UploadError = { - file: TUploadContent; + file: UploadContent; status: UploadStatus.Error; - error: MatrixError; + error: MatrixError | Error; }; export type Upload = UploadIdle | UploadLoading | UploadSuccess | UploadError; export type UploadAtomAction = | { - promise: Promise; + abortController: AbortController; } | { progress: UploadProgress; @@ -49,10 +52,10 @@ export type UploadAtomAction = mxc: string; } | { - error: MatrixError; + error: MatrixError | Error; }; -export const createUploadAtom = (file: TUploadContent) => { +export const createUploadAtom = (file: UploadContent) => { const baseUploadAtom = atom({ file, status: UploadStatus.Idle, @@ -61,11 +64,11 @@ export const createUploadAtom = (file: TUploadContent) => { (get) => get(baseUploadAtom), (get, set, update) => { const uploadState = get(baseUploadAtom); - if ('promise' in update) { + if ('abortController' in update) { set(baseUploadAtom, { status: UploadStatus.Loading, file, - promise: update.promise, + abortController: update.abortController, progress: { loaded: 0, total: file.size }, }); return; @@ -95,13 +98,10 @@ export const createUploadAtom = (file: TUploadContent) => { } ); }; -export type TUploadAtom = ReturnType; +export type UploadAtom = ReturnType; -export const useBindUploadAtom = ( - mx: MatrixClient, - uploadAtom: TUploadAtom, - hideFilename?: boolean -) => { +export const useBindUploadAtom = (uploadAtom: UploadAtom, hideFilename?: boolean) => { + const ct = useClient(); const [upload, setUpload] = useAtom(uploadAtom); const { file } = upload; @@ -110,23 +110,46 @@ export const useBindUploadAtom = ( { immediate: true, wait: 200 } ); - const startUpload = useCallback( - () => - uploadContent(mx, file, { - hideFilename, - onPromise: (promise: Promise) => setUpload({ promise }), - onProgress: handleProgress, - onSuccess: (mxc) => setUpload({ mxc }), - onError: (error) => setUpload({ error }), + const startUpload = useCallback(() => { + const request = getAuthUploadRequest( + ct.session.baseUrl, + ct.session.accessToken, + handleProgress + ); + const abortController = new AbortController(); + + request( + postUpload({ + searchParams: { + filename: hideFilename ? undefined : file.name, + }, + body: file, }), - [mx, file, hideFilename, setUpload, handleProgress] - ); + abortController.signal + ) + .then(([error, data]) => { + if (error) { + setUpload({ error }); + return; + } + setUpload({ mxc: data.content_uri }); + }) + .catch((anyError) => { + if (anyError instanceof Error) { + setUpload({ error: anyError }); + return; + } + setUpload({ error: new Error('Unknown Error') }); + }); + + setUpload({ abortController }); + }, [ct, file, hideFilename, setUpload, handleProgress]); const cancelUpload = useCallback(async () => { if (upload.status === UploadStatus.Loading) { - await mx.cancelUpload(upload.promise); + upload.abortController.abort(); } - }, [mx, upload]); + }, [upload]); return { upload, @@ -135,12 +158,11 @@ export const useBindUploadAtom = ( }; }; -export const createUploadAtomFamily = () => - atomFamily(createUploadAtom); -export type TUploadAtomFamily = ReturnType; +export const createUploadContentToUploadAtom = () => + atomFamily(createUploadAtom); -export const createUploadFamilyObserverAtom = ( - uploadFamily: TUploadAtomFamily, - uploads: TUploadContent[] +export const createUploadsAtom = ( + uploadFamily: ReturnType, + uploads: UploadContent[] ) => atom((get) => uploads.map((upload) => get(uploadFamily(upload)))); -export type TUploadFamilyObserverAtom = ReturnType; +export type UploadsAtom = ReturnType; diff --git a/src/app/utils/disposable.ts b/src/app/utils/disposable.ts deleted file mode 100644 index 74424154..00000000 --- a/src/app/utils/disposable.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type DisposeCallback = ( - ...args: DisposeArgs -) => DisposeReturn; -export type DisposableContext< - DisposableArgs extends unknown[] = [], - DisposeArgs extends unknown[] = [], - DisposeReturn = void -> = (...args: DisposableArgs) => DisposeCallback; - -export const disposable = < - DisposableArgs extends unknown[], - DisposeArgs extends unknown[] = [], - DisposeReturn = void ->( - context: DisposableContext -) => context; diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 80db3ae7..5b8a75a1 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -173,16 +173,16 @@ export type ScrollInfo = { scrollable: boolean; }; export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({ - offsetTop: Math.round(target.offsetTop), - top: Math.round(target.scrollTop), - height: Math.round(target.scrollHeight), - viewHeight: Math.round(target.offsetHeight), + offsetTop: target.offsetTop, + top: target.scrollTop, + height: target.scrollHeight, + viewHeight: target.offsetHeight, scrollable: target.scrollHeight > target.offsetHeight, }); export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => { scrollEl.scrollTo({ - top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight), + top: scrollEl.scrollHeight - scrollEl.offsetHeight, behavior, }); }; diff --git a/src/app/utils/featureCheck.ts b/src/app/utils/featureCheck.ts index a9474c31..6b2cb2e4 100644 --- a/src/app/utils/featureCheck.ts +++ b/src/app/utils/featureCheck.ts @@ -1,21 +1,28 @@ export const checkIndexedDBSupport = async (): Promise => { - const ts = new Date().getTime(); - const dbName = `checkIndexedDBSupport-${ts}`; + const dbName = `checkIndexedDBSupport-${Date.now()}`; + return new Promise((resolve) => { - let db; + let request: IDBOpenDBRequest; + try { - db = indexedDB.open(dbName); + request = indexedDB.open(dbName); } catch { resolve(false); return; } - db.onsuccess = () => { + + request.onsuccess = () => { + const db = request.result; + + db.close(); + + indexedDB.deleteDatabase(dbName); + resolve(true); - indexedDB.deleteDatabase(dbName); }; - db.onerror = () => { + + request.onerror = () => { resolve(false); - indexedDB.deleteDatabase(dbName); }; }); }; diff --git a/src/app/utils/matrix-uia.ts b/src/app/utils/matrix-uia.ts index 15c5799c..b174f678 100644 --- a/src/app/utils/matrix-uia.ts +++ b/src/app/utils/matrix-uia.ts @@ -1,4 +1,25 @@ -import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk'; +export enum AuthType { + Password = 'm.login.password', + Recaptcha = 'm.login.recaptcha', + Terms = 'm.login.terms', + Email = 'm.login.email.identity', + Msisdn = 'm.login.msisdn', + Sso = 'm.login.sso', + SsoUnstable = 'org.matrix.login.sso', + Dummy = 'm.login.dummy', + RegistrationToken = 'm.login.registration_token', +} + +export type UIAFlow = { + stages: Array; +}; + +export type AuthResponse = { + session?: string; + completed?: string[]; + flows?: UIAFlow[]; + params?: Record>; +}; export const getSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] => { const supportedUIAFlows = uiaFlows.filter((flow) => @@ -8,30 +29,30 @@ export const getSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: strin return supportedUIAFlows; }; -export const getUIACompleted = (authData: IAuthData): string[] => { +export const getUIACompleted = (authData: AuthResponse): string[] => { const completed = authData.completed ?? []; return completed; }; export type UIAParams = Record>; -export const getUIAParams = (authData: IAuthData): UIAParams => { +export const getUIAParams = (authData: AuthResponse): UIAParams => { const params = authData.params ?? {}; return params; }; -export const getUIASession = (authData: IAuthData): string | undefined => { +export const getUIASession = (authData: AuthResponse): string | undefined => { const session = authData.session ?? undefined; return session; }; -export const getUIAErrorCode = (authData: IAuthData): string | undefined => { +export const getUIAErrorCode = (authData: AuthResponse): string | undefined => { const errorCode = 'errcode' in authData && typeof authData.errcode === 'string' ? authData.errcode : undefined; return errorCode; }; -export const getUIAError = (authData: IAuthData): string | undefined => { +export const getUIAError = (authData: AuthResponse): string | undefined => { const errorCode = 'error' in authData && typeof authData.error === 'string' ? authData.error : undefined; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e2..848fed57 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -3,21 +3,17 @@ import { decryptAttachment, encryptAttachment, } from 'browser-encrypt-attachment'; -import { - EventTimeline, - MatrixClient, - MatrixError, - MatrixEvent, - Room, - RoomMember, - UploadProgress, - UploadResponse, -} from 'matrix-js-sdk'; +import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import to from 'await-to-js'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; +import { MatrixError } from 'neurone/lib/matrix-request'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; -import { AccountDataEvent } from '../../types/matrix/accountData'; +import { AccountDataType } from '../../types/matrix/accountData'; import { getStateEvent } from './room'; -import { Membership, StateEvent } from '../../types/matrix/room'; +import { StateEvent } from '../../types/matrix/room'; +import { ClientContext } from '../../client/context'; +import { JoinRoomMemberObject } from '../../client/storage/collections'; +import { rError } from '../../client/reader'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -133,43 +129,7 @@ export const decryptFile = async ( return blob; }; -export type TUploadContent = File | Blob; - -export type ContentUploadOptions = { - name?: string; - fileType?: string; - hideFilename?: boolean; - onPromise?: (promise: Promise) => void; - onProgress?: (progress: UploadProgress) => void; - onSuccess: (mxc: string) => void; - onError: (error: MatrixError) => void; -}; - -export const uploadContent = async ( - mx: MatrixClient, - file: TUploadContent, - options: ContentUploadOptions -) => { - const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; - - const uploadPromise = mx.uploadContent(file, { - name, - type: fileType, - includeFilename: !hideFilename, - progressHandler: onProgress, - }); - onPromise?.(uploadPromise); - try { - const data = await uploadPromise; - const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); - } catch (e: any) { - const error = typeof e?.message === 'string' ? e.message : undefined; - const errcode = typeof e?.name === 'string' ? e.message : undefined; - onError(new MatrixError({ error, errcode })); - } -}; +export type UploadContent = File | Blob; export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs(); @@ -179,62 +139,61 @@ export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) => export const eventWithShortcode = (ev: MatrixEvent) => typeof ev.getContent().shortcode === 'string'; -export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => { - const dmLikeRooms = mx - .getRooms() - .filter( - (room) => - room.getMyMembership() === Membership.Join && - room.hasEncryptionStateEvent() && - room.getMembers().length <= 2 +export const getUserDirectRooms = (ct: ClientContext, userId: string): string[] => { + const rooms = Object.keys(ct.roomToSummary.read); + const directRooms = rooms.filter((roomId) => { + const summary = ct.roomToSummary.read.get(roomId); + const heroes = summary?.['m.heroes']; + return ( + Array.isArray(heroes) && + heroes.length === 1 && + heroes[1] === userId && + ct.directRooms.read.has(roomId) ); + }); - return dmLikeRooms.find((room) => room.getMember(userId)); + return directRooms; }; -export const guessDmRoomUserId = (room: Room, myUserId: string): string => { - const getOldestMember = (members: RoomMember[]): RoomMember | undefined => { - let oldestMemberTs: number | undefined; - let oldestMember: RoomMember | undefined; +export const guessDmRoomUserId = (members: JoinRoomMemberObject[], myUserId: string): string => { + let oldestMemberTs: number | undefined; + let oldestMember: JoinRoomMemberObject | undefined; - const pickOldestMember = (member: RoomMember) => { - if (member.userId === myUserId) return; + const pickOldestMember = (member: JoinRoomMemberObject) => { + 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; + if (oldestMemberTs === undefined || member.event.origin_server_ts < oldestMemberTs) { + oldestMember = member; + oldestMemberTs = member.event.origin_server_ts; + } }; - // Pick the joined user who's been here longest (and isn't us), - const member = getOldestMember(room.getJoinedMembers()); - if (member) return member.userId; + members.forEach(pickOldestMember); - // if there are no joined members other than us, use the oldest member - const member1 = getOldestMember( - room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getMembers() ?? [] - ); - return member1?.userId ?? myUserId; + return oldestMember?.userId ?? myUserId; }; +const getUserIdToRoomIds = (content?: Record): Record => { + const userIdToRoomIds: Record = {}; + + if (typeof content === 'object' && content) { + Object.keys(content).forEach((uId) => { + const roomIds = content[uId]; + if (Array.isArray(roomIds)) { + userIdToRoomIds[uId] = roomIds.filter((rId) => typeof rId === 'string'); + } + }); + } + return userIdToRoomIds; +}; export const addRoomIdToMDirect = async ( - mx: MatrixClient, + ct: ClientContext, roomId: string, userId: string ): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + const mDirectsEventObj = await ct.storage.accountData.read.get(AccountDataType.Direct); + const content = mDirectsEventObj?.event.content as Record | undefined; + const userIdToRoomIds = getUserIdToRoomIds(content); // remove it from the lists of any others users // (it can only be a DM room for one person) @@ -255,15 +214,18 @@ export const addRoomIdToMDirect = async ( } userIdToRoomIds[userId] = roomIds; - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); + await ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.Direct }, + body: userIdToRoomIds, + }) + ); }; -export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); +export const removeRoomIdFromMDirect = async (ct: ClientContext, roomId: string): Promise => { + const mDirectsEventObj = await ct.storage.accountData.read.get(AccountDataType.Direct); + const content = mDirectsEventObj?.event.content as Record | undefined; + const userIdToRoomIds = getUserIdToRoomIds(content); Object.keys(userIdToRoomIds).forEach((targetUserId) => { const roomIds = userIdToRoomIds[targetUserId]; @@ -273,7 +235,12 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): } }); - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); + await ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.Direct }, + body: userIdToRoomIds, + }) + ); }; export const mxcUrlToHttp = ( @@ -313,9 +280,9 @@ export const downloadEncryptedMedia = async ( return decryptedContent; }; -export const rateLimitedActions = async ( +export const rateLimitedActions = async ( data: T[], - callback: (item: T, index: number) => Promise, + callback: (item: T, index: number) => Promise, maxRetryCount?: number ) => { let retryCount = 0; @@ -328,14 +295,15 @@ export const rateLimitedActions = async ( }); const performAction = async (dataItem: T, index: number) => { - const [err] = await to(callback(dataItem, index)); + const [, err] = await to(callback(dataItem, index)); - if (err?.httpStatus === 429) { + if (err?.status === 429) { if (retryCount === maxRetryCount) { return; } - const waitMS = err.getRetryAfterMs() ?? 3000; + const retryAfterMs = rError.retryAfterMs(err.data) ?? undefined; + const waitMS = retryAfterMs ?? 3000; actionInterval = waitMS * 1.5; await sleepForMs(waitMS); retryCount += 1; diff --git a/src/app/utils/randomString.ts b/src/app/utils/randomString.ts new file mode 100644 index 00000000..ec15bd74 --- /dev/null +++ b/src/app/utils/randomString.ts @@ -0,0 +1,84 @@ +// https://github.com/matrix-org/matrix-js-sdk/blob/c7a75c882479fc54f24eadce963a0c5c0d3c4788/src/randomstring.ts + +/** + * String representing the lowercase latin alphabet for use in {@link secureRandomStringFrom} + * (can be combined with other such exports or other characters by appending strings) + */ +export const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz'; + +/** + * String representing the uppercase latin alphabet for use in secureRandomStringFrom + * (can be combined with other such exports or other characters by appending strings) + */ +export const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +/** + * String representing the arabic numerals for use in secureRandomStringFrom + * (can be combined with other such exports or other characters by appending strings) + */ +export const DIGITS = '0123456789'; + +/** + * Generate a cryptographically secure random string using characters given. + * + * @param len - The length of the string to generate (must be positive and less than 32768). + * @param chars - The characters to use in the random string (between 2 and 256 characters long). + * @returns Random string of characters of length `len`. + */ +export function secureRandomStringFrom(len: number, chars: string): string { + // This is intended for latin strings so 256 possibilities should be more than enough and + // means we can use random bytes, minimising the amount of entropy we need to ask for. + if (chars.length < 2 || chars.length > 256) { + throw new Error('Character set must be between 2 and 256 characters long'); + } + + if (len < 1 || len > 32768) { + throw new Error('Requested random string length must be between 1 and 32768'); + } + + // We'll generate random unsigned bytes, so get the largest number less than 256 that is a multiple + // of the length of the character set: We'll need to discard any random values that are larger than + // this as we can't possibly map them onto the character set while keeping each character equally + // likely to be chosen (minus 1 to convert to indices in a string). (Essentially, we're using a d8 + // to choose between 7 possibilities and re-rolling on an 8, keeping all 7 outcomes equally likely.) + // Our random values must be strictly less than this + const randomValueCutoff = 256 - (256 % chars.length); + + // Grab 30% more entropy than we need. This should be enough that we can discard the values that are + // too high without having to go back and grab more unless we're super unlucky. + const entropyBuffer = new Uint8Array(Math.floor(len * 1.3)); + // Mark all of this buffer as used to start with (we haven't populated it with entropy yet) so it will + // be filled on the first iteration. + let entropyBufferPos = entropyBuffer.length; + + const result = []; + while (result.length < len) { + if (entropyBufferPos === entropyBuffer.length) { + globalThis.crypto.getRandomValues(entropyBuffer); + entropyBufferPos = 0; + } + + const randomByte = entropyBuffer[entropyBufferPos]; + entropyBufferPos += 1; + + if (randomByte < randomValueCutoff) { + result.push(chars[randomByte % chars.length]); + } + } + + return result.join(''); +} + +/** + * Generates a random string of uppercase and lowercase letters plus digits using a + * cryptographically secure random number generator. + * @param len The length of the string to generate + * @returns Random string of uppercase and lowercase letters plus digits of length `len` + */ +export function secureRandomString(len: number): string { + return secureRandomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS); +} + +export function generateClientSecret(): string { + return secureRandomString(32); +} diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index b4bba2ad..85318405 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -1,26 +1,22 @@ import { IconName, IconSrc } from 'folds'; - import { EventTimeline, EventTimelineSet, EventType, - IMentions, - IPowerLevelsContent, IPushRule, IPushRules, - JoinRule, MatrixClient, MatrixEvent, MsgType, NotificationCountType, RelationType, Room, - RoomMember, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; +import { MessageEventWithoutRoomId, RoomMembership } from 'neurone/lib/api/types'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { - IRoomCreateContent, + JoinRule, Membership, MessageEvent, NotificationType, @@ -29,6 +25,10 @@ import { StateEvent, UnreadInfo, } from '../../types/matrix/room'; +import { JoinRoomMemberObject } from '../../client/storage/collections'; +import { MentionsContent, ReactionContent } from '../../types/matrix/common'; +import { ClientContext } from '../../client/context'; +import { rContent, rInReplyTo, rRelatesTo } from '../../client/reader'; export const getStateEvent = ( room: Room, @@ -261,16 +261,16 @@ export const joinRuleToIconSrc = ( joinRule: JoinRule, space: boolean ): IconSrc | undefined => { - if (joinRule === JoinRule.Restricted) { + if (joinRule === 'restricted') { return space ? icons.Space : icons.Hash; } - if (joinRule === JoinRule.Knock) { + if (joinRule === 'knock') { return space ? icons.SpaceLock : icons.HashLock; } - if (joinRule === JoinRule.Invite) { + if (joinRule === 'invite') { return space ? icons.SpaceLock : icons.HashLock; } - if (joinRule === JoinRule.Public) { + if (joinRule === 'public') { return space ? icons.SpaceGlobe : icons.HashGlobe; } return undefined; @@ -344,13 +344,40 @@ export const getMemberDisplayName = (room: Room, userId: string): string | undef return name; }; +export const roomMemberContentDisplayname = (content: object): string | undefined => { + const name = + 'displayname' in content && typeof content.displayname === 'string' + ? content.displayname.trim() + : undefined; + + return name; +}; + +export const roomMemberContentAvatarUrl = (content: object): string | undefined => { + const url = + 'avatar_url' in content && typeof content.avatar_url === 'string' + ? content.avatar_url.trim() + : undefined; + + return url?.startsWith('mxc://') ? url : undefined; +}; + +export const roomMemberContentMembership = (content: object): RoomMembership | undefined => { + const membership = + 'membership' in content && typeof content.membership === 'string' + ? (content.membership as RoomMembership) + : undefined; + + return membership; +}; + export const getMemberSearchStr = ( - member: RoomMember, + member: JoinRoomMemberObject, query: string, - mxIdToName: (mxId: string) => string + getName: (mxId: string) => string ): string[] => [ - member.rawDisplayName === member.userId ? mxIdToName(member.userId) : member.rawDisplayName, - query.startsWith('@') || query.indexOf(':') > -1 ? member.userId : mxIdToName(member.userId), + getName(member.userId), + query.startsWith('@') || query.indexOf(':') > -1 ? member.userId : getName(member.userId), ]; export const getMemberAvatarMxc = (room: Room, userId: string): string | undefined => { @@ -389,6 +416,92 @@ export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string EventType.Reaction ); +export type ReactionGroup = { + key: string; + users: Set; + shortcode?: string; + firstTs: number; +}; +export const getReactionGroups = ( + reactions: MessageEventWithoutRoomId[] +): Array => { + const map = new Map(); + + reactions.forEach((event) => { + const { key } = event.content['m.relates_to']; + const userId = event.sender; + const { shortcode } = event.content; + const ts = event.origin_server_ts; + + const existing = map.get(key); + + if (!existing) { + map.set(key, { + key, + users: new Set([userId]), + shortcode, + firstTs: ts, + }); + } else { + existing.users.add(userId); + + if (!existing.shortcode && shortcode) { + existing.shortcode = shortcode; + } + + // keep earliest timestamp + if (ts < existing.firstTs) { + existing.firstTs = ts; + } + } + }); + + return Array.from(map.values()).sort((a, b) => a.firstTs - b.firstTs); +}; + +export const getLatestEditEvent = ( + sender: string, + events: MessageEventWithoutRoomId[], + redacted: Set +): MessageEventWithoutRoomId | undefined => { + let event: MessageEventWithoutRoomId | undefined; + events.forEach((e) => { + if (redacted.has(e.event_id) || sender !== e.sender) return; + if (!event) { + event = e; + return; + } + if (event.origin_server_ts < e.origin_server_ts) { + event = e; + } + }); + + return event; +}; + +export const getReplyId = (content: object): string | undefined => + rInReplyTo.eventId(rRelatesTo.inReplyTo(rContent.relatesTo(content))); + +export const fallbackReply = (content: object): boolean => { + const relatesTo = rContent.relatesTo(content); + + if (rRelatesTo.relType(relatesTo) === RelationType.Thread) { + return rRelatesTo.isFallingBack(relatesTo) ?? false; + } + return false; +}; + +export const getThreadId = (event: MessageEventWithoutRoomId): string | undefined => { + const { content } = event; + + const relatesTo = rContent.relatesTo(content); + if (rRelatesTo.relType(relatesTo) === RelationType.Thread) { + return rRelatesTo.eventId(relatesTo); + } + + return undefined; +}; + export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) => timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType); @@ -410,16 +523,18 @@ export const getEditedEvent = ( return edits && getLatestEdit(mEvent, edits.getRelations()); }; -export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => { - const content = mEvent.getContent(); - const relationType = content['m.relates_to']?.rel_type; +export const canEditEvent = (ct: ClientContext, event: MessageEventWithoutRoomId) => { + const { content } = event; + const msgType = rContent.msgtype(content); + if (!msgType) return false; + + const relationType = rRelatesTo.relType(rContent.relatesTo(content)); + return ( - mEvent.getSender() === mx.getUserId() && + event.sender === ct.session.userId && (!relationType || relationType === RelationType.Thread) && - mEvent.getType() === MessageEvent.RoomMessage && - (content.msgtype === MsgType.Text || - content.msgtype === MsgType.Emote || - content.msgtype === MsgType.Notice) + event.type === MessageEvent.RoomMessage && + (msgType === MsgType.Text || msgType === MsgType.Emote || msgType === MsgType.Notice) ); }; @@ -440,8 +555,8 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent) => mEvent.getRelation()?.rel_type === RelationType.Annotation || mEvent.getRelation()?.rel_type === RelationType.Replace; -export const getMentionContent = (userIds: string[], room: boolean): IMentions => { - const mMentions: IMentions = {}; +export const getMentionContent = (userIds: string[], room: boolean): MentionsContent => { + const mMentions: MentionsContent = {}; if (userIds.length > 0) { mMentions.user_ids = userIds; } @@ -481,27 +596,16 @@ export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: st return banned; }); -export const getAllVersionsRoomCreator = (room: Room): Set => { - const creators = new Set(); - - const createEvent = getStateEvent(room, StateEvent.RoomCreate); - const createContent = createEvent?.getContent(); - const creator = createEvent?.getSender(); - if (typeof creator === 'string') creators.add(creator); - - if (createContent && Array.isArray(createContent.additional_creators)) { - createContent.additional_creators.forEach((c) => { - if (typeof c === 'string') creators.add(c); - }); - } - - return creators; -}; - export const guessPerfectParent = ( - mx: MatrixClient, roomId: string, - parents: string[] + parents: string[], + getCreators: (roomId: string) => Set | undefined, + getPowerLevelContent: (roomId: string) => + | { + users?: Record; + users_default?: number; + } + | undefined ): string | undefined => { if (parents.length === 1) { return parents[0]; @@ -510,17 +614,9 @@ export const guessPerfectParent = ( const getSpecialUsers = (rId: string): string[] => { const specialUsers: Set = new Set(); - const r = mx.getRoom(rId); - if (!r) return []; + getCreators(rId)?.forEach((c) => specialUsers.add(c)); - getAllVersionsRoomCreator(r).forEach((c) => specialUsers.add(c)); - - const powerLevels = getStateEvent( - r, - StateEvent.RoomPowerLevels - )?.getContent(); - - const { users_default: usersDefault, users } = powerLevels ?? {}; + const { users_default: usersDefault, users } = getPowerLevelContent(rId) ?? {}; const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0; if (typeof users === 'object') diff --git a/src/app/utils/sort.ts b/src/app/utils/sort.ts index 512c1998..ee8aa832 100644 --- a/src/app/utils/sort.ts +++ b/src/app/utils/sort.ts @@ -15,10 +15,10 @@ export const factoryRoomIdByActivity = }; export const factoryRoomIdByAtoZ = - (mx: MatrixClient): SortFunc => + (getRoomName: (roomId: string) => string | undefined): SortFunc => (a, b) => { - let aName = mx.getRoom(a)?.name ?? ''; - let bName = mx.getRoom(b)?.name ?? ''; + let aName = getRoomName(a) ?? ''; + let bName = getRoomName(b) ?? ''; // remove "#" from the room name // To ignore it in sorting diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index ca6d03d1..74fea24b 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -11,3 +11,20 @@ export const mobileOrTablet = (): boolean => { if (os.name === 'Android' || os.name === 'iOS') return true; return false; }; + +export const getInitialDeviceDisplayName = (): string => { + const browserName = ua().browser.name; + const osName = ua().os.name; + + if (browserName && osName) { + return `Cinny Web - ${ua().browser.name} (${ua().os.name})`; + } + if (browserName) { + return `Cinny Web - ${ua().browser.name}`; + } + if (osName) { + return `Cinny Web (${ua().os.name})`; + } + + return 'Cinny Web'; +}; diff --git a/src/client/LogoutManager.ts b/src/client/LogoutManager.ts new file mode 100644 index 00000000..220442e3 --- /dev/null +++ b/src/client/LogoutManager.ts @@ -0,0 +1,50 @@ +import { postLogout } from 'neurone/lib/api/matrix/client/v3/logout'; +import { MatrixRequest, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { ObservableValue } from 'neurone/lib/utils/observable'; + +export class LogoutManager implements Disposable { + private readonly disposer = new Disposer(); + + private readonly request: MatrixRequest; + + private readonly deleteStorage: () => Promise; + + constructor( + logoutObservable: ObservableValue, + request: MatrixRequest, + deleteStorage: () => Promise + ) { + this.request = request; + this.deleteStorage = deleteStorage; + + this.disposer.collect( + logoutObservable.subscribe((loggedOut) => { + if (loggedOut) { + this.clearStoresAndRefresh(); + } + }) + ); + } + + async logout(): Promise { + const response = await this.request(postLogout({})); + const [err] = await parseMatrixResp(response); + + if (err) { + throw new Error(printMatrixError(err)); + } + + await this.clearStoresAndRefresh(); + } + + private async clearStoresAndRefresh(): Promise { + await this.deleteStorage(); + window.localStorage.clear(); + window.location.reload(); + } + + dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/MachineManager.ts b/src/client/MachineManager.ts new file mode 100644 index 00000000..bd4ce51f --- /dev/null +++ b/src/client/MachineManager.ts @@ -0,0 +1,45 @@ +import { ObjectReader } from 'neurone/lib/utils'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { ClientContext } from './context'; + +export class MachineManager implements Disposable { + private readonly ct: ClientContext; + + private readonly disposer = new Disposer(); + + constructor(ct: ClientContext) { + this.ct = ct; + + this.manageRoomEncryptionSettings(); + } + + private manageRoomEncryptionSettings(): void { + const handleEncryptionContent = (roomId: string, content: object) => { + const algorithm = ObjectReader.string(content, 'algorithm'); + if (algorithm !== 'm.megolm.v1.aes-sha2') return; + + const sessionRotationPeriodMs = ObjectReader.number(content, 'rotation_period_ms'); + const sessionRotationPeriodMessages = ObjectReader.number(content, 'rotation_period_msgs'); + + this.ct.machine.roomEncryption.setSettings(roomId, { + algorithm, + encryptStateEvents: false, + onlyAllowTrustedDevices: false, + sessionRotationPeriodMs, + sessionRotationPeriodMessages, + }); + }; + + this.disposer.collect( + this.ct.state.join.encryption.subscribe((events) => + events.forEach((obj) => { + handleEncryptionContent(obj.roomId, obj.event.content); + }) + ) + ); + } + + dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/SyncResponseManager.ts b/src/client/SyncResponseManager.ts new file mode 100644 index 00000000..232c24f7 --- /dev/null +++ b/src/client/SyncResponseManager.ts @@ -0,0 +1,135 @@ +import { GetSyncOK, JoinRoom } from 'neurone/lib/api/matrix/client/v3/sync'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { EncryptionSyncChanges } from 'neurone/lib/crypto/machine'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { roomState } from 'neurone/lib/utils/sync/v3/join'; +import { ObjectReader } from 'neurone/lib/utils'; +import to from 'await-to-js'; +import { ClientContext } from './context'; +import { writeSync } from './syncWriter'; +import { sessionValue } from './storage/collections'; +import { syncLatestEvent } from './syncLatestEvent'; +import { SyncValue } from './sync'; + +export class SyncResponseManager implements Disposable { + private firstSync = true; + + private readonly ct: ClientContext; + + private readonly syncTokenObservable: ObservableValue; + + private readonly onWriteComplete: () => void; + + private readonly onSyncToken: (token: string) => void; + + private readonly disposer = new Disposer(); + + constructor( + ct: ClientContext, + syncTokenObservable: ObservableValue, + onWriteComplete: () => void, + onSyncToken: (token: string) => void + ) { + this.ct = ct; + this.syncTokenObservable = syncTokenObservable; + this.onWriteComplete = onWriteComplete; + this.onSyncToken = onSyncToken; + + this.disposer.collect( + this.syncTokenObservable.subscribe(this.handleSyncToken.bind(this)), + this.ct.sync.observable.subscribe(this.handleSync.bind(this)) + ); + } + + private handleSyncToken = (sinceToken?: string) => { + if (sinceToken === undefined) return; + if (this.firstSync) { + // We will call onSyncToken explicitly + // after syncLatestEvent completes to prevent race + return; + } + this.onSyncToken(sinceToken); + }; + + private async handleSync(syncValue: SyncValue) { + const [, response] = syncValue ?? []; + if (!response) return; + + // TODO: make it error safe or we can miss saving since token. + // resulting in staled sync loop + await this.beforeWrite(response); + await writeSync(this.ct, response); + await this.afterWrite(response.next_batch); + } + + private async beforeWrite(data: GetSyncOK) { + const encryptionChanges: EncryptionSyncChanges = { + toDeviceEvents: data.to_device?.events, + deviceLists: data.device_lists, + oneTimeKeysCounts: data.device_one_time_keys_count, + unusedFallbackKeys: data.device_unused_fallback_key_types, + }; + + await this.ct.machine.syncChanges.receive(encryptionChanges); + // TODO: room key bundle detection from to device events; + + const encryptedRooms = this.filterEncryptedRoomResponse(data.rooms?.join ?? {}); + const users = this.filterUsersToTrack(encryptedRooms); + await this.ct.machine.userTracker.track(users); + + await this.ct.machine.outgoingRequests.processAll(); + } + + private async afterWrite(nextBatch: string) { + this.onWriteComplete(); + + await this.ct.machine.outgoingRequests.processAll(); + + const currentSyncToken = this.syncTokenObservable.read; + this.ct.storage.sessionValue.write.put({ + id: sessionValue.syncToken.id, + value: nextBatch, + }); + + if (this.firstSync) { + const [err] = await to(syncLatestEvent(this.ct, currentSyncToken)); + if (err) console.error(err); + this.firstSync = false; + this.onSyncToken(nextBatch); + } + } + + private filterEncryptedRoomResponse(join: Record): Array<[string, JoinRoom]> { + const rooms = Object.entries(join).filter(([roomId, room]) => { + const encrypted = roomState(room).some((event) => event.type === 'm.room.encryption'); + + return encrypted || this.ct.encryptedRooms.read.has(roomId); + }); + + return rooms; + } + + private filterUsersToTrack(rooms: Array<[string, JoinRoom]>): string[] { + const userIds = new Set(); + + rooms.forEach(([roomId, room]) => { + roomState(room) + .filter((event) => event.type === 'm.room.member') + .forEach((event) => { + const membership = ObjectReader.string(event.content, 'membership'); + + const encryptForInvited = this.ct.roomToHistoryVisibility.read.get(roomId) !== 'joined'; + + if (membership === 'join' || (membership === 'invite' && encryptForInvited)) { + userIds.add(event.state_key); + } + }); + }); + + return Array.from(userIds); + } + + dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/context.ts b/src/client/context.ts new file mode 100644 index 00000000..78bf0533 --- /dev/null +++ b/src/client/context.ts @@ -0,0 +1,773 @@ +import { ComputedObservableValue, ObservableValue } from 'neurone/lib/utils/observable'; +import { MatrixRequest } from 'neurone/lib/matrix-request'; +import { HistoryVisibility, Machine } from 'neurone/lib/crypto/machine'; +import { Disposer } from 'neurone/lib/utils/disposable'; +import { + MessageEventWithoutRoomId, + RoomSummary, + StateEventWithoutRoomId, +} from 'neurone/lib/api/types'; +import { + InviteRoomStateObject, + JoinRoomStateObject, + KnockRoomStateObject, + LeaveRoomStateObject, + sessionValue, +} from './storage/collections'; +import { Storage } from './storage'; +import { SyncContext, SyncValue } from './sync'; +import { Session } from '../app/state/sessions'; +import { JoinRule, SpaceChildContent, StateEventType } from '../types/matrix/room'; +import { GetTxnId } from './txn'; +import { Timeline } from './room'; +import { AccountDataType } from '../types/matrix/accountData'; +import { DecryptionManager } from './decryption'; +import { ObjectReader } from 'neurone/lib/utils'; + +export type StateContext = { + join: { + create: ObservableValue; + encryption: ObservableValue; + tombstone: ObservableValue; + spaceChild: ObservableValue; + canonicalAlias: ObservableValue; + name: ObservableValue; + avatar: ObservableValue; + joinRules: ObservableValue; + historyVisibility: ObservableValue; + }; + invite: { + create: ObservableValue; + encryption: ObservableValue; + canonicalAlias: ObservableValue; + name: ObservableValue; + avatar: ObservableValue; + joinRules: ObservableValue; + }; + knock: { + create: ObservableValue; + encryption: ObservableValue; + canonicalAlias: ObservableValue; + name: ObservableValue; + avatar: ObservableValue; + joinRules: ObservableValue; + }; + leave: { + create: ObservableValue; + encryption: ObservableValue; + canonicalAlias: ObservableValue; + name: ObservableValue; + avatar: ObservableValue; + joinRules: ObservableValue; + }; +}; + +export type RoomsContext = { + join: ObservableValue; + invite: ObservableValue; + knock: ObservableValue; + leave: ObservableValue; +}; + +export type ClientContext = { + session: Session; + storage: Storage; + machine: Machine; + state: StateContext; + rooms: RoomsContext; + decryption: DecryptionManager; + + directRooms: ObservableValue>; + spaceRooms: ObservableValue>; + encryptedRooms: ObservableValue>; + + roomToParents: ObservableValue>>; + roomToChildEvents: ObservableValue[]>>; + roomToSummary: ObservableValue>; + roomToAlias: ObservableValue>; + roomToName: ObservableValue>; + roomToAvatar: ObservableValue>; + roomToHistoryVisibility: ObservableValue>; + roomToJoinRule: ObservableValue>; + roomToReplacement: ObservableValue>; + roomToCreators: ObservableValue>>; + roomToVersion: ObservableValue>; + roomToLatestEvent: ObservableValue>; + roomToTimeline: ObservableValue>; + + pendingMemberRooms: ObservableValue; + sync: SyncContext; + requestAuthJSON: MatrixRequest; + getTxnId: GetTxnId; + logout: () => Promise; +}; + +type RoomStateObject = + | JoinRoomStateObject + | InviteRoomStateObject + | KnockRoomStateObject + | LeaveRoomStateObject; + +export async function createStateContext( + storage: Storage, + disposer: Disposer +): Promise { + const { joinRoomState, inviteRoomState, leaveRoomState, knockRoomState } = storage; + + const joinCreate = await joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCreate + ); + const inviteCreate = await inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCreate + ); + const knockCreate = await knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCreate + ); + const leaveCreate = await leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCreate + ); + disposer.collect(joinCreate, inviteCreate, knockCreate, leaveCreate); + + const joinEncryption = await joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomEncryption + ); + const inviteEncryption = await inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomEncryption + ); + const knockEncryption = await knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomEncryption + ); + const leaveEncryption = await leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomEncryption + ); + disposer.collect(joinEncryption, inviteEncryption, knockEncryption, leaveEncryption); + + const joinTombstone = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomTombstone + ); + disposer.collect(joinTombstone); + + const joinSpaceChild = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.SpaceChild + ); + disposer.collect(joinSpaceChild); + + const joinCanonicalAlias = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCanonicalAlias + ); + const inviteCanonicalAlias = await storage.inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCanonicalAlias + ); + const knockCanonicalAlias = await storage.knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCanonicalAlias + ); + const leaveCanonicalAlias = await storage.leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomCanonicalAlias + ); + + disposer.collect( + joinCanonicalAlias, + inviteCanonicalAlias, + knockCanonicalAlias, + leaveCanonicalAlias + ); + + const joinName = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomName + ); + const inviteName = await storage.inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomName + ); + const knockName = await storage.knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomName + ); + const leaveName = await storage.leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomName + ); + disposer.collect(joinName, inviteName, knockName, leaveName); + + const joinAvatar = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomAvatar + ); + const inviteAvatar = await storage.inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomAvatar + ); + const knockAvatar = await storage.knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomAvatar + ); + const leaveAvatar = await storage.leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomAvatar + ); + disposer.collect(joinAvatar, inviteAvatar, knockAvatar, leaveAvatar); + + const joinJoinRules = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomJoinRules + ); + const inviteJoinRules = await storage.inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomJoinRules + ); + const knockJoinRules = await storage.knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomJoinRules + ); + const leaveJoinRules = await storage.leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomJoinRules + ); + disposer.collect(joinJoinRules, inviteJoinRules, knockJoinRules, leaveJoinRules); + + const joinHistoryVisibility = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomHistoryVisibility + ); + disposer.collect(joinHistoryVisibility); + + return { + join: { + create: joinCreate, + encryption: joinEncryption, + tombstone: joinTombstone, + spaceChild: joinSpaceChild, + canonicalAlias: joinCanonicalAlias, + name: joinName, + avatar: joinAvatar, + joinRules: joinJoinRules, + historyVisibility: joinHistoryVisibility, + }, + invite: { + create: inviteCreate, + encryption: inviteEncryption, + canonicalAlias: inviteCanonicalAlias, + name: inviteName, + avatar: inviteAvatar, + joinRules: inviteJoinRules, + }, + knock: { + create: knockCreate, + encryption: knockEncryption, + canonicalAlias: knockCanonicalAlias, + name: knockName, + avatar: knockAvatar, + joinRules: knockJoinRules, + }, + leave: { + create: leaveCreate, + encryption: leaveEncryption, + canonicalAlias: leaveCanonicalAlias, + name: leaveName, + avatar: leaveAvatar, + joinRules: leaveJoinRules, + }, + }; +} + +export const createRoomContext = async ( + state: StateContext, + disposer: Disposer +): Promise<{ + rooms: RoomsContext; + spaceRooms: ObservableValue>; + roomToCreators: ObservableValue>>; + roomToVersion: ObservableValue>; +}> => { + const join: ObservableValue = new ComputedObservableValue( + () => state.join.create.read.map((obj) => obj.roomId), + [state.join.create] + ); + disposer.collect(join); + + const invite: ObservableValue = new ComputedObservableValue( + () => state.invite.create.read.map((obj) => obj.roomId), + [state.invite.create] + ); + disposer.collect(invite); + + const knock: ObservableValue = new ComputedObservableValue( + () => state.knock.create.read.map((obj) => obj.roomId), + [state.knock.create] + ); + disposer.collect(knock); + + const leave: ObservableValue = new ComputedObservableValue( + () => state.leave.create.read.map((obj) => obj.roomId), + [state.leave.create] + ); + disposer.collect(leave); + + const spacesObservable: ObservableValue> = new ComputedObservableValue(() => { + const spaces = new Set(); + + const addToSpaces = (roomId: string, content: object) => { + if ('type' in content && typeof content.type === 'string' && content.type === 'm.space') { + spaces.add(roomId); + } + }; + + state.join.create.read.forEach((obj) => addToSpaces(obj.roomId, obj.event.content)); + state.invite.create.read.forEach((obj) => addToSpaces(obj.roomId, obj.event.content)); + state.knock.create.read.forEach((obj) => addToSpaces(obj.roomId, obj.event.content)); + state.leave.create.read.forEach((obj) => addToSpaces(obj.roomId, obj.event.content)); + + return spaces; + }, [state.join.create, state.invite.create, state.knock.create, state.leave.create]); + disposer.collect(spacesObservable); + + const roomToCreatorsObservable: ObservableValue>> = + new ComputedObservableValue(() => { + const roomToCreators = new Map>(); + + state.join.create.read.forEach((obj) => { + const creators = new Set(); + const { sender, content } = obj.event; + creators.add(sender); + + if ('additional_creators' in content && Array.isArray(content.additional_creators)) { + content.additional_creators.forEach((userId) => { + if (typeof userId === 'string') { + creators.add(userId); + } + }); + } + }); + + return roomToCreators; + }, [state.join.create]); + disposer.collect(roomToCreatorsObservable); + + const roomToVersionObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToVersion = new Map(); + + state.join.create.read.forEach((obj) => { + const { content } = obj.event; + + if ('room_version' in content && typeof content.room_version === 'string') { + roomToVersion.set(obj.roomId, content.room_version); + } + }); + + return roomToVersion; + }, [state.join.create]); + disposer.collect(roomToVersionObservable); + + const roomsContext: RoomsContext = { + join, + invite, + knock, + leave, + }; + + return { + rooms: roomsContext, + spaceRooms: spacesObservable, + roomToCreators: roomToCreatorsObservable, + roomToVersion: roomToVersionObservable, + }; +}; + +export const createEncryptedRoomsObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const encryptedRoomsObservable: ObservableValue> = new ComputedObservableValue(() => { + const encryptedRooms = new Set(); + + state.join.encryption.read.forEach((obj) => encryptedRooms.add(obj.roomId)); + state.invite.encryption.read.forEach((obj) => encryptedRooms.add(obj.roomId)); + state.knock.encryption.read.forEach((obj) => encryptedRooms.add(obj.roomId)); + state.leave.encryption.read.forEach((obj) => encryptedRooms.add(obj.roomId)); + + return encryptedRooms; + }, [ + state.join.encryption, + state.invite.encryption, + state.knock.encryption, + state.leave.encryption, + ]); + + disposer.collect(encryptedRoomsObservable); + + return encryptedRoomsObservable; +}; + +export const createDirectRoomsObservable = async ( + storage: Storage, + disposer: Disposer +): Promise>> => { + const mDirectObj = await storage.accountData.observableRead.get(AccountDataType.Direct); + const directRoomsObservable = new ComputedObservableValue(() => { + const rooms = new Set(); + const userIdToRooms = mDirectObj.read?.event.content; + + if (typeof userIdToRooms !== 'object') return rooms; + + Object.values(userIdToRooms).forEach((roomIds) => { + if (!Array.isArray(roomIds)) return; + roomIds.forEach((id) => { + if (typeof id === 'string') rooms.add(id); + }); + }); + + return rooms; + }, [mDirectObj]); + + disposer.collect(mDirectObj, directRoomsObservable); + + return directRoomsObservable; +}; + +export const createRoomToReplacementObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const roomToReplacementObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToReplacement = new Map(); + + state.join.tombstone.read.forEach((obj) => { + const { content } = obj.event ?? {}; + if ('replacement_room' in content && typeof content.replacement_room === 'string') { + roomToReplacement.set(obj.roomId, content.replacement_room); + } + }); + + return roomToReplacement; + }, [state.join.tombstone]); + + disposer.collect(roomToReplacementObservable); + + return roomToReplacementObservable; +}; + +export const createSpaceMapping = async ( + state: StateContext, + disposer: Disposer +): Promise<{ + roomToParents: ObservableValue>>; + roomToChildEvents: ObservableValue[]>>; +}> => { + const roomToParentsObservable: ObservableValue>> = + new ComputedObservableValue(() => { + const roomToParents = new Map>(); + + state.join.spaceChild.read.forEach((childEventObj) => { + const { event, roomId: parentId } = childEventObj; + const roomId = event.state_key; + if ('via' in event.content && Array.isArray(event.content.via)) { + const parents = roomToParents.get(roomId) ?? new Set(); + parents.add(parentId); + roomToParents.set(roomId, parents); + } + }); + + return roomToParents; + }, [state.join.spaceChild]); + + const roomToChildEventsObservable: ObservableValue< + Map[]> + > = new ComputedObservableValue(() => { + const roomToChildEvents = new Map[]>(); + + state.join.spaceChild.read.forEach((childEventObj) => { + const { event, roomId: parentId } = childEventObj; + + if ('via' in event.content && Array.isArray(event.content.via)) { + const children = roomToChildEvents.get(parentId) ?? []; + children.push(event as StateEventWithoutRoomId); + roomToChildEvents.set(parentId, children); + } + }); + + return roomToChildEvents; + }, [state.join.spaceChild]); + + disposer.collect(roomToParentsObservable, roomToChildEventsObservable); + + return { + roomToParents: roomToParentsObservable, + roomToChildEvents: roomToChildEventsObservable, + }; +}; + +export const createRoomToSummaryObservable = async ( + storage: Storage, + disposer: Disposer +): Promise>> => { + const summariesObservable = await storage.joinRoomSummary.observableRead.getAll(); + const roomToSummaryObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToSummary = new Map(); + + summariesObservable.read.forEach((obj) => { + roomToSummary.set(obj.id, obj.summary); + }); + + return roomToSummary; + }, [summariesObservable]); + + disposer.collect(summariesObservable, roomToSummaryObservable); + return roomToSummaryObservable; +}; + +export const createRoomToAliasObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const roomToAliasObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToAlias = new Map(); + + const addAlias = (obj: RoomStateObject) => { + const { content, state_key: stateKey } = obj.event; + if ( + stateKey === '' && + 'alias' in content && + typeof content.alias === 'string' && + content.alias + ) { + roomToAlias.set(obj.roomId, content.alias); + } + }; + + state.join.canonicalAlias.read.forEach(addAlias); + state.invite.canonicalAlias.read.forEach(addAlias); + state.knock.canonicalAlias.read.forEach(addAlias); + state.leave.canonicalAlias.read.forEach(addAlias); + + return roomToAlias; + }, [ + state.join.canonicalAlias, + state.invite.canonicalAlias, + state.knock.canonicalAlias, + state.leave.canonicalAlias, + ]); + + disposer.collect(roomToAliasObservable); + return roomToAliasObservable; +}; + +export const createRoomToNameObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const roomToNameObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToName = new Map(); + + const addName = (obj: RoomStateObject) => { + const { content, state_key: stateKey } = obj.event; + if ( + stateKey === '' && + 'name' in content && + typeof content.name === 'string' && + content.name + ) { + roomToName.set(obj.roomId, content.name); + } + }; + state.join.name.read.forEach(addName); + state.invite.name.read.forEach(addName); + state.knock.name.read.forEach(addName); + state.leave.name.read.forEach(addName); + + return roomToName; + }, [state.join.name, state.invite.name, state.knock.name, state.leave.name]); + + disposer.collect(roomToNameObservable); + return roomToNameObservable; +}; + +export const createRoomToAvatarObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const roomToAvatarObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToAvatar = new Map(); + const addAvatar = (obj: RoomStateObject) => { + const { content, state_key: stateKey } = obj.event; + if ( + stateKey === '' && + 'url' in content && + typeof content.url === 'string' && + content.url.startsWith('mxc://') + ) { + roomToAvatar.set(obj.roomId, content.url); + } + }; + state.join.avatar.read.forEach(addAvatar); + state.invite.avatar.read.forEach(addAvatar); + state.knock.avatar.read.forEach(addAvatar); + state.leave.avatar.read.forEach(addAvatar); + + return roomToAvatar; + }, [state.join.avatar, state.invite.avatar, state.knock.avatar, state.leave.avatar]); + + disposer.collect(roomToAvatarObservable); + return roomToAvatarObservable; +}; + +export const createRoomToHistoryVisibilityObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const roomToHistoryVisibilityObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToHistoryVisibility = new Map(); + + const addHistoryVisibility = (obj: RoomStateObject) => { + const { content, state_key: stateKey } = obj.event; + if (stateKey !== '') return; + const historyVisibility = ObjectReader.string(content, 'history_visibility'); + if (historyVisibility) { + roomToHistoryVisibility.set(obj.roomId, historyVisibility as HistoryVisibility); + } + }; + + state.join.historyVisibility.read.forEach(addHistoryVisibility); + + return roomToHistoryVisibility; + }, [state.join.joinRules]); + + disposer.collect(roomToHistoryVisibilityObservable); + return roomToHistoryVisibilityObservable; +}; + +export const createRoomToJoinRuleObservable = async ( + state: StateContext, + disposer: Disposer +): Promise>> => { + const roomToJoinRuleObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToJoinRule = new Map(); + + const addJoinRule = (obj: RoomStateObject) => { + const { content, state_key: stateKey } = obj.event; + if (stateKey === '' && 'join_rule' in content && typeof content.join_rule === 'string') { + roomToJoinRule.set(obj.roomId, content.join_rule as JoinRule); + } + }; + + state.join.joinRules.read.forEach(addJoinRule); + state.invite.joinRules.read.forEach(addJoinRule); + state.knock.joinRules.read.forEach(addJoinRule); + state.leave.joinRules.read.forEach(addJoinRule); + + return roomToJoinRule; + }, [ + state.join.joinRules, + state.invite.joinRules, + state.knock.joinRules, + state.leave.joinRules, + ]); + + disposer.collect(roomToJoinRuleObservable); + return roomToJoinRuleObservable; +}; + +export const createRoomToLatestEventObservable = async ( + storage: Storage, + disposer: Disposer +): Promise>> => { + const roomLatestEventObjects = await storage.joinRoomLatestEvent.observableRead.getAll(); + + const roomToLatestEventObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToLatestEvent = new Map(); + roomLatestEventObjects.read.map((obj) => roomToLatestEvent.set(obj.id, obj.event)); + return roomToLatestEvent; + }, [roomLatestEventObjects]); + + disposer.collect(roomLatestEventObjects, roomToLatestEventObservable); + + return roomToLatestEventObservable; +}; + +export const createLogoutObservable = ( + syncObservable: ObservableValue, + disposer: Disposer +): ObservableValue => { + const LOGOUT_PUBLISHER_KEY = 'LOGOUT_PUBLISHER_KEY'; + const logoutObservable: ObservableValue = new ObservableValue( + false, + LOGOUT_PUBLISHER_KEY + ); + + const unsubscribeSync = syncObservable.subscribe((value) => { + if (value === undefined) return; + const [error] = value; + if (error && !(error instanceof Error)) { + const { errcode } = error.data; + if (errcode === 'M_UNKNOWN_TOKEN') { + logoutObservable.publish(LOGOUT_PUBLISHER_KEY, true); + unsubscribeSync(); + } + } + }); + + logoutObservable.onDispose(LOGOUT_PUBLISHER_KEY, () => { + unsubscribeSync(); + }); + + disposer.collect(logoutObservable); + + return logoutObservable; +}; + +export const createSyncTokenObservable = async ( + storage: Storage, + disposer: Disposer +): Promise> => { + const syncTokenObj = await storage.sessionValue.observableRead.get(sessionValue.syncToken.id); + + const syncTokenObservable: ObservableValue = + new ComputedObservableValue(() => { + const syncToken = sessionValue.syncToken.parse(syncTokenObj.read); + return syncToken; + }, [syncTokenObj]); + + disposer.collect(syncTokenObj, syncTokenObservable); + return syncTokenObservable; +}; + +export const createPendingMemberRoomObservable = async ( + storage: Storage, + disposer: Disposer +): Promise> => { + const pendingMemberRoomsObj = await storage.sessionValue.observableRead.get( + sessionValue.pendingMemberRooms.id + ); + + const pendingMemberRoomsObservable: ObservableValue = + new ComputedObservableValue(() => { + const rooms = sessionValue.pendingMemberRooms.parse(pendingMemberRoomsObj.read); + return rooms ?? []; + }, [pendingMemberRoomsObj]); + + disposer.collect(pendingMemberRoomsObj, pendingMemberRoomsObservable); + return pendingMemberRoomsObservable; +}; diff --git a/src/client/decryption/DecryptionManager.ts b/src/client/decryption/DecryptionManager.ts new file mode 100644 index 00000000..3b180183 --- /dev/null +++ b/src/client/decryption/DecryptionManager.ts @@ -0,0 +1,85 @@ +import { + ClientEventWithoutRoomId, + MessageEventWithoutRoomId, + StateEventWithoutRoomId, +} from 'neurone/lib/api/types'; +import { Machine, RoomKeyInfo, RoomKeyWithheldInfo } from 'neurone/lib/crypto/machine'; +import { ObjectReader } from 'neurone/lib/utils'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { EncryptedEvent } from './EncryptedEvent'; +import { EncryptedEventIndex } from './EncryptedEventIndex'; + +export class DecryptionManager implements Disposable { + private readonly machine: Machine; + + private readonly disposer = new Disposer(); + + private readonly stateEvents = new EncryptedEventIndex(); + + private readonly messageEvents = new EncryptedEventIndex(); + + constructor(machine: Machine) { + this.machine = machine; + + this.disposer.collect( + this.machine.events.onRoomKeysUpdated.subscribe(this.handleRoomKeysUpdated.bind(this)), + this.machine.events.onRoomKeysWithheld.subscribe(this.handleRoomKeysWithheld.bind(this)) + ); + } + + private handleRoomKeysUpdated(keysInfo: RoomKeyInfo[]): void { + keysInfo.forEach((keyInfo) => this.decryptSession(keyInfo.roomId, keyInfo.sessionId)); + } + + private handleRoomKeysWithheld(keysInfo: RoomKeyWithheldInfo[]): void { + keysInfo.forEach((keyInfo) => this.decryptSession(keyInfo.roomId, keyInfo.sessionId)); + } + + private decryptSession(roomId: string, sessionId: string): void { + const m = this.messageEvents.getEventsForSession(roomId, sessionId); + m.forEach((event) => event.decrypt()); + + const s = this.stateEvents.getEventsForSession(roomId, sessionId); + s.forEach((event) => event.decrypt()); + } + + private registerEvent( + eventIndex: EncryptedEventIndex, + roomId: string, + event: T + ): EncryptedEvent { + const { content } = event; + const sessionId = ObjectReader.string(content, 'session_id'); + + if (!sessionId) { + throw new Error('Malformed event! Missing session_id'); + } + + const existingEvent = eventIndex.get(roomId, sessionId, event.event_id); + if (existingEvent) return existingEvent; + + const encryptedEvent = new EncryptedEvent(this.machine.roomEncryption, roomId, event); + + eventIndex.store(roomId, sessionId, encryptedEvent); + + return encryptedEvent; + } + + public decryptableMessageEvent( + roomId: string, + event: MessageEventWithoutRoomId + ): EncryptedEvent { + return this.registerEvent(this.messageEvents, roomId, event); + } + + public decryptableStateEvent( + roomId: string, + event: StateEventWithoutRoomId + ): EncryptedEvent { + return this.registerEvent(this.stateEvents, roomId, event); + } + + dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/decryption/EncryptedEvent.ts b/src/client/decryption/EncryptedEvent.ts new file mode 100644 index 00000000..a48b6d41 --- /dev/null +++ b/src/client/decryption/EncryptedEvent.ts @@ -0,0 +1,93 @@ +import { + ClientEventWithoutRoomId, + MessageEventWithoutRoomId, + StateEventWithoutRoomId, +} from 'neurone/lib/api/types'; +import { MegolmDecryptionError, RoomEncryption } from 'neurone/lib/crypto/machine'; +import { ObservableValue } from 'neurone/lib/utils/observable'; + +export class EventDecryptionIdle {} + +export class EventDecryptionPending {} + +export class EventDecryptionError { + public readonly error?: MegolmDecryptionError; + + public readonly cause?: unknown; + + constructor(error?: MegolmDecryptionError, cause?: unknown) { + this.error = error; + this.cause = cause; + } +} + +export class EventDecryptionSuccess { + public readonly event: T extends StateEventWithoutRoomId + ? StateEventWithoutRoomId + : MessageEventWithoutRoomId; + + constructor( + event: T extends StateEventWithoutRoomId ? StateEventWithoutRoomId : MessageEventWithoutRoomId + ) { + this.event = event; + } +} + +export class EncryptedEvent { + private readonly roomEncryption: RoomEncryption; + + private readonly publishKey = crypto.randomUUID(); + + private decryptPromise: Promise | undefined; + + public readonly roomId: string; + + public readonly event: T; + + public readonly decryption: ObservableValue< + EventDecryptionPending | EventDecryptionError | EventDecryptionSuccess + >; + + constructor(roomEncryption: RoomEncryption, roomId: string, event: T) { + this.roomEncryption = roomEncryption; + + this.roomId = roomId; + this.event = event; + + this.decryption = new ObservableValue(new EventDecryptionIdle(), this.publishKey); + } + + get eventId(): string { + return this.event.event_id; + } + + public decrypt(): Promise { + if (this.decryptPromise) { + return this.decryptPromise; + } + + this.decryptPromise = this.runDecryption().finally(() => { + this.decryptPromise = undefined; + }); + + return this.decryptPromise; + } + + private async runDecryption(): Promise { + this.decryption.publish(this.publishKey, new EventDecryptionPending()); + + try { + const [err, decryptedEvent] = await this.roomEncryption.decryptEvent( + this.roomId, + this.event + ); + + this.decryption.publish( + this.publishKey, + err ? new EventDecryptionError(err) : new EventDecryptionSuccess(decryptedEvent) + ); + } catch (e) { + this.decryption.publish(this.publishKey, new EventDecryptionError(undefined, e)); + } + } +} diff --git a/src/client/decryption/EncryptedEventIndex.ts b/src/client/decryption/EncryptedEventIndex.ts new file mode 100644 index 00000000..6709b517 --- /dev/null +++ b/src/client/decryption/EncryptedEventIndex.ts @@ -0,0 +1,52 @@ +import { ClientEventWithoutRoomId } from 'neurone/lib/api/types'; +import { EncryptedEvent } from './EncryptedEvent'; + +/** + * Indexes encrypted events by room, Megolm session, and event ID. + * + * This index is used to efficiently locate encrypted events that depend on a + * particular Megolm session. When new room keys become available, all events + * associated with the corresponding room and session can be retrieved and + * retried for decryption without scanning every tracked event. + * + * Structure: + * + * room_id -> session_id -> event_id -> EncryptedEvent + */ +export class EncryptedEventIndex { + private readonly rooms = new Map>>>(); + + get(roomId: string, sessionId: string, eventId: string): EncryptedEvent | undefined { + return this.rooms.get(roomId)?.get(sessionId)?.get(eventId); + } + + store(roomId: string, sessionId: string, encryptedEvent: EncryptedEvent): void { + let sessions = this.rooms.get(roomId); + if (!sessions) { + sessions = new Map(); + this.rooms.set(roomId, sessions); + } + + let events = sessions.get(sessionId); + if (!events) { + events = new Map(); + sessions.set(sessionId, events); + } + + events.set(encryptedEvent.eventId, encryptedEvent); + } + + getEventsForSession(roomId: string, sessionId: string): EncryptedEvent[] { + const events = this.rooms.get(roomId)?.get(sessionId); + + if (!events) { + return []; + } + + return Array.from(events.values()); + } + + public remove(roomId: string): boolean { + return this.rooms.delete(roomId); + } +} diff --git a/src/client/decryption/index.ts b/src/client/decryption/index.ts new file mode 100644 index 00000000..f2208e97 --- /dev/null +++ b/src/client/decryption/index.ts @@ -0,0 +1,3 @@ +export * from './DecryptionManager'; +export * from './EncryptedEvent'; +export * from './EncryptedEventIndex'; diff --git a/src/client/filter.ts b/src/client/filter.ts new file mode 100644 index 00000000..22dc26db --- /dev/null +++ b/src/client/filter.ts @@ -0,0 +1,70 @@ +import { Filter, RoomEventFilter } from 'neurone/lib/api/types'; +import { MessageEvent } from '../types/matrix/room'; + +export const getInitialSyncFilter = (): Filter => { + const filter: Filter = { + room: { + state: { + lazy_load_members: true, + }, + timeline: { + unread_thread_notifications: true, + }, + }, + }; + + return filter; +}; + +export const getSyncFilter = (): Filter => { + const filter: Filter = { + room: { + timeline: { + unread_thread_notifications: true, + }, + }, + }; + + return filter; +}; + +export const getLatestEventSyncFilter = (): Filter => { + const filter: Filter = { + account_data: { + not_types: ['*'], + }, + presence: { + not_types: ['*'], + }, + room: { + account_data: { + not_types: ['*'], + }, + ephemeral: { + not_types: ['*'], + }, + state: { + not_types: ['*'], + }, + timeline: { + types: [MessageEvent.RoomMessage, MessageEvent.RoomMessageEncrypted, MessageEvent.Sticker], + limit: 1, + }, + }, + }; + + return filter; +}; + +export const getMessagesFilter = (): RoomEventFilter => { + const filter: RoomEventFilter = { + types: [ + MessageEvent.RoomMessage, + MessageEvent.RoomMessageEncrypted, + MessageEvent.Sticker, + MessageEvent.Reaction, + ], + }; + + return filter; +}; diff --git a/src/client/initialize.ts b/src/client/initialize.ts new file mode 100644 index 00000000..505a636d --- /dev/null +++ b/src/client/initialize.ts @@ -0,0 +1,177 @@ +import { Machine, OutgoingRequestSender } from 'neurone/lib/crypto/machine'; +import { MatrixRequestRetrier, sleep } from 'neurone/lib/utils'; +import { MatrixRequest } from 'neurone/lib/matrix-request'; +import { Disposer } from 'neurone/lib/utils/disposable'; +import { deleteDB } from 'idb'; +import { Session } from '../app/state/sessions'; +import { + ClientContext, + createDirectRoomsObservable, + createEncryptedRoomsObservable, + createLogoutObservable, + createPendingMemberRoomObservable, + createRoomContext, + createRoomToAliasObservable, + createRoomToAvatarObservable, + createRoomToHistoryVisibilityObservable, + createRoomToJoinRuleObservable, + createRoomToLatestEventObservable, + createRoomToNameObservable, + createRoomToReplacementObservable, + createRoomToSummaryObservable, + createSpaceMapping, + createStateContext, + createSyncTokenObservable, +} from './context'; +import { createStorage } from './storage'; +import { getAuthJSONRequest } from './request'; +import { createSync, createSyncController } from './sync'; +import { GetTxnId, getTxnIdFunc } from './txn'; +import { createTimelines } from './room'; +import { SyncResponseManager } from './SyncResponseManager'; +import { LogoutManager } from './LogoutManager'; +import { MachineManager } from './MachineManager'; +import { DecryptionManager } from './decryption'; + +const initializeCrypto = async ( + session: Session, + request: MatrixRequest +): Promise<{ + machine: Machine; + deleteCryptoStorage: () => Promise; +}> => { + const storeName = `crypto${session.userId}`; + + const { olmMachine, closeMachine } = await Machine.init( + session.userId, + session.deviceId, + storeName + ); + + const outgoingSenderAbortCont = new AbortController(); + const outgoingRequestRetrier = new MatrixRequestRetrier(sleep); + const outgoingSender = new OutgoingRequestSender(request, outgoingSenderAbortCont.signal); + + const machine = new Machine(olmMachine, outgoingSender, outgoingRequestRetrier); + + const deleteCryptoStorage = async () => { + closeMachine(); + + // the matrix-crypto-sdk add postfix to storeName + const realStoreName = `${storeName}::matrix-sdk-crypto`; + await deleteDB(realStoreName); + }; + + return { machine, deleteCryptoStorage }; +}; + +export const initializeClient = async (session: Session): Promise => { + const disposer = new Disposer(); + + const { storage, deleteStorage } = await createStorage('client', session.userId); + + const requestAuthJSON: MatrixRequest = getAuthJSONRequest(session.baseUrl, session.accessToken); + + const { machine, deleteCryptoStorage } = await initializeCrypto(session, requestAuthJSON); + await machine.outgoingRequests.processAll(); + + const state = await createStateContext(storage, disposer); + + const { rooms, spaceRooms, roomToCreators, roomToVersion } = await createRoomContext( + state, + disposer + ); + const encryptedRooms = await createEncryptedRoomsObservable(state, disposer); + const directRooms = await createDirectRoomsObservable(storage, disposer); + const { roomToParents, roomToChildEvents } = await createSpaceMapping(state, disposer); + + const roomToSummary = await createRoomToSummaryObservable(storage, disposer); + const roomToAlias = await createRoomToAliasObservable(state, disposer); + const roomToName = await createRoomToNameObservable(state, disposer); + const roomToAvatar = await createRoomToAvatarObservable(state, disposer); + const roomToHistoryVisibility = await createRoomToHistoryVisibilityObservable(state, disposer); + const roomToJoinRule = await createRoomToJoinRuleObservable(state, disposer); + const roomToReplacement = await createRoomToReplacementObservable(state, disposer); + const roomToLatestEvent = await createRoomToLatestEventObservable(storage, disposer); + + const syncTokenObservable = await createSyncTokenObservable(storage, disposer); + const pendingMemberRooms = await createPendingMemberRoomObservable(storage, disposer); + + const getTxnId: GetTxnId = getTxnIdFunc(); + + const [syncObservable, sync] = createSync(requestAuthJSON); + const [syncController, onWriteComplete, onSyncToken] = createSyncController( + syncObservable, + sync, + () => syncTokenObservable.read + ); + + const decryption = new DecryptionManager(machine); + + const roomToTimeline = createTimelines(rooms.join, syncObservable, requestAuthJSON, disposer); + + const logoutObservable = createLogoutObservable(syncObservable, disposer); + const logoutManager = new LogoutManager(logoutObservable, requestAuthJSON, async () => { + disposer.run(); + await deleteCryptoStorage(); + await deleteStorage(); + }); + + const ct: ClientContext = { + session, + storage, + machine, + state, + rooms, + decryption, + + directRooms, + spaceRooms, + encryptedRooms, + + roomToParents, + roomToChildEvents, + roomToSummary, + roomToAlias, + roomToName, + roomToAvatar, + roomToHistoryVisibility, + roomToJoinRule, + roomToReplacement, + roomToLatestEvent, + roomToCreators, + roomToVersion, + roomToTimeline, + + pendingMemberRooms, + sync: { + observable: syncObservable, + controller: syncController, + }, + requestAuthJSON, + getTxnId, + logout: () => logoutManager.logout(), + }; + + const machineManager = new MachineManager(ct); + + const syncResponseManager = new SyncResponseManager( + ct, + syncTokenObservable, + onWriteComplete, + onSyncToken + ); + + disposer.collect( + decryption, + syncObservable, + syncController, + roomToTimeline, + logoutObservable, + logoutManager, + machineManager, + syncResponseManager + ); + + return ct; +}; diff --git a/src/client/mxcToHttp.ts b/src/client/mxcToHttp.ts new file mode 100644 index 00000000..443914c5 --- /dev/null +++ b/src/client/mxcToHttp.ts @@ -0,0 +1,39 @@ +import { + getDownload, + getThumbnail, + GetThumbnailSearchParams, +} from 'neurone/lib/api/matrix/client/v1/media'; +import { getDownload as getDownloadUnauthenticated } from 'neurone/lib/api/matrix/media/v3/download'; +import { getThumbnail as getThumbnailUnauthenticated } from 'neurone/lib/api/matrix/media/v3/thumbnail'; + +export const mxcToHttp = (baseUrl: string, mxc: string, authenticated = true) => { + // TODO: validate mxc: + const [serverName, mediaId] = mxc.slice(6).split('/'); + + const api = { + url: (): string => { + const endpoint = authenticated + ? getDownload({ pathParams: { serverName, mediaId } }) + : getDownloadUnauthenticated({ pathParams: { serverName, mediaId } }); + + const url = new URL(endpoint.path, baseUrl); + return url.href; + }, + thumbnail: (params: GetThumbnailSearchParams): string => { + const endpoint = authenticated + ? getThumbnail({ + pathParams: { serverName, mediaId }, + searchParams: params, + }) + : getThumbnailUnauthenticated({ + pathParams: { serverName, mediaId }, + searchParams: params, + }); + + const url = new URL(endpoint.path, baseUrl); + return url.href; + }, + }; + + return api; +}; diff --git a/src/client/reader/Error.ts b/src/client/reader/Error.ts new file mode 100644 index 00000000..dbbfe52d --- /dev/null +++ b/src/client/reader/Error.ts @@ -0,0 +1,15 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rError { + static error(data?: object): string | undefined { + return ObjectReader.string(data, 'error'); + } + + static errcode(data?: object): string | undefined { + return ObjectReader.string(data, 'errcode'); + } + + static retryAfterMs(data?: object): number | undefined { + return ObjectReader.number(data, 'retry_after_ms'); + } +} diff --git a/src/client/reader/Event.ts b/src/client/reader/Event.ts new file mode 100644 index 00000000..f5929154 --- /dev/null +++ b/src/client/reader/Event.ts @@ -0,0 +1,39 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rEvent { + static eventId(event?: object): string | undefined { + return ObjectReader.string(event, 'event_id'); + } + + static roomId(event?: object): string | undefined { + return ObjectReader.string(event, 'room_id'); + } + + static type(event?: object): string | undefined { + return ObjectReader.string(event, 'type'); + } + + static stateKey(event?: object): string | undefined { + return ObjectReader.string(event, 'state_key'); + } + + static sender(event?: object): string | undefined { + return ObjectReader.string(event, 'sender'); + } + + static content(event?: object): object | undefined { + return ObjectReader.object(event, 'content'); + } + + static originServerTs(event?: object): number | undefined { + return ObjectReader.number(event, 'origin_server_ts'); + } + + static unsigned(event?: object): object | undefined { + return ObjectReader.object(event, 'unsigned'); + } + + static redacts(event?: object): string | undefined { + return ObjectReader.string(event, 'redacts'); + } +} diff --git a/src/client/reader/index.ts b/src/client/reader/index.ts new file mode 100644 index 00000000..646ca727 --- /dev/null +++ b/src/client/reader/index.ts @@ -0,0 +1,3 @@ +export * from './message'; +export * from './Event'; +export * from './Error'; diff --git a/src/client/reader/message/Content.ts b/src/client/reader/message/Content.ts new file mode 100644 index 00000000..d82aaac6 --- /dev/null +++ b/src/client/reader/message/Content.ts @@ -0,0 +1,75 @@ +import { ObjectReader } from 'neurone/lib/utils'; +import { + MATRIX_SPOILER_PROPERTY_NAME, + MATRIX_SPOILER_REASON_PROPERTY_NAME, +} from '../../../types/matrix/common'; + +export class rContent { + static msgtype(content: object): string | undefined { + return ObjectReader.string(content, 'msgtype'); + } + + static body(content: object): string | undefined { + return ObjectReader.string(content, 'body'); + } + + static format(content: object): string | undefined { + return ObjectReader.string(content, 'format'); + } + + static formattedBody(content: object): string | undefined { + return ObjectReader.string(content, 'formatted_body'); + } + + static mentions(content: object): object | undefined { + return ObjectReader.object(content, 'm.mentions'); + } + + static relatesTo(content: object): object | undefined { + return ObjectReader.object(content, 'm.relates_to'); + } + + static shortcode(content: object): string | undefined { + return ObjectReader.string(content, 'shortcode'); + } + + static url(content: object): string | undefined { + return ObjectReader.string(content, 'url'); + } + + static filename(content: object): string | undefined { + return ObjectReader.string(content, 'filename'); + } + + static geoUri(content: object): string | undefined { + return ObjectReader.string(content, 'geo_uri'); + } + + static info(content: object): object | undefined { + return ObjectReader.object(content, 'info'); + } + + static file(content: object): object | undefined { + return ObjectReader.object(content, 'file'); + } + + static newContent(content: object): object | undefined { + return ObjectReader.object(content, 'm.new_content'); + } + + static redacts(content: object): string | undefined { + return ObjectReader.string(content, 'redacts'); + } + + static reason(content: object): string | undefined { + return ObjectReader.string(content, 'reason'); + } + + static spoiler(content: object): boolean | undefined { + return ObjectReader.boolean(content, MATRIX_SPOILER_PROPERTY_NAME); + } + + static spoilerReason(content: object): string | undefined { + return ObjectReader.string(content, MATRIX_SPOILER_REASON_PROPERTY_NAME); + } +} diff --git a/src/client/reader/message/EncryptedFile.ts b/src/client/reader/message/EncryptedFile.ts new file mode 100644 index 00000000..f2ff0f1e --- /dev/null +++ b/src/client/reader/message/EncryptedFile.ts @@ -0,0 +1,7 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rEncryptedFile { + static url(encFile?: object): string | undefined { + return ObjectReader.string(encFile, 'url'); + } +} diff --git a/src/client/reader/message/InReplyTo.ts b/src/client/reader/message/InReplyTo.ts new file mode 100644 index 00000000..c64da493 --- /dev/null +++ b/src/client/reader/message/InReplyTo.ts @@ -0,0 +1,7 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rInReplyTo { + static eventId(inReplyTo?: object): string | undefined { + return ObjectReader.string(inReplyTo, 'event_id'); + } +} diff --git a/src/client/reader/message/Info.ts b/src/client/reader/message/Info.ts new file mode 100644 index 00000000..0b5d30f7 --- /dev/null +++ b/src/client/reader/message/Info.ts @@ -0,0 +1,40 @@ +import { ObjectReader } from 'neurone/lib/utils'; +import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common'; + +export class rInfo { + static mimetype(info?: object): string | undefined { + return ObjectReader.string(info, 'mimetype'); + } + + static size(info?: object): number | undefined { + return ObjectReader.number(info, 'size'); + } + + static duration(info?: object): number | undefined { + return ObjectReader.number(info, 'duration'); + } + + static h(info?: object): number | undefined { + return ObjectReader.number(info, 'h'); + } + + static w(info?: object): number | undefined { + return ObjectReader.number(info, 'w'); + } + + static thumbnailFile(info?: object): object | undefined { + return ObjectReader.object(info, 'thumbnail_file'); + } + + static thumbnailUrl(info?: object): string | undefined { + return ObjectReader.string(info, 'thumbnail_url'); + } + + static thumbnailInfo(info?: object): object | undefined { + return ObjectReader.object(info, 'thumbnail_info'); + } + + static blurhash(info?: object): string | undefined { + return ObjectReader.string(info, MATRIX_BLUR_HASH_PROPERTY_NAME); + } +} diff --git a/src/client/reader/message/Mentions.ts b/src/client/reader/message/Mentions.ts new file mode 100644 index 00000000..c66d403d --- /dev/null +++ b/src/client/reader/message/Mentions.ts @@ -0,0 +1,13 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rMentions { + static userIds(mentions?: object): string[] | undefined { + return ObjectReader.array(mentions, 'user_ids')?.filter( + (id): id is string => typeof id === 'string' + ); + } + + static room(mentions?: object): boolean | undefined { + return ObjectReader.boolean(mentions, 'room'); + } +} diff --git a/src/client/reader/message/RelatesTo.ts b/src/client/reader/message/RelatesTo.ts new file mode 100644 index 00000000..fc635ff1 --- /dev/null +++ b/src/client/reader/message/RelatesTo.ts @@ -0,0 +1,23 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rRelatesTo { + static relType(relatesTo?: object): string | undefined { + return ObjectReader.string(relatesTo, 'rel_type'); + } + + static eventId(relatesTo?: object): string | undefined { + return ObjectReader.string(relatesTo, 'event_id'); + } + + static key(relatesTo?: object): string | undefined { + return ObjectReader.string(relatesTo, 'key'); + } + + static isFallingBack(relatesTo?: object): boolean | undefined { + return ObjectReader.boolean(relatesTo, 'is_falling_back'); + } + + static inReplyTo(relatesTo?: object): object | undefined { + return ObjectReader.object(relatesTo, 'm.in_reply_to'); + } +} diff --git a/src/client/reader/message/Relations.ts b/src/client/reader/message/Relations.ts new file mode 100644 index 00000000..97aa55e2 --- /dev/null +++ b/src/client/reader/message/Relations.ts @@ -0,0 +1,15 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rRelations { + static replace(relations?: object): object | undefined { + return ObjectReader.object(relations, 'm.relations'); + } + + static thread(relations?: object): object | undefined { + return ObjectReader.object(relations, 'm.thread'); + } + + static reference(relations?: object): object | undefined { + return ObjectReader.object(relations, 'm.reference'); + } +} diff --git a/src/client/reader/message/Thread.ts b/src/client/reader/message/Thread.ts new file mode 100644 index 00000000..3f568557 --- /dev/null +++ b/src/client/reader/message/Thread.ts @@ -0,0 +1,15 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rThread { + static latestEvent(thread?: object): object | undefined { + return ObjectReader.object(thread, 'latest_event'); + } + + static count(thread?: object): number | undefined { + return ObjectReader.number(thread, 'count'); + } + + static currentUserParticipated(thread?: object): boolean | undefined { + return ObjectReader.boolean(thread, 'current_user_participated'); + } +} diff --git a/src/client/reader/message/ThumbnailInfo.ts b/src/client/reader/message/ThumbnailInfo.ts new file mode 100644 index 00000000..94f6a9ff --- /dev/null +++ b/src/client/reader/message/ThumbnailInfo.ts @@ -0,0 +1,24 @@ +import { ObjectReader } from 'neurone/lib/utils'; +import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common'; + +export class rThumbnailInfo { + static mimetype(thumbInfo?: object): string | undefined { + return ObjectReader.string(thumbInfo, 'mimetype'); + } + + static size(thumbInfo?: object): number | undefined { + return ObjectReader.number(thumbInfo, 'size'); + } + + static h(thumbInfo?: object): number | undefined { + return ObjectReader.number(thumbInfo, 'h'); + } + + static w(thumbInfo?: object): number | undefined { + return ObjectReader.number(thumbInfo, 'w'); + } + + static blurhash(info?: object): string | undefined { + return ObjectReader.string(info, MATRIX_BLUR_HASH_PROPERTY_NAME); + } +} diff --git a/src/client/reader/message/Unsigned.ts b/src/client/reader/message/Unsigned.ts new file mode 100644 index 00000000..1acc3ed9 --- /dev/null +++ b/src/client/reader/message/Unsigned.ts @@ -0,0 +1,27 @@ +import { ObjectReader } from 'neurone/lib/utils'; + +export class rUnsigned { + static relations(unsigned?: object): object | undefined { + return ObjectReader.object(unsigned, 'm.relations'); + } + + static age(unsigned?: object): number | undefined { + return ObjectReader.number(unsigned, 'age'); + } + + static membership(unsigned?: object): string | undefined { + return ObjectReader.string(unsigned, 'membership'); + } + + static prevContent(unsigned?: object): object | undefined { + return ObjectReader.object(unsigned, 'prev_content'); + } + + static redactedBecause(unsigned?: object): object | undefined { + return ObjectReader.object(unsigned, 'redacted_because'); + } + + static transactionId(unsigned?: object): string | undefined { + return ObjectReader.string(unsigned, 'transaction_id'); + } +} diff --git a/src/client/reader/message/index.ts b/src/client/reader/message/index.ts new file mode 100644 index 00000000..bf31acb5 --- /dev/null +++ b/src/client/reader/message/index.ts @@ -0,0 +1,10 @@ +export * from './Content'; +export * from './EncryptedFile'; +export * from './Info'; +export * from './InReplyTo'; +export * from './Mentions'; +export * from './RelatesTo'; +export * from './Relations'; +export * from './Thread'; +export * from './ThumbnailInfo'; +export * from './Unsigned'; diff --git a/src/client/request.ts b/src/client/request.ts new file mode 100644 index 00000000..45fa8179 --- /dev/null +++ b/src/client/request.ts @@ -0,0 +1,123 @@ +import { MatrixEndpointStruct } from 'neurone/lib/api/MatrixEndpoint'; +import { + makeRequest, + MatrixErrorData, + MatrixRequest, + MatrixResponseResult, +} from 'neurone/lib/matrix-request'; + +export function getJSONRequest(baseUrl: string): MatrixRequest { + const request = makeRequest(fetch, baseUrl, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + return request; +} + +export function getAuthJSONRequest(baseUrl: string, accessToken: string): MatrixRequest { + const request = makeRequest(fetch, baseUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + return request; +} + +export type UploadProgress = { + loaded: number; + total: number; +}; + +export type UploadProgressCallback = (progress: UploadProgress) => void; + +type MediaRequest = ( + matrixEndpointStruct: MatrixEndpointStruct, + abortSignal?: AbortSignal +) => Promise>; + +export function getAuthUploadRequest( + baseUrl: string, + accessToken: string, + onProgress?: UploadProgressCallback +): MediaRequest { + const request: MediaRequest = (endpoint, signal) => + new Promise((resolve, reject) => { + const { path, body } = endpoint; + const xhr = new XMLHttpRequest(); + + if (body instanceof Blob === false && body instanceof FormData === false) { + reject( + TypeError(`Invalid body type. Requires Blob or FormData but received ${typeof body}`) + ); + return; + } + + let url: URL; + + try { + url = new URL(path, baseUrl); + xhr.open(endpoint.method, url); + xhr.setRequestHeader('Authorization', `Bearer ${accessToken}`); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + } catch (e) { + reject(e); + } + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.({ + loaded: event.loaded, + total: event.total, + }); + } + }; + + xhr.onload = () => { + const contentType = xhr.getResponseHeader('Content-Type'); + if (contentType !== 'application/json') { + reject( + new TypeError(`Expected JSON response but received "${contentType || 'unknown'}"`) + ); + return; + } + + const jsonResponse = JSON.parse(xhr.responseText); + if (xhr.status >= 200 && xhr.status < 300) { + resolve([undefined, jsonResponse as D]); + return; + } + resolve([ + { + status: xhr.status, + url: url.href, + data: jsonResponse as ED, + }, + undefined, + ]); + }; + + xhr.onerror = () => { + reject(new Error('Network Error')); + }; + + xhr.onabort = () => { + reject(new DOMException('The request was aborted!', 'AbortError')); + }; + + xhr.ontimeout = () => { + reject(new DOMException(`Request timed out after ${xhr.timeout}ms`, 'TimeoutError')); + }; + + xhr.send(body); + + signal?.addEventListener('abort', () => xhr.abort()); + }); + + return request; +} diff --git a/src/client/responseProgress.ts b/src/client/responseProgress.ts new file mode 100644 index 00000000..08d925b9 --- /dev/null +++ b/src/client/responseProgress.ts @@ -0,0 +1,41 @@ +export type ProgressCallback = (receivedBytes: number, totalBytes?: number) => void; + +export const responseProgress = async (response: Response, onProgress: ProgressCallback) => { + const contentLength = response.headers.get('Content-Length'); + if (contentLength === null) { + console.warn('Content-Length not available. Cannot track progress accurately.'); + } + const total = contentLength ? parseInt(contentLength, 10) : undefined; + let loaded = 0; + + const { body } = response; + if (!body) { + console.warn('Request body not available. Cannot track progress accurately.'); + return; + } + const reader = body.getReader(); + + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) break; + + loaded += value.length; + onProgress(loaded, total); + } + + if (total === undefined) { + onProgress(loaded, loaded); + } +}; + +export function createFakeProgress() { + let slowdownFactor: number | undefined; + return (bytes: number) => { + if (!slowdownFactor) { + slowdownFactor = bytes; + return 0; + } + return bytes / (bytes + slowdownFactor * 100); + }; +} diff --git a/src/client/room/ThreadMeta.ts b/src/client/room/ThreadMeta.ts new file mode 100644 index 00000000..54213f8d --- /dev/null +++ b/src/client/room/ThreadMeta.ts @@ -0,0 +1,19 @@ +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; + +export class ThreadMeta { + public readonly latestEvent: MessageEventWithoutRoomId; + + public readonly count: number; + + public readonly currentUserParticipated: boolean; + + constructor( + latestEvent: MessageEventWithoutRoomId, + count: number, + currentUserParticipated: boolean + ) { + this.latestEvent = latestEvent; + this.count = count; + this.currentUserParticipated = currentUserParticipated; + } +} diff --git a/src/client/room/ThreadTimeline.ts b/src/client/room/ThreadTimeline.ts new file mode 100644 index 00000000..87226670 --- /dev/null +++ b/src/client/room/ThreadTimeline.ts @@ -0,0 +1,303 @@ +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { Observable, ObservableValue } from 'neurone/lib/utils/observable'; +import { getRelations, GetRelationsOK } from 'neurone/lib/api/matrix/client/v1/rooms'; +import { + getContext, + GetContextOK, + getEvent, + GetEventOK, +} from 'neurone/lib/api/matrix/client/v3/rooms'; +import { + MatrixRequest, + MatrixResponseResult, + parseMatrixResp, + printMatrixError, +} from 'neurone/lib/matrix-request'; +import { TimelineView } from './TimelineView'; +import { MessageRange, MessageBatch } from './messages'; +import { TimelineBatchManager } from './TimelineBatchManager'; +import { RelationsProcessor } from './relations'; +import { ThreadMeta } from './ThreadMeta'; +import { rRelations, rThread, rUnsigned } from '../reader'; +import { TimelineUtils } from './TimelineUtils'; +import { LocalEventOutbox } from './local-event/LocalEventOutbox'; +import { EventSenderIdle, LocalEvent, LocalEventSender, LocalMessage } from './local-event'; + +export type ThreadSyncData = { + events: MessageEventWithoutRoomId[]; + limited?: boolean; + prevToken?: string; +}; + +export class ThreadTimeline implements TimelineView, Disposable { + private readonly publishKey = crypto.randomUUID(); + + public readonly roomId: string; + + public readonly threadId: string; + + private readonly request: MatrixRequest; + + private readonly disposer = new Disposer(); + + public readonly onSync: Observable; + + public readonly relations: RelationsProcessor; + + public readonly batch: ObservableValue; + + private readonly batchManager: TimelineBatchManager; + + public readonly range: MessageRange; + + public readonly outbox = new LocalEventOutbox(); + + public meta: ObservableValue; + + public rootEvent: ObservableValue; + + private utils: TimelineUtils; + + private paginatingBackward = false; + + private paginatingForward = false; + + constructor( + roomId: string, + threadId: string, + publishKey: string, + request: MatrixRequest, + relations: RelationsProcessor, + rootEvent?: MessageEventWithoutRoomId + ) { + this.roomId = roomId; + this.threadId = threadId; + this.request = request; + // NOTE: we don't have dedicated set of relations, + // because we do not want to recurse relations to put them in thread. + this.relations = relations; + + this.batch = new ObservableValue(MessageBatch.emptyAtForwardEnd(), this.publishKey); + this.batchManager = new TimelineBatchManager(this.batch, this.publishKey); + this.range = new MessageRange(this.batch); + + this.meta = new ObservableValue(undefined, this.publishKey); + this.rootEvent = new ObservableValue(undefined, this.publishKey); + this.onSync = new Observable(publishKey); + this.utils = new TimelineUtils(roomId, request); + + this.disposer.collect( + this.batch, + this.range, + this.meta, + this.onSync, + this.onSync.subscribe(this.onSyncListener.bind(this)) + ); + + if (rootEvent) { + this.processRootEvent(rootEvent); + } else { + this.fetchRootEvent(); + } + } + + private processRootEvent(event: MessageEventWithoutRoomId): void { + const relations = rUnsigned.relations(event.unsigned); + const threadRelation = rRelations.thread(relations); + const latestEvent = rThread.latestEvent(threadRelation); + const count = rThread.count(threadRelation) ?? 0; + const currentUserParticipated = rThread.currentUserParticipated(threadRelation) ?? false; + + if (latestEvent) { + this.meta.publish( + this.publishKey, + new ThreadMeta(latestEvent as MessageEventWithoutRoomId, count, currentUserParticipated) + ); + } + this.rootEvent.publish(this.publishKey, event); + } + + private async fetchRootEvent(): Promise { + const resp = await this.request( + getEvent({ + pathParams: { roomId: this.roomId, eventId: this.threadId }, + }) + ); + const [err, event] = await parseMatrixResp(resp); + if (err) { + console.error(printMatrixError(err)); + return; + } + this.processRootEvent(event); + } + + private onSyncListener(data: ThreadSyncData): void { + const { events, limited, prevToken } = data; + + // NOTE: relations are already picked & filtered in main timeline. + this.batchManager.handleSync(events, limited, prevToken); + + const eventsWithRemoteEcho = this.outbox.getSentEventIds().filter((e) => this.findEvent(e)); + this.outbox.removeByEventIds(eventsWithRemoteEcho); + } + + private async getMessages( + token: string | undefined, + dir: 'b' | 'f', + limit: number + ): Promise> { + const resp = await this.request( + getRelations({ + pathParams: { + roomId: this.roomId, + eventId: this.threadId, + }, + searchParams: { + dir, + from: token, + limit, + recurse: true, + }, + }) + ); + return parseMatrixResp(resp); + } + + public async paginateBackward(limit: number): Promise { + if (this.paginatingBackward) return; + this.paginatingBackward = true; + + try { + const { id, prev, messages } = this.batch.read; + if (prev.end) return; + + const token = prev.token ?? (await this.utils.generatePaginationToken(messages.events, 'b')); + + if (!token && messages.events.length > 0) { + this.batchManager.markTimelineStart(); + return; + } + + const [err, result] = await this.getMessages(token, 'b', limit); + if (err) { + throw new Error(printMatrixError(err)); + } + + const { next_batch: prevToken, chunk } = result; + + const messageEvents = this.relations.pickAndFilter(chunk); + + this.batchManager.handleBackwardPagination(id, messageEvents, prevToken); + } finally { + this.paginatingBackward = false; + } + } + + public async paginateForward(limit: number): Promise { + if (this.paginatingForward) return; + this.paginatingForward = true; + + try { + const { id, next, messages } = this.batch.read; + if (next.end) return; + + const token = next.token ?? (await this.utils.generatePaginationToken(messages.events, 'f')); + + if (!token && messages.events.length > 0) { + this.batchManager.markTimelineEnd(); + return; + } + + if (!token) { + // We do not want to fetch messages from start + // as back pagination will fetch from live end. + return; + } + + const [err, result] = await this.getMessages(token, 'f', limit); + if (err) { + throw new Error(printMatrixError(err)); + } + + const { prev_batch: nextToken, chunk } = result; + + const messageEvents = this.relations.pickAndFilter(chunk); + + this.batchManager.handleForwardPagination(id, messageEvents, nextToken); + } finally { + this.paginatingForward = false; + } + } + + public async jumpToEvent(eventId: string): Promise { + const resp = await this.request( + getContext({ + pathParams: { + roomId: this.roomId, + eventId, + }, + searchParams: { + limit: 0, + }, + }) + ); + + const [err, result] = await parseMatrixResp(resp); + if (err) { + throw new Error(printMatrixError(err)); + } + const { event, start, end } = result; + if (!event) { + throw new Error( + `Failed to jump to event! No event found for ${eventId} in room ${this.roomId}` + ); + } + + const events = [event]; + const messageEvents: MessageEventWithoutRoomId[] = this.relations.pickAndFilter(events); + + this.batchManager.loadEvents(messageEvents, start, end); + } + + public async jumpToLatest(): Promise { + if (this.batch.read.next.end) { + this.range.setRange(MessageRange.atLiveEnd(this.batch.read.size)); + return; + } + const [err, result] = await this.getMessages(undefined, 'b', 80); + + if (err) { + throw new Error(printMatrixError(err)); + } + + const { next_batch: prevToken, prev_batch: endToken, chunk } = result; + const messageEvents: MessageEventWithoutRoomId[] = this.relations.pickAndFilter(chunk); + this.batchManager.loadEvents(messageEvents, prevToken, endToken); + } + + public findEvent(eventId: string): MessageEventWithoutRoomId | undefined { + return this.batch.read.messages.get(eventId); + } + + public async sendMessage( + originalMessage: LocalMessage, + wireMessage: LocalMessage, + txnId: string + ): Promise { + const localEvent = new LocalEvent(originalMessage, wireMessage, txnId, Date.now()); + + const sender = new LocalEventSender(localEvent, this.request); + + this.outbox.add(sender); + if (sender.state.read instanceof EventSenderIdle) { + sender.state.read.send(); + } + + return sender; + } + + dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/room/Timeline.ts b/src/client/room/Timeline.ts new file mode 100644 index 00000000..68a36d32 --- /dev/null +++ b/src/client/room/Timeline.ts @@ -0,0 +1,349 @@ +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { Observable, ObservableValue } from 'neurone/lib/utils/observable'; +import { JoinRoom } from 'neurone/lib/api/matrix/client/v3/sync'; +import { + getContext, + GetContextOK, + getMessages, + GetMessagesOK, +} from 'neurone/lib/api/matrix/client/v3/rooms'; +import { + MatrixRequest, + MatrixResponseResult, + parseMatrixResp, + printMatrixError, +} from 'neurone/lib/matrix-request'; +import { TimelineView } from './TimelineView'; +import { MessageRange, MessageBatch } from './messages'; +import { TimelineBatchManager } from './TimelineBatchManager'; +import { RelationsProcessor } from './relations'; +import { ThreadTimeline } from './ThreadTimeline'; +import { getMessagesFilter } from '../filter'; +import { rContent, rRelatesTo, rRelations, rUnsigned } from '../reader'; +import { RelationType } from '../../types/matrix/common'; +import { getThreadId } from '../../app/utils/room'; +import { TimelineUtils } from './TimelineUtils'; +import { EventSenderIdle, LocalEvent, LocalEventSender, LocalMessage } from './local-event'; +import { LocalEventOutbox } from './local-event/LocalEventOutbox'; + +export class Timeline implements TimelineView, Disposable { + private readonly publishKey = crypto.randomUUID(); + + public readonly roomId: string; + + private readonly request: MatrixRequest; + + private readonly disposer: Disposer = new Disposer(); + + private readonly msgFilter: string = JSON.stringify(getMessagesFilter()); + + public readonly onSync: Observable; + + public readonly relations: RelationsProcessor; + + public readonly batch: ObservableValue; + + private readonly batchManager: TimelineBatchManager; + + public readonly range: MessageRange; + + public readonly outbox = new LocalEventOutbox(); + + public readonly threads: ObservableValue> = new ObservableValue( + new Map(), + this.publishKey + ); + + public readonly activeThread = new ObservableValue( + undefined, + this.publishKey + ); + + private utils: TimelineUtils; + + private paginatingBackward = false; + + private paginatingForward = false; + + constructor(roomId: string, publishKey: string, request: MatrixRequest) { + this.roomId = roomId; + this.request = request; + this.relations = new RelationsProcessor(); + + this.batch = new ObservableValue(MessageBatch.emptyAtForwardEnd(), this.publishKey); + this.batchManager = new TimelineBatchManager(this.batch, this.publishKey); + this.range = new MessageRange(this.batch); + + this.onSync = new Observable(publishKey); + this.utils = new TimelineUtils(roomId, request); + + this.disposer.collect( + this.range, + this.relations, + this.batch, + () => { + this.threads.read.forEach((thread) => thread.dispose()); + this.threads.dispose(); + }, + this.activeThread, + this.onSync, + this.onSync.subscribe(this.onSyncListener.bind(this)) + ); + } + + private onSyncListener(joinRoom: JoinRoom): void { + const { events, limited, prev_batch: prevToken } = joinRoom.timeline ?? {}; + + const messageEvents: MessageEventWithoutRoomId[] = events + ? this.relations.pickAndFilter(events) + : []; + + queueMicrotask(() => this.syncThreads(messageEvents, limited, prevToken)); + + this.batchManager.handleSync(messageEvents, limited, prevToken); + + const eventsWithRemoteEcho = this.outbox.getSentEventIds().filter((e) => this.findEvent(e)); + this.outbox.removeByEventIds(eventsWithRemoteEcho); + } + + private syncThreads( + messageEvents: MessageEventWithoutRoomId[], + limited: boolean | undefined, + prevToken: string | undefined + ): void { + const threadIdToEvents = new Map(); + + messageEvents.forEach((event) => { + const relatesTo = rContent.relatesTo(event.content); + const threadId = + rRelatesTo.relType(relatesTo) === RelationType.Thread + ? rRelatesTo.eventId(relatesTo) + : undefined; + + if (threadId) { + const threadEvents = threadIdToEvents.get(threadId) ?? []; + threadEvents.push(event); + threadIdToEvents.set(threadId, threadEvents); + } + }); + + threadIdToEvents.forEach((events, threadId) => { + const thread: ThreadTimeline = + this.threads.read.get(threadId) ?? + new ThreadTimeline(this.roomId, threadId, this.publishKey, this.request, this.relations); + + thread.onSync.publish(this.publishKey, { events, limited, prevToken }); + + if (!this.threads.read.has(threadId)) { + this.addThread(thread); + } + }); + } + + private addThread(thread: ThreadTimeline): void { + const newThreads = new Map(this.threads.read); + newThreads.set(thread.threadId, thread); + this.threads.publish(this.publishKey, newThreads); + } + + private pickThreads(events: MessageEventWithoutRoomId[]): void { + const threadRoots = new Map(); + + events.forEach((event) => { + const threadId = getThreadId(event); + if (threadId) { + threadRoots.set(threadId, undefined); + return; + } + + const relations = rUnsigned.relations(event.unsigned); + const threadRelations = rRelations.thread(relations); + if (threadRelations) { + threadRoots.set(event.event_id, event); + } + }); + + threadRoots.forEach((event, threadId) => { + if (this.threads.read.has(threadId)) return; + + const thread = new ThreadTimeline( + this.roomId, + threadId, + this.publishKey, + this.request, + this.relations, + event + ); + this.addThread(thread); + }); + } + + public async paginateBackward(limit: number): Promise { + if (this.paginatingBackward) return; + this.paginatingBackward = true; + + try { + const { id, prev, messages } = this.batch.read; + if (prev.end) return; + + const token = prev.token ?? (await this.utils.generatePaginationToken(messages.events, 'b')); + + if (!token && messages.events.length > 0) { + this.batchManager.markTimelineStart(); + return; + } + + const [err, result] = await this.getMessages(token, 'b', limit); + if (err) { + throw new Error(printMatrixError(err)); + } + + const { end: prevToken, chunk } = result; + const messageEvents: MessageEventWithoutRoomId[] = this.relations.pickAndFilter(chunk); + + this.pickThreads(messageEvents); + + this.batchManager.handleBackwardPagination(id, messageEvents, prevToken); + } finally { + this.paginatingBackward = false; + } + } + + public async paginateForward(limit: number): Promise { + if (this.paginatingForward) return; + this.paginatingForward = true; + + try { + const { id, next, messages } = this.batch.read; + if (next.end) return; + + const token = next.token ?? (await this.utils.generatePaginationToken(messages.events, 'f')); + + if (!token && messages.events.length > 0) { + this.batchManager.markTimelineEnd(); + return; + } + + if (!token) { + // We do not want to fetch messages from start + // as back pagination will fetch from live end. + return; + } + + const [err, result] = await this.getMessages(token, 'f', limit); + if (err) { + throw new Error(printMatrixError(err)); + } + + const { end: nextToken, chunk } = result; + const messageEvents: MessageEventWithoutRoomId[] = this.relations.pickAndFilter(chunk); + + this.pickThreads(messageEvents); + + this.batchManager.handleForwardPagination(id, messageEvents, nextToken); + } finally { + this.paginatingForward = false; + } + } + + public async jumpToEvent(eventId: string): Promise { + const resp = await this.request( + getContext({ + pathParams: { + roomId: this.roomId, + eventId, + }, + searchParams: { + limit: 20, + filter: this.msgFilter, + }, + }) + ); + + const [err, result] = await parseMatrixResp(resp); + if (err) { + throw new Error(printMatrixError(err)); + } + const { event, events_after: before, events_after: after, start, end } = result; + if (!event) { + throw new Error( + `Failed to jump to event! No event found for ${eventId} in room ${this.roomId}` + ); + } + + const events = [...(before ?? []), event, ...(after ?? [])]; + const messageEvents: MessageEventWithoutRoomId[] = this.relations.pickAndFilter(events); + + this.pickThreads(messageEvents); + this.batchManager.loadEvents(messageEvents, start, end); + } + + public async jumpToLatest(): Promise { + if (this.batch.read.next.end) { + this.range.setRange(MessageRange.atLiveEnd(this.batch.read.size)); + return; + } + const [err, result] = await this.getMessages(undefined, 'b', 80); + + if (err) { + throw new Error(printMatrixError(err)); + } + + const { end: prevToken, start: endToken, chunk } = result; + const messageEvents: MessageEventWithoutRoomId[] = this.relations.pickAndFilter(chunk); + this.batchManager.loadEvents(messageEvents, prevToken, endToken); + } + + public findEvent(eventId: string): MessageEventWithoutRoomId | undefined { + return this.batch.read.messages.get(eventId); + } + + private async getMessages( + token: string | undefined, + dir: 'b' | 'f', + limit: number + ): Promise> { + const msgResp = await this.request( + getMessages({ + pathParams: { roomId: this.roomId }, + searchParams: { + dir, + from: token, + limit, + filter: this.msgFilter, + }, + }) + ); + return parseMatrixResp(msgResp); + } + + public async sendMessage( + originalMessage: LocalMessage, + wireMessage: LocalMessage, + txnId: string + ): Promise { + const localEvent = new LocalEvent(originalMessage, wireMessage, txnId, Date.now()); + + const sender = new LocalEventSender(localEvent, this.request); + + this.outbox.add(sender); + if (sender.state.read instanceof EventSenderIdle) { + sender.state.read.send(); + } + + return sender; + } + + public openThread(threadId: string): void { + this.activeThread.publish(this.publishKey, threadId); + } + + public closeThread(): void { + this.activeThread.publish(this.publishKey, undefined); + } + + public dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/room/TimelineBatchManager.ts b/src/client/room/TimelineBatchManager.ts new file mode 100644 index 00000000..413ec57f --- /dev/null +++ b/src/client/room/TimelineBatchManager.ts @@ -0,0 +1,127 @@ +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { MessageBatch, Messages, BatchInfo, BatchChange } from './messages'; + +export class TimelineBatchManager { + private readonly publishKey: string; + + private readonly batch: ObservableValue; + + constructor(batch: ObservableValue, publishKey: string) { + this.batch = batch; + this.publishKey = publishKey; + } + + public handleSync( + messageEvents: MessageEventWithoutRoomId[], + limited: boolean | undefined, + prevToken: string | undefined + ): void { + if (!limited && messageEvents.length === 0) return; + + // Skip if timeline is not at live end + if (!this.batch.read.next.end) return; + + const { id, prev, next, messages } = this.batch.read; + const liveEmpty = next.end && this.batch.read.size === 0; + const mergeable = limited ? messages.overlap(messageEvents) || liveEmpty : undefined; + + if (limited && !mergeable) { + this.batch.publish( + this.publishKey, + new MessageBatch( + crypto.randomUUID(), + this.batch.read.messages, + this.batch.read.prev, + new BatchInfo(undefined, false), + this.batch.read.change + ) + ); + return; + } + + const newPrev = + !prev.token && messages.events.length === 0 ? new BatchInfo(prevToken, !prevToken) : prev; + + const newBatch = new MessageBatch( + id, + messages.mergeFork(messageEvents), + newPrev, + next.end ? next : new BatchInfo(undefined, true), + new BatchChange('Grow', 'f') + ); + this.batch.publish(this.publishKey, newBatch); + } + + public handleBackwardPagination( + batchId: string, + messageEvents: MessageEventWithoutRoomId[], + prevToken: string | undefined + ): void { + if (batchId !== this.batch.read.id) { + return; + } + + const newBatch = new MessageBatch( + this.batch.read.id, + this.batch.read.messages.mergeFork(messageEvents), + new BatchInfo(prevToken, !prevToken), + this.batch.read.next, + new BatchChange('Grow', 'b') + ); + this.batch.publish(this.publishKey, newBatch); + } + + public handleForwardPagination( + batchId: string, + messageEvents: MessageEventWithoutRoomId[], + nextToken: string | undefined + ): void { + if (batchId !== this.batch.read.id) { + return; + } + + const newBatch = new MessageBatch( + this.batch.read.id, + this.batch.read.messages.mergeFork(messageEvents), + this.batch.read.prev, + new BatchInfo(nextToken, !nextToken), + new BatchChange('Grow', 'f') + ); + this.batch.publish(this.publishKey, newBatch); + } + + public markTimelineStart(): void { + const newBatch = new MessageBatch( + this.batch.read.id, + this.batch.read.messages, + new BatchInfo(undefined, true), + this.batch.read.next, + new BatchChange('Grow', 'b') + ); + this.batch.publish(this.publishKey, newBatch); + } + + public markTimelineEnd(): void { + const newBatch = new MessageBatch( + this.batch.read.id, + this.batch.read.messages, + this.batch.read.prev, + new BatchInfo(undefined, true), + new BatchChange('Grow', 'f') + ); + this.batch.publish(this.publishKey, newBatch); + } + + public loadEvents(events: MessageEventWithoutRoomId[], prevToken?: string, nextToken?: string) { + const newBatch = new MessageBatch( + crypto.randomUUID(), + new Messages(events), + new BatchInfo(prevToken, !prevToken), + new BatchInfo(nextToken, !nextToken), + new BatchChange() + ); + + this.batch.publish(this.publishKey, newBatch); + } +} diff --git a/src/client/room/TimelineUtils.ts b/src/client/room/TimelineUtils.ts new file mode 100644 index 00000000..55361cfe --- /dev/null +++ b/src/client/room/TimelineUtils.ts @@ -0,0 +1,57 @@ +import { + getContext, + GetContextOK, + getEvent, + GetEventOK, +} from 'neurone/lib/api/matrix/client/v3/rooms'; +import { ClientEvent, MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { MatrixRequest, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; + +export class TimelineUtils { + private roomId: string; + + private request: MatrixRequest; + + constructor(roomId: string, request: MatrixRequest) { + this.roomId = roomId; + this.request = request; + } + + public async generatePaginationToken( + events: MessageEventWithoutRoomId[], + dir: 'b' | 'f' + ): Promise { + const event: MessageEventWithoutRoomId | undefined = + events[dir === 'b' ? 0 : events.length - 1]; + if (!event) return undefined; + + const ctxResp = await this.request( + getContext({ + pathParams: { + roomId: this.roomId, + eventId: event.event_id, + }, + searchParams: { + limit: 0, + }, + }) + ); + const [err, result] = await parseMatrixResp(ctxResp); + if (err) { + throw new Error(printMatrixError(err)); + } + return dir === 'b' ? result.start : result.end; + } + + public async getEvent(eventId: string): Promise { + const resp = await this.request( + getEvent({ + pathParams: { roomId: this.roomId, eventId }, + }) + ); + + const [err, event] = await parseMatrixResp(resp); + if (err) throw new Error(printMatrixError(err)); + return event; + } +} diff --git a/src/client/room/TimelineView.ts b/src/client/room/TimelineView.ts new file mode 100644 index 00000000..4706ccd7 --- /dev/null +++ b/src/client/room/TimelineView.ts @@ -0,0 +1,23 @@ +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { MessageBatch, MessageRange } from './messages'; +import { RelationsProcessor } from './relations'; +import { LocalEventOutbox } from './local-event/LocalEventOutbox'; +import { LocalEventSender, LocalMessage } from './local-event'; + +export interface TimelineView { + readonly batch: ObservableValue; + readonly range: MessageRange; + readonly outbox: LocalEventOutbox; + readonly relations: RelationsProcessor; + paginateBackward(limit: number): Promise; + paginateForward(limit: number): Promise; + findEvent(eventId: string): MessageEventWithoutRoomId | undefined; + jumpToEvent(eventId: string): Promise; + jumpToLatest(): Promise; + sendMessage( + originalMessage: LocalMessage, + wireMessage: LocalMessage, + txnId: string + ): Promise; +} diff --git a/src/client/room/index.ts b/src/client/room/index.ts new file mode 100644 index 00000000..012879e4 --- /dev/null +++ b/src/client/room/index.ts @@ -0,0 +1,96 @@ +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { Disposer } from 'neurone/lib/utils/disposable'; +import { JoinRoom } from 'neurone/lib/api/matrix/client/v3/sync'; +import { MatrixRequest } from 'neurone/lib/matrix-request'; +import { Timeline } from './Timeline'; +import { SyncValue } from '../sync'; + +export * from './Timeline'; +export * from './TimelineView'; + +export const createTimelines = ( + joinedRooms: ObservableValue, + syncObservable: ObservableValue, + request: MatrixRequest, + disposer: Disposer +): ObservableValue> => { + const publishKey: string = crypto.randomUUID(); + + const roomToTimeline = new ObservableValue>(new Map(), publishKey); + + const initTimelineForRooms = (roomIds: string[]) => { + const nextTimelines = new Map(roomToTimeline.read); + roomIds.forEach((rId) => { + if (nextTimelines.has(rId)) return; + nextTimelines.set(rId, new Timeline(rId, publishKey, request)); + }); + roomToTimeline.publish(publishKey, nextTimelines); + }; + + const pushTimelines = (timelines: Timeline[]) => { + if (timelines.length === 0) return; + + const nextTimelines = new Map(roomToTimeline.read); + timelines.forEach((t) => { + if (nextTimelines.has(t.roomId)) return; + nextTimelines.set(t.roomId, t); + }); + roomToTimeline.publish(publishKey, nextTimelines); + }; + + const deleteTimelines = (roomIds: string[]) => { + const nextTimelines = new Map(roomToTimeline.read); + + roomIds.forEach((rId) => { + const timeline = nextTimelines.get(rId); + if (timeline) { + timeline.dispose?.(); + nextTimelines.delete(rId); + } + }); + + roomToTimeline.publish(publishKey, nextTimelines); + }; + + initTimelineForRooms(joinedRooms.read); + + disposer.collect( + joinedRooms.subscribe((rooms) => { + initTimelineForRooms(rooms); + const removedRooms = Array.from(roomToTimeline.read.keys()).filter((r) => !rooms.includes(r)); + deleteTimelines(removedRooms); + }) + ); + + const writeToTimelines = (join: Record) => { + const joinRooms = Object.keys(join); + const unknownTimeline: Timeline[] = []; + + joinRooms.forEach((roomId) => { + const joinRoom = join[roomId]; + const timeline = roomToTimeline.read.get(roomId); + if (!timeline) { + const nT = new Timeline(roomId, publishKey, request); + nT.onSync.publish(publishKey, joinRoom); + unknownTimeline.push(nT); + return; + } + timeline.onSync.publish(publishKey, joinRoom); + }); + + pushTimelines(unknownTimeline); + }; + + disposer.collect( + syncObservable.subscribe((value) => { + const [, result] = value ?? []; + const join = result?.rooms?.join; + + if (join) { + writeToTimelines(join); + } + }) + ); + + return roomToTimeline; +}; diff --git a/src/client/room/local-event/LocalEvent.ts b/src/client/room/local-event/LocalEvent.ts new file mode 100644 index 00000000..275547ec --- /dev/null +++ b/src/client/room/local-event/LocalEvent.ts @@ -0,0 +1,29 @@ +export class LocalMessage { + constructor( + public readonly roomId: string, + public readonly type: string, + public readonly content: TContent + ) {} + + clone(): LocalMessage { + return new LocalMessage(this.roomId, this.type, this.content); + } +} + +export class LocalEvent { + public readonly original: LocalMessage; + + public readonly wire: LocalMessage; + + public readonly txnId: string; + + public readonly ts: number; + + constructor(original: LocalMessage, wire: LocalMessage, txnId: string, ts: number) { + this.original = original; + this.wire = wire; + this.txnId = txnId; + + this.ts = ts; + } +} diff --git a/src/client/room/local-event/LocalEventOutbox.ts b/src/client/room/local-event/LocalEventOutbox.ts new file mode 100644 index 00000000..89a0fa99 --- /dev/null +++ b/src/client/room/local-event/LocalEventOutbox.ts @@ -0,0 +1,53 @@ +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { EventSenderSuccess, LocalEventSender } from './LocalEventSender'; + +export class LocalEventOutbox { + private readonly publishKey = crypto.randomUUID(); + + public readonly senders: ObservableValue; + + constructor() { + this.senders = new ObservableValue([], this.publishKey); + } + + private get items(): readonly LocalEventSender[] { + return this.senders.read; + } + + public add(sender: LocalEventSender): LocalEventSender { + const n = this.items.filter((s) => s !== sender); + n.push(sender); + this.senders.publish(this.publishKey, n); + + return sender; + } + + public remove(sender: LocalEventSender): void { + const index = this.items.indexOf(sender); + + if (index === -1) { + return; + } + + const n = this.items.filter((s) => s !== sender); + this.senders.publish(this.publishKey, n); + } + + public removeByEventIds(eventIds: readonly string[]): void { + const ids = new Set(eventIds); + + const n = this.items.filter( + (s) => !(s.state.read instanceof EventSenderSuccess && ids.has(s.state.read.eventId)) + ); + if (n.length === this.items.length) { + return; + } + this.senders.publish(this.publishKey, n); + } + + public getSentEventIds(): string[] { + return this.items + .map((s) => (s.state.read instanceof EventSenderSuccess ? s.state.read.eventId : undefined)) + .filter((eId): eId is string => typeof eId === 'string'); + } +} diff --git a/src/client/room/local-event/LocalEventSender.ts b/src/client/room/local-event/LocalEventSender.ts new file mode 100644 index 00000000..9d885d76 --- /dev/null +++ b/src/client/room/local-event/LocalEventSender.ts @@ -0,0 +1,111 @@ +import { putSend, PutSendOK } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { MatrixError, MatrixRequest, parseMatrixResp } from 'neurone/lib/matrix-request'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import to from 'await-to-js'; +import { LocalEvent } from './LocalEvent'; + +export class EventSenderIdle { + constructor(public readonly send: () => Promise) {} +} + +export class EventSenderPending { + constructor(public readonly abort: () => void) {} +} + +export class EventSenderSuccess { + constructor(public readonly eventId: string) {} +} + +export class EventSenderError { + constructor( + public readonly resend: () => Promise, + public readonly error?: MatrixError, + public readonly cause?: unknown + ) {} +} + +export type LocalEventSenderState = + | EventSenderIdle + | EventSenderPending + | EventSenderSuccess + | EventSenderError; + +export class LocalEventSender { + public readonly localEvent: LocalEvent; + + private readonly request: MatrixRequest; + + private readonly publishKey = crypto.randomUUID(); + + public readonly state: ObservableValue; + + private sending = false; + + constructor(localEvent: LocalEvent, request: MatrixRequest) { + this.localEvent = localEvent; + this.request = request; + this.state = new ObservableValue(new EventSenderIdle(this.send.bind(this)), this.publishKey); + } + + private markIdle() { + this.state.publish(this.publishKey, new EventSenderIdle(this.send.bind(this))); + } + + private markSending(abortController: AbortController) { + this.state.publish(this.publishKey, new EventSenderPending(() => abortController.abort())); + } + + private markSent(eventId: string) { + this.state.publish(this.publishKey, new EventSenderSuccess(eventId)); + } + + private markFailed(error?: MatrixError, cause?: unknown) { + this.state.publish(this.publishKey, new EventSenderError(this.send.bind(this), error, cause)); + } + + private async send(): Promise { + if (this.sending) return; + this.sending = true; + + try { + const abortController = new AbortController(); + + this.markSending(abortController); + + const [reqError, response] = await to( + this.request( + putSend({ + pathParams: { + roomId: this.localEvent.wire.roomId, + eventType: this.localEvent.wire.type, + txnId: this.localEvent.txnId, + }, + body: this.localEvent.wire.content, + }) + ), + abortController.signal + ); + + if (reqError) { + if (reqError.name === 'AbortError') { + this.markIdle(); + return; + } + + this.markFailed(undefined, reqError); + return; + } + + const [err, result] = await parseMatrixResp(response); + + if (err) { + this.markFailed(err, undefined); + return; + } + + this.markSent(result.event_id); + } finally { + this.sending = false; + } + } +} diff --git a/src/client/room/local-event/index.ts b/src/client/room/local-event/index.ts new file mode 100644 index 00000000..6da91b4b --- /dev/null +++ b/src/client/room/local-event/index.ts @@ -0,0 +1,2 @@ +export * from './LocalEvent'; +export * from './LocalEventSender'; diff --git a/src/client/room/messages/BatchChange.ts b/src/client/room/messages/BatchChange.ts new file mode 100644 index 00000000..8c16e42e --- /dev/null +++ b/src/client/room/messages/BatchChange.ts @@ -0,0 +1,14 @@ +type ChangeType = 'Grow' | 'Shrink'; + +type ChangeDirection = 'f' | 'b'; + +export class BatchChange { + public readonly type?: ChangeType; + + public readonly dir?: ChangeDirection; + + constructor(type?: ChangeType, dir?: ChangeDirection) { + this.type = type; + this.dir = dir; + } +} diff --git a/src/client/room/messages/BatchInfo.ts b/src/client/room/messages/BatchInfo.ts new file mode 100644 index 00000000..7bd6c765 --- /dev/null +++ b/src/client/room/messages/BatchInfo.ts @@ -0,0 +1,10 @@ +export class BatchInfo { + public readonly token?: string; + + public readonly end?: boolean; + + constructor(token?: string, end?: boolean) { + this.token = token; + this.end = end; + } +} diff --git a/src/client/room/messages/MessageBatch.ts b/src/client/room/messages/MessageBatch.ts new file mode 100644 index 00000000..a9d5d8b1 --- /dev/null +++ b/src/client/room/messages/MessageBatch.ts @@ -0,0 +1,43 @@ +import { BatchChange } from './BatchChange'; +import { BatchInfo } from './BatchInfo'; +import { Messages } from '.'; + +export class MessageBatch { + public readonly id: string; + + public readonly messages: Messages; + + public readonly prev: BatchInfo; + + public readonly next: BatchInfo; + + public readonly change: BatchChange; + + static emptyAtForwardEnd(): MessageBatch { + return new MessageBatch( + crypto.randomUUID(), + new Messages([]), + new BatchInfo(), + new BatchInfo(undefined, true), + new BatchChange() + ); + } + + constructor( + id: string, + messages: Messages, + prev: BatchInfo, + next: BatchInfo, + change: BatchChange + ) { + this.id = id; + this.messages = messages; + this.prev = prev; + this.next = next; + this.change = change; + } + + get size(): number { + return this.messages.events.length; + } +} diff --git a/src/client/room/messages/MessageRange.ts b/src/client/room/messages/MessageRange.ts new file mode 100644 index 00000000..41e9b0f4 --- /dev/null +++ b/src/client/room/messages/MessageRange.ts @@ -0,0 +1,90 @@ +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { MessageBatch } from './MessageBatch'; + +export type RangeInfo = { + start: number; + end: number; +}; + +export class MessageRange implements Disposable { + public readonly disposer = new Disposer(); + + public readonly info: ObservableValue; + + private readonly publishKey = crypto.randomUUID(); + + private oldLength: number; + + /** + * View saves this on unmount to restore later. + */ + public scrollTopCache: number | undefined; + + static rangeLimit = 80; + + static atLiveEnd(len: number): RangeInfo { + return { + start: Math.max(0, len - this.rangeLimit), + end: len, + }; + } + + constructor(batch: ObservableValue) { + const len = batch.read.size; + + this.info = new ObservableValue(MessageRange.atLiveEnd(len), this.publishKey); + this.oldLength = len; + + this.disposer.collect(batch.subscribe(this.onBatchUpdate.bind(this))); + } + + private onBatchUpdate(batch: MessageBatch) { + const { change, size } = batch; + const oldLen = this.oldLength; + this.oldLength = size; + + if (change.type === 'Grow' && change.dir === 'b') { + const lenAdded = size - oldLen; + this.setRange( + oldLen === 0 && batch.next.end + ? MessageRange.atLiveEnd(size) + : this.adjustBackwardGrowth(lenAdded) + ); + return; + } + if (change.type === 'Grow' && change.dir === 'f') { + if (oldLen !== size && batch.next.end) { + // keep timeline at live end and updated + const lenAdded = size - oldLen; + this.setRange({ + start: size <= MessageRange.rangeLimit ? 0 : lenAdded + this.info.read.start, + end: lenAdded + this.info.read.end, + }); + } + return; + } + + if (change.type === 'Shrink') { + // NOTE: skip as we do not delete messages. + return; + } + + this.setRange(MessageRange.atLiveEnd(size)); + } + + private adjustBackwardGrowth(lenthChange: number): RangeInfo { + return { + start: this.info.read.start + lenthChange, + end: this.info.read.end + lenthChange, + }; + } + + public setRange(info: RangeInfo) { + this.info.publish(this.publishKey, info); + } + + public dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/room/messages/Messages.ts b/src/client/room/messages/Messages.ts new file mode 100644 index 00000000..ff00d865 --- /dev/null +++ b/src/client/room/messages/Messages.ts @@ -0,0 +1,82 @@ +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; + +export class Messages { + public readonly events: MessageEventWithoutRoomId[]; + + constructor(events: MessageEventWithoutRoomId[]) { + this.events = Messages.sort(events); + } + + public fork(): Messages { + return new Messages(this.events); + } + + public overlap(events: MessageEventWithoutRoomId[]): boolean { + return events.some((e) => this.events.some((ev) => ev.event_id === e.event_id)); + } + + public mergeFork(events: MessageEventWithoutRoomId[]): Messages { + const newEvents = Messages.sort(events); + + return Messages.merge(newEvents, this.events); + } + + public get(eventId: string): MessageEventWithoutRoomId | undefined { + return this.events.find((event) => event.event_id === eventId); + } + + static sort(events: MessageEventWithoutRoomId[]): MessageEventWithoutRoomId[] { + return [...events].sort((a, b) => a.origin_server_ts - b.origin_server_ts); + } + + static merge(m1: MessageEventWithoutRoomId[], m2: MessageEventWithoutRoomId[]): Messages { + const seen = new Set(); + const merged: MessageEventWithoutRoomId[] = []; + + let i = 0; + let j = 0; + + while (i < m1.length && j < m2.length) { + const oldEvent = m1[i]; + const newEvent = m2[j]; + + if (oldEvent.origin_server_ts <= newEvent.origin_server_ts) { + if (!seen.has(oldEvent.event_id)) { + seen.add(oldEvent.event_id); + merged.push(oldEvent); + } + i += 1; + } else { + if (!seen.has(newEvent.event_id)) { + seen.add(newEvent.event_id); + merged.push(newEvent); + } + j += 1; + } + } + + // Drain remaining old events + while (i < m1.length) { + const event = m1[i]; + i += 1; + + if (!seen.has(event.event_id)) { + seen.add(event.event_id); + merged.push(event); + } + } + + // Drain remaining new events + while (j < m2.length) { + const event = m2[j]; + j += 1; + + if (!seen.has(event.event_id)) { + seen.add(event.event_id); + merged.push(event); + } + } + + return new Messages(merged); + } +} diff --git a/src/client/room/messages/index.ts b/src/client/room/messages/index.ts new file mode 100644 index 00000000..de605a2a --- /dev/null +++ b/src/client/room/messages/index.ts @@ -0,0 +1,5 @@ +export * from './BatchChange'; +export * from './BatchInfo'; +export * from './MessageBatch'; +export * from './MessageRange'; +export * from './Messages'; diff --git a/src/client/room/relations/Relations.ts b/src/client/room/relations/Relations.ts new file mode 100644 index 00000000..c59029b8 --- /dev/null +++ b/src/client/room/relations/Relations.ts @@ -0,0 +1,57 @@ +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { Relation, RelationType } from '../../../types/matrix/common'; +import { rContent, rRelatesTo } from '../../reader'; + +export class Relations = Relation> + implements Disposable +{ + public readonly relType: RelationType; + + public readonly eventToRelations: ObservableValue< + Map[]> + >; + + private publishKey: string = crypto.randomUUID(); + + private disposer = new Disposer(); + + constructor(relType: T) { + this.relType = relType; + + this.eventToRelations = new ObservableValue(new Map(), this.publishKey); + } + + public addEvents(events: MessageEventWithoutRoomId[]) { + if (events.length === 0) return; + const eventToRelations = new Map(this.eventToRelations.read); + + const relationEvents: MessageEventWithoutRoomId[] = []; + + events.forEach((event) => { + if ( + rRelatesTo.relType(rContent.relatesTo(event.content)) === this.relType && + rRelatesTo.eventId(rContent.relatesTo(event.content)) + ) { + relationEvents.push(event as MessageEventWithoutRoomId); + } + }); + + if (relationEvents.length === 0) return; + + relationEvents.forEach((event) => { + const eventId = event.content['m.relates_to'].event_id; + const relations = eventToRelations.get(eventId) ?? []; + const newRelations = relations.filter((r) => r.event_id !== event.event_id); + newRelations.push(event as MessageEventWithoutRoomId); + eventToRelations.set(eventId, newRelations); + }); + + this.eventToRelations.publish(this.publishKey, eventToRelations); + } + + dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/room/relations/RelationsProcessor.ts b/src/client/room/relations/RelationsProcessor.ts new file mode 100644 index 00000000..49925762 --- /dev/null +++ b/src/client/room/relations/RelationsProcessor.ts @@ -0,0 +1,98 @@ +import { ClientEventWithoutRoomId, MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { Disposable, Disposer } from 'neurone/lib/utils/disposable'; +import { MessageEvent } from '../../../types/matrix/room'; +import { Relations } from './Relations'; +import { RelationType, ReactionContent } from '../../../types/matrix/common'; +import { getRedactedEventId, redacted, reference, relation, replacement } from './utils'; + +export class RelationsProcessor implements Disposable { + private readonly publishKey = crypto.randomUUID(); + + public readonly reactions: Relations; + + public readonly edits: Relations; + + public readonly refs: Relations; + + public readonly redacted: ObservableValue>; + + private readonly disposer = new Disposer(); + + constructor() { + this.reactions = new Relations(RelationType.Annotation); + this.edits = new Relations(RelationType.Replace); + this.refs = new Relations(RelationType.Reference); + this.redacted = new ObservableValue(new Set(), this.publishKey); + + this.disposer.collect(this.reactions, this.edits, this.refs, this.redacted); + } + + private addRedacted(events: string[]): void { + if (events.length === 0) return; + this.redacted.publish(this.publishKey, new Set([...events, ...this.redacted.read])); + } + + /** + * Processes a list of events and extracts: + * - Message events (returned) + * - Redaction events (added to redacted set) + * - Reaction events (added to reactions) + * - Edit events (added to edits) + * - Reference events (added to refs) + */ + public pickAndFilter(events: ClientEventWithoutRoomId[]): MessageEventWithoutRoomId[] { + const messageEvents: MessageEventWithoutRoomId[] = []; + const redactedEvents: string[] = []; + const reactionEvents: MessageEventWithoutRoomId[] = []; + const editEvents: MessageEventWithoutRoomId[] = []; + const referenceEvents: MessageEventWithoutRoomId[] = []; + + events?.forEach((event) => { + // Skip state events + if ('state_key' in event) return; + + // Handle redaction events + if (event.type === MessageEvent.RoomRedaction) { + const redactedEvtId = getRedactedEventId(event); + if (redactedEvtId) redactedEvents.push(redactedEvtId); + return; + } + + // Check if event itself is redacted + if (redacted(event)) { + redactedEvents.push(event.event_id); + } + + // Handle relation events + if (relation(event)) { + if (event.type === MessageEvent.Reaction) { + reactionEvents.push(event); + return; + } + if (replacement(event)) { + editEvents.push(event); + return; + } + if (reference(event)) { + referenceEvents.push(event); + return; + } + } + + messageEvents.push(event); + }); + + // Add all extracted relations + this.addRedacted(redactedEvents); + this.reactions.addEvents(reactionEvents); + this.edits.addEvents(editEvents); + this.refs.addEvents(referenceEvents); + + return messageEvents; + } + + public dispose(): void { + this.disposer.run(); + } +} diff --git a/src/client/room/relations/index.ts b/src/client/room/relations/index.ts new file mode 100644 index 00000000..4c4a49f5 --- /dev/null +++ b/src/client/room/relations/index.ts @@ -0,0 +1,3 @@ +export * from './Relations'; +export * from './RelationsProcessor'; +export * from './utils'; diff --git a/src/client/room/relations/utils.ts b/src/client/room/relations/utils.ts new file mode 100644 index 00000000..cf3e6801 --- /dev/null +++ b/src/client/room/relations/utils.ts @@ -0,0 +1,36 @@ +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { rContent, rUnsigned, rEvent, rRelatesTo } from '../../reader'; +import { RelationType } from '../../../types/matrix/common'; + +export const getRedactedEventId = (event: MessageEventWithoutRoomId): string | undefined => { + const { content } = event; + + return rEvent.redacts(event) ?? rContent.redacts(content); +}; + +export const redacted = (event: MessageEventWithoutRoomId): boolean => { + const { unsigned } = event; + + return rUnsigned.redactedBecause(unsigned) !== undefined; +}; + +export const relation = (event: MessageEventWithoutRoomId): boolean => + rRelatesTo.relType(rContent.relatesTo(event.content)) !== undefined; + +export const replacement = (event: MessageEventWithoutRoomId): boolean => { + const { content } = event; + + return ( + rRelatesTo.relType(rContent.relatesTo(content)) === RelationType.Replace && + rRelatesTo.eventId(rContent.relatesTo(content)) !== undefined + ); +}; + +export const reference = (event: MessageEventWithoutRoomId): boolean => { + const { content } = event; + + return ( + rRelatesTo.relType(rContent.relatesTo(content)) === RelationType.Reference && + rRelatesTo.eventId(rContent.relatesTo(content)) !== undefined + ); +}; diff --git a/src/client/storage/collections/AccountData.ts b/src/client/storage/collections/AccountData.ts new file mode 100644 index 00000000..e4636ade --- /dev/null +++ b/src/client/storage/collections/AccountData.ts @@ -0,0 +1,28 @@ +import { StorageDriver } from 'neurone/lib/storage'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { AccountDataEvent } from 'neurone/lib/api/types'; +import { IDBPDatabase } from 'idb'; + +const ACCOUNT_DATA_STORE_NAME = 'AccountData'; + +export const prepareAccountDataStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + idb.createObjectStore(ACCOUNT_DATA_STORE_NAME, { + keyPath: 'id', + }); + } +}; + +export type AccountDataObject = Entry< + 'id', + never, + { + id: string; + event: AccountDataEvent; + } +>; + +export type AccountDataCollection = Collection<'id', never, AccountDataObject>; + +export const createAccountDataCollection = (driver: StorageDriver): AccountDataCollection => + new Collection(ACCOUNT_DATA_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/InviteRoom.ts b/src/client/storage/collections/InviteRoom.ts new file mode 100644 index 00000000..c703cfc8 --- /dev/null +++ b/src/client/storage/collections/InviteRoom.ts @@ -0,0 +1,37 @@ +import { IDBPDatabase } from 'idb'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { StrippedStateEvent } from 'neurone/lib/api/types'; +import { StorageDriver } from 'neurone/lib/storage'; +import { encodeKey } from '../utils'; + +const ROOM_STATE_STORE_NAME = 'InviteRoomState'; + +export const getInviteRoomStateId = (roomId: string, type: string, stateKey: string): string => + encodeKey(roomId, type, stateKey); + +export const prepareInviteRoomStateStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ROOM_STATE_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('type', 'type', { unique: false }); + } +}; + +export type InviteRoomStateObject = Entry< + 'id', + 'roomId' | 'type', + { + id: string; + roomId: string; + type: string; + event: StrippedStateEvent; + } +>; + +export type InviteRoomStateCollection = Collection<'id', 'roomId' | 'type', InviteRoomStateObject>; + +export const createInviteRoomStateCollection = (driver: StorageDriver): InviteRoomStateCollection => + new Collection(ROOM_STATE_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/JoinRoom.ts b/src/client/storage/collections/JoinRoom.ts new file mode 100644 index 00000000..325e0a45 --- /dev/null +++ b/src/client/storage/collections/JoinRoom.ts @@ -0,0 +1,176 @@ +import { IDBPDatabase } from 'idb'; +import { + AccountDataEvent, + MessageEventWithoutRoomId, + RoomSummary, + StateEventWithoutRoomId, +} from 'neurone/lib/api/types'; +import { StorageDriver } from 'neurone/lib/storage'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { encodeKey } from '../utils'; + +/** + * JoinAccountData Store + */ +const ACCOUNT_DATA_STORE_NAME = 'JoinAccountData'; + +export const getJoinAccountDataId = (roomId: string, type: string): string => + encodeKey(roomId, type); + +export const prepareJoinAccountDataStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ACCOUNT_DATA_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('type', 'type', { unique: false }); + } +}; + +export type JoinAccountDataObject = Entry< + 'id', + 'roomId' | 'type', + { + id: string; + roomId: string; + type: string; + event: AccountDataEvent; + } +>; + +export type JoinAccountDataCollection = Collection<'id', 'roomId' | 'type', JoinAccountDataObject>; + +export const createJoinAccountDataCollection = (driver: StorageDriver): JoinAccountDataCollection => + new Collection(ACCOUNT_DATA_STORE_NAME, 'id', driver); + +/** + * JoinRoomSummary Store + */ +const ROOM_SUMMARY_STORE_NAME = 'JoinRoomSummary'; + +export const equalRoomSummary = (s1: RoomSummary, s2: RoomSummary): boolean => + s1['m.invited_member_count'] === s2['m.invited_member_count'] && + s1['m.joined_member_count'] === s2['m.joined_member_count'] && + s1['m.heroes']?.length === s2['m.heroes']?.length && + (s1['m.heroes']?.every((userId, index) => userId === s2['m.heroes']?.[index]) ?? true); + +export const prepareJoinRoomSummaryStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + idb.createObjectStore(ROOM_SUMMARY_STORE_NAME, { + keyPath: 'id', + }); + } +}; + +export type JoinRoomSummaryObject = Entry< + 'id', + never, + { + id: string; + summary: RoomSummary; + } +>; +export type JoinRoomSummaryCollection = Collection<'id', never, JoinRoomSummaryObject>; + +export const createJoinRoomSummaryCollection = (driver: StorageDriver): JoinRoomSummaryCollection => + new Collection(ROOM_SUMMARY_STORE_NAME, 'id', driver); + +/** + * JoinRoomState Store + */ +const ROOM_STATE_STORE_NAME = 'JoinRoomState'; + +export const getJoinRoomStateId = (roomId: string, type: string, stateKey: string): string => + encodeKey(roomId, type, stateKey); + +export const prepareJoinRoomStateStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ROOM_STATE_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('type', 'type', { unique: false }); + } +}; + +export type JoinRoomStateObject = Entry< + 'id', + 'roomId' | 'type', + { + id: string; + roomId: string; + type: string; + event: StateEventWithoutRoomId; + } +>; + +export type JoinRoomStateCollection = Collection<'id', 'roomId' | 'type', JoinRoomStateObject>; + +export const createJoinRoomStateCollection = (driver: StorageDriver): JoinRoomStateCollection => + new Collection(ROOM_STATE_STORE_NAME, 'id', driver); + +/** + * JoinRoomMember Store + */ +const ROOM_MEMBER_STORE_NAME = 'JoinRoomMember'; + +export const getJoinRoomMemberId = (roomId: string, userId: string): string => + encodeKey(roomId, userId); + +export const prepareJoinRoomMemberStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ROOM_MEMBER_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('userId', 'userId', { unique: false }); + } +}; + +export type JoinRoomMemberObject = Entry< + 'id', + 'roomId' | 'userId', + { + id: string; + roomId: string; + userId: string; + event: StateEventWithoutRoomId; + } +>; + +export type JoinRoomMemberCollection = Collection<'id', 'roomId' | 'userId', JoinRoomMemberObject>; + +export const createJoinRoomMemberCollection = (driver: StorageDriver): JoinRoomMemberCollection => + new Collection(ROOM_MEMBER_STORE_NAME, 'id', driver); + +/** + * JoinRoomLatestEvent Store + */ + +const ROOM_LATEST_EVENT_STORE_NAME = 'JoinRoomLatestEvent'; + +export const prepareJoinRoomLatestEventStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + idb.createObjectStore(ROOM_LATEST_EVENT_STORE_NAME, { + keyPath: 'id', + }); + } +}; + +export type JoinRoomLatestEventObject = Entry< + 'id', + never, + { + id: string; + event: MessageEventWithoutRoomId; + } +>; + +export type JoinRoomLatestCollection = Collection<'id', never, JoinRoomLatestEventObject>; + +export const createJoinRoomLatestEventCollection = ( + driver: StorageDriver +): JoinRoomLatestCollection => new Collection(ROOM_LATEST_EVENT_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/KnockRoom.ts b/src/client/storage/collections/KnockRoom.ts new file mode 100644 index 00000000..4aa45479 --- /dev/null +++ b/src/client/storage/collections/KnockRoom.ts @@ -0,0 +1,37 @@ +import { IDBPDatabase } from 'idb'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { StrippedStateEvent } from 'neurone/lib/api/types'; +import { StorageDriver } from 'neurone/lib/storage'; +import { encodeKey } from '../utils'; + +const ROOM_STATE_STORE_NAME = 'KnockRoomState'; + +export const getKnockRoomStateId = (roomId: string, type: string, stateKey: string): string => + encodeKey(roomId, type, stateKey); + +export const prepareKnockRoomStateStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ROOM_STATE_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('type', 'type', { unique: false }); + } +}; + +export type KnockRoomStateObject = Entry< + 'id', + 'roomId' | 'type', + { + id: string; + roomId: string; + type: string; + event: StrippedStateEvent; + } +>; + +export type KnockRoomStateCollection = Collection<'id', 'roomId' | 'type', KnockRoomStateObject>; + +export const createKnockRoomStateCollection = (driver: StorageDriver): KnockRoomStateCollection => + new Collection(ROOM_STATE_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/LeaveRoom.ts b/src/client/storage/collections/LeaveRoom.ts new file mode 100644 index 00000000..5e79fa19 --- /dev/null +++ b/src/client/storage/collections/LeaveRoom.ts @@ -0,0 +1,80 @@ +import { IDBPDatabase } from 'idb'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { AccountDataEvent, StateEventWithoutRoomId } from 'neurone/lib/api/types'; +import { StorageDriver } from 'neurone/lib/storage'; +import { encodeKey } from '../utils'; + +/** + * LeaveAccountData Store + */ +const ACCOUNT_DATA_STORE_NAME = 'LeaveAccountData'; + +export const getLeaveAccountDataId = (roomId: string, type: string): string => + encodeKey(roomId, type); + +export const prepareLeaveAccountDataStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ACCOUNT_DATA_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('type', 'type', { unique: false }); + } +}; + +export type LeaveAccountDataObject = Entry< + 'id', + 'roomId' | 'type', + { + id: string; + roomId: string; + type: string; + event: AccountDataEvent; + } +>; + +export type LeaveAccountDataCollection = Collection< + 'id', + 'roomId' | 'type', + LeaveAccountDataObject +>; + +export const createLeaveAccountDataCollection = ( + driver: StorageDriver +): LeaveAccountDataCollection => new Collection(ACCOUNT_DATA_STORE_NAME, 'id', driver); + +/** + * LeaveRoomState Store + */ +const ROOM_STATE_STORE_NAME = 'LeaveRoomState'; + +export const getLeaveRoomStateId = (roomId: string, type: string, stateKey: string): string => + encodeKey(roomId, type, stateKey); + +export const prepareLeaveRoomStateStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(ROOM_STATE_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('type', 'type', { unique: false }); + } +}; + +export type LeaveRoomStateObject = Entry< + 'id', + 'roomId' | 'type', + { + id: string; + roomId: string; + type: string; + event: StateEventWithoutRoomId; + } +>; + +export type LeaveRoomStateCollection = Collection<'id', 'roomId' | 'type', LeaveRoomStateObject>; + +export const createLeaveRoomStateCollection = (driver: StorageDriver): LeaveRoomStateCollection => + new Collection(ROOM_STATE_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/Presence.ts b/src/client/storage/collections/Presence.ts new file mode 100644 index 00000000..1a67594c --- /dev/null +++ b/src/client/storage/collections/Presence.ts @@ -0,0 +1,31 @@ +import { IDBPDatabase } from 'idb'; +import { Presence, StatusContent } from 'neurone/lib/api/types'; +import { StorageDriver } from 'neurone/lib/storage'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; + +const PRESENCE_STORE_NAME = 'Presence'; + +export const preparePresenceStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(PRESENCE_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('presence', 'presence', { unique: false }); + } +}; + +export type PresenceObject = Entry< + 'id', + 'presence', + { + id: string; + presence: Presence; + content: StatusContent; + } +>; + +export type PresenceCollection = Collection<'id', 'presence', PresenceObject>; + +export const createPresenceCollection = (driver: StorageDriver): PresenceCollection => + new Collection(PRESENCE_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/ReadReceipt.ts b/src/client/storage/collections/ReadReceipt.ts new file mode 100644 index 00000000..0ce4a87b --- /dev/null +++ b/src/client/storage/collections/ReadReceipt.ts @@ -0,0 +1,44 @@ +import { IDBPDatabase } from 'idb'; +import { StorageDriver } from 'neurone/lib/storage'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { ReceiptType } from 'neurone/lib/api/types'; +import { encodeKey } from '../utils'; +import { ThreadId } from '../../../types/matrix/room'; + +const READ_RECEIPT_STORE_NAME = 'ReadReceipt'; + +export const getReadReceiptId = ( + roomId: string, + userId: string, + threadId: string | ThreadId +): string => encodeKey(roomId, userId, threadId); + +export const prepareReadReceiptStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(READ_RECEIPT_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + store.createIndex('eventId', 'eventId', { unique: false }); + } +}; + +export type ReadReceiptObject = Entry< + 'id', + 'eventId' | 'roomId', + { + id: string; + roomId: string; + userId: string; + type: ReceiptType; + eventId: string; + ts: number; + threadId: string | ThreadId; + } +>; + +export type ReadReceiptCollection = Collection<'id', 'eventId' | 'roomId', ReadReceiptObject>; + +export const createReadReceiptCollection = (driver: StorageDriver): ReadReceiptCollection => + new Collection(READ_RECEIPT_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/SessionValue.ts b/src/client/storage/collections/SessionValue.ts new file mode 100644 index 00000000..56b6a29b --- /dev/null +++ b/src/client/storage/collections/SessionValue.ts @@ -0,0 +1,65 @@ +import { IDBPDatabase } from 'idb'; +import { StorageDriver } from 'neurone/lib/storage'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; + +const SESSION_VALUE_STORE_NAME = 'SessionValue'; + +export const prepareSessionValueStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + idb.createObjectStore(SESSION_VALUE_STORE_NAME, { + keyPath: 'id', + }); + } +}; + +export type SessionValueObject = Entry< + 'id', + never, + { + id: string; + value: unknown; + } +>; + +export type SessionValueCollection = Collection<'id', never, SessionValueObject>; + +export const createSessionValueCollection = (driver: StorageDriver): SessionValueCollection => + new Collection(SESSION_VALUE_STORE_NAME, 'id', driver); + +/** + * utils for better read/write for unknown values + */ + +export type ValueParser = (obj?: SessionValueObject) => T | undefined; + +export type ValueUtil = { + id: string; + parse: ValueParser; +}; + +export type SessionValueUtils = { + syncToken: ValueUtil; + pendingMemberRooms: ValueUtil; +}; + +export const sessionValue: SessionValueUtils = { + syncToken: { + id: 'syncToken', + parse: (obj) => { + if (obj && typeof obj.value === 'string') { + return obj.value; + } + + return undefined; + }, + }, + pendingMemberRooms: { + id: 'pendingMemberRooms', + parse: (obj?: SessionValueObject) => { + if (obj && Array.isArray(obj.value)) { + return obj.value.filter((i) => typeof i === 'string'); + } + return undefined; + }, + }, +} as const; diff --git a/src/client/storage/collections/UnreadNotifications.ts b/src/client/storage/collections/UnreadNotifications.ts new file mode 100644 index 00000000..fb92e5bf --- /dev/null +++ b/src/client/storage/collections/UnreadNotifications.ts @@ -0,0 +1,71 @@ +import { IDBPDatabase } from 'idb'; +import { UnreadNotificationCounts } from 'neurone/lib/api/types'; +import { StorageDriver } from 'neurone/lib/storage'; +import { Collection, Entry } from 'neurone/lib/storage/collection'; +import { encodeKey } from '../utils'; + +/** + * UnreadNotifications + */ +const UNREAD_NOTIFICATIONS_STORE_NAME = 'UnreadNotifications'; + +export const prepareUnreadNotificationsStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + idb.createObjectStore(UNREAD_NOTIFICATIONS_STORE_NAME, { + keyPath: 'id', + }); + } +}; + +export type UnreadNotificationsObject = Entry< + 'id', + never, + { + id: string; + unread: UnreadNotificationCounts; + } +>; +export type UnreadNotificationsCollection = Collection<'id', never, UnreadNotificationsObject>; + +export const createUnreadNotificationsCollection = ( + driver: StorageDriver +): UnreadNotificationsCollection => new Collection(UNREAD_NOTIFICATIONS_STORE_NAME, 'id', driver); + +/** + * UnreadThreadNotifications + */ +const UNREAD_THREAD_NOTIFICATIONS_STORE_NAME = 'UnreadThreadNotifications'; + +export const getUnreadThreadNotificationId = (roomId: string, threadId: string): string => + encodeKey(roomId, threadId); + +export const prepareUnreadThreadNotificationsStore = (idb: IDBPDatabase, oldVersion: number) => { + if (oldVersion < 1) { + const store = idb.createObjectStore(UNREAD_THREAD_NOTIFICATIONS_STORE_NAME, { + keyPath: 'id', + }); + + store.createIndex('roomId', 'roomId', { unique: false }); + } +}; + +export type UnreadThreadNotificationsObject = Entry< + 'id', + 'roomId', + { + id: string; + roomId: string; + threadId: string; + unread: UnreadNotificationCounts; + } +>; +export type UnreadThreadNotificationsCollection = Collection< + 'id', + 'roomId', + UnreadThreadNotificationsObject +>; + +export const createUnreadThreadNotificationsCollection = ( + driver: StorageDriver +): UnreadThreadNotificationsCollection => + new Collection(UNREAD_THREAD_NOTIFICATIONS_STORE_NAME, 'id', driver); diff --git a/src/client/storage/collections/index.ts b/src/client/storage/collections/index.ts new file mode 100644 index 00000000..a3451be5 --- /dev/null +++ b/src/client/storage/collections/index.ts @@ -0,0 +1,9 @@ +export * from './AccountData'; +export * from './JoinRoom'; +export * from './ReadReceipt'; +export * from './UnreadNotifications'; +export * from './InviteRoom'; +export * from './KnockRoom'; +export * from './LeaveRoom'; +export * from './Presence'; +export * from './SessionValue'; diff --git a/src/client/storage/index.ts b/src/client/storage/index.ts new file mode 100644 index 00000000..46653c5a --- /dev/null +++ b/src/client/storage/index.ts @@ -0,0 +1,153 @@ +import { deleteDB, openDB, unwrap } from 'idb'; +import { IndexedDBStorageDriver } from 'neurone/lib/storage/driver'; +import { + AccountDataCollection, + createAccountDataCollection, + prepareAccountDataStore, + createJoinAccountDataCollection, + createJoinRoomMemberCollection, + createJoinRoomStateCollection, + createJoinRoomSummaryCollection, + createUnreadNotificationsCollection, + createUnreadThreadNotificationsCollection, + JoinAccountDataCollection, + JoinRoomMemberCollection, + JoinRoomStateCollection, + JoinRoomSummaryCollection, + UnreadNotificationsCollection, + UnreadThreadNotificationsCollection, + prepareJoinAccountDataStore, + prepareJoinRoomMemberStore, + prepareJoinRoomStateStore, + prepareJoinRoomSummaryStore, + prepareUnreadNotificationsStore, + prepareUnreadThreadNotificationsStore, + ReadReceiptCollection, + prepareReadReceiptStore, + createReadReceiptCollection, + InviteRoomStateCollection, + prepareInviteRoomStateStore, + createInviteRoomStateCollection, + KnockRoomStateCollection, + prepareKnockRoomStateStore, + createKnockRoomStateCollection, + LeaveAccountDataCollection, + LeaveRoomStateCollection, + prepareLeaveAccountDataStore, + prepareLeaveRoomStateStore, + createLeaveAccountDataCollection, + createLeaveRoomStateCollection, + PresenceCollection, + preparePresenceStore, + createPresenceCollection, + SessionValueCollection, + prepareSessionValueStore, + createSessionValueCollection, + JoinRoomLatestCollection, + createJoinRoomLatestEventCollection, + prepareJoinRoomLatestEventStore, +} from './collections'; + +export * from './utils'; + +const DATABASE_VERSION = 1; + +export interface Storage { + sessionValue: SessionValueCollection; + + accountData: AccountDataCollection; + + joinAccountData: JoinAccountDataCollection; + joinRoomSummary: JoinRoomSummaryCollection; + joinRoomState: JoinRoomStateCollection; + joinRoomMember: JoinRoomMemberCollection; + joinRoomLatestEvent: JoinRoomLatestCollection; + + unreadNotifications: UnreadNotificationsCollection; + unreadThreadNotifications: UnreadThreadNotificationsCollection; + + readReceipt: ReadReceiptCollection; + + inviteRoomState: InviteRoomStateCollection; + + knockRoomState: KnockRoomStateCollection; + + leaveAccountData: LeaveAccountDataCollection; + leaveRoomState: LeaveRoomStateCollection; + + presence: PresenceCollection; +} + +export const createStorage = async ( + storageName: string, + userId: string +): Promise<{ + storage: Storage; + deleteStorage: () => Promise; +}> => { + const dbName = `${storageName}${userId}`; + + // TODO: handle all openDB callbacks. + const idb = await openDB(dbName, DATABASE_VERSION, { + upgrade(db, oldVersion) { + prepareSessionValueStore(db, oldVersion); + + prepareAccountDataStore(db, oldVersion); + + prepareJoinAccountDataStore(db, oldVersion); + prepareJoinRoomSummaryStore(db, oldVersion); + prepareJoinRoomStateStore(db, oldVersion); + prepareJoinRoomMemberStore(db, oldVersion); + prepareJoinRoomLatestEventStore(db, oldVersion); + + prepareUnreadNotificationsStore(db, oldVersion); + prepareUnreadThreadNotificationsStore(db, oldVersion); + + prepareReadReceiptStore(db, oldVersion); + + prepareInviteRoomStateStore(db, oldVersion); + + prepareKnockRoomStateStore(db, oldVersion); + + prepareLeaveAccountDataStore(db, oldVersion); + prepareLeaveRoomStateStore(db, oldVersion); + + preparePresenceStore(db, oldVersion); + }, + }); + + const driver = new IndexedDBStorageDriver(unwrap(idb)); + + const storage: Storage = { + sessionValue: createSessionValueCollection(driver), + + accountData: createAccountDataCollection(driver), + + joinAccountData: createJoinAccountDataCollection(driver), + joinRoomSummary: createJoinRoomSummaryCollection(driver), + joinRoomState: createJoinRoomStateCollection(driver), + joinRoomMember: createJoinRoomMemberCollection(driver), + joinRoomLatestEvent: createJoinRoomLatestEventCollection(driver), + + unreadNotifications: createUnreadNotificationsCollection(driver), + unreadThreadNotifications: createUnreadThreadNotificationsCollection(driver), + + readReceipt: createReadReceiptCollection(driver), + + inviteRoomState: createInviteRoomStateCollection(driver), + + knockRoomState: createKnockRoomStateCollection(driver), + + leaveAccountData: createLeaveAccountDataCollection(driver), + leaveRoomState: createLeaveRoomStateCollection(driver), + + presence: createPresenceCollection(driver), + }; + + const deleteStorage = async () => { + idb.close(); + await deleteDB(dbName); + }; + + return { storage, deleteStorage }; +}; diff --git a/src/client/storage/utils.ts b/src/client/storage/utils.ts new file mode 100644 index 00000000..77a96abe --- /dev/null +++ b/src/client/storage/utils.ts @@ -0,0 +1,2 @@ +export const encodeKey = (...args: string[]): string => args.join(''); +export const MAX_UNICODE = '\u{10FFFF}'; diff --git a/src/client/sync.ts b/src/client/sync.ts new file mode 100644 index 00000000..5099b386 --- /dev/null +++ b/src/client/sync.ts @@ -0,0 +1,177 @@ +import { + MatrixError, + MatrixRequest, + MatrixResponseResult, + parseMatrixResp, +} from 'neurone/lib/matrix-request'; +import { getSync, GetSyncOK } from 'neurone/lib/api/matrix/client/v3/sync'; +import { ObservableValue } from 'neurone/lib/utils/observable'; +import { ProgressCallback, responseProgress } from './responseProgress'; +import { getInitialSyncFilter, getSyncFilter } from './filter'; + +type SyncError = Error | MatrixError; + +export type SyncValue = [SyncError, undefined] | [undefined, GetSyncOK] | undefined; + +export type SyncCallback = ( + since: string | undefined, + progressCallback?: ProgressCallback +) => Promise; + +export const createSync = (request: MatrixRequest): [ObservableValue, SyncCallback] => { + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + const initialSyncFilter = JSON.stringify(getInitialSyncFilter()); + const syncFilter = JSON.stringify(getSyncFilter()); + + const publisherKey = Math.random().toString(); + const syncObservable = new ObservableValue(undefined, publisherKey); + + const sync: SyncCallback = async (since, progressCallback) => { + if (syncObservable.read !== undefined) { + syncObservable.publish(publisherKey, undefined); + } + + const responsePromise = request( + getSync({ + searchParams: { + filter: since ? syncFilter : initialSyncFilter, + full_state: false, + timeout: 30000, + since, + }, + }), + abortSignal + ); + + let response: Response; + try { + response = await responsePromise; + } catch (err: any) { + if (!abortSignal.aborted) { + syncObservable.publish(publisherKey, [err, undefined]); + } + return; + } + + if (progressCallback) { + responseProgress(response.clone(), progressCallback); + } + + let result: MatrixResponseResult; + try { + result = await parseMatrixResp(response); + } catch (err: any) { + syncObservable.publish(publisherKey, [err, undefined]); + return; + } + + const [matrixError, data] = result; + + if (matrixError) { + syncObservable.publish(publisherKey, [matrixError, undefined]); + return; + } + + syncObservable.publish(publisherKey, [undefined, data]); + }; + + syncObservable.onDispose(publisherKey, () => { + abortController.abort(); + }); + + return [syncObservable, sync]; +}; + +export type SyncProgress = { + received: number; + total?: number; +}; + +type InitialSyncObservable = ObservableValue< + | { + progress: ObservableValue; + } + | undefined +>; + +export type SyncController = { + initialSync: InitialSyncObservable; + start: () => void; + recover: (afterMs?: number) => void; + dispose: () => void; +}; + +export const createSyncController = ( + syncObservable: ObservableValue, + sync: SyncCallback, + getSyncToken: () => string | undefined +): [SyncController, () => void, (since: string) => void] => { + let started = false; + + const progressPublisherKey = 'PROGRESS_KEY'; + const progress: ObservableValue = new ObservableValue( + undefined, + progressPublisherKey + ); + + const initialSyncPublisherKey = 'SYNC_KEY'; + const initialSync: InitialSyncObservable = new ObservableValue( + getSyncToken() ? undefined : { progress }, + initialSyncPublisherKey + ); + + const handleInitialSyncProgress: ProgressCallback = (received, total) => { + if (progress.isDisposed) return; + progress.publish(progressPublisherKey, { + received, + total, + }); + }; + + const doSync = async () => { + const token = getSyncToken(); + await sync(token, token ? undefined : handleInitialSyncProgress); + }; + + const syncController: SyncController = { + initialSync, + start: () => { + if (started) { + console.warn('Can not start the sync controller twice. Use recover method for recovery.'); + return; + } + started = true; + doSync(); + }, + recover: () => { + const [error] = syncObservable.read ?? []; + if (!error) { + console.warn('Failed to recover. Sync does not have any error.'); + return; + } + doSync(); + }, + dispose: () => { + initialSync.dispose(); + }, + }; + + const onWriteComplete = () => { + if (initialSync.read) { + initialSync.publish(initialSyncPublisherKey, undefined); + } + }; + + const onSyncToken = (token: string) => { + sync(token); + }; + + return [syncController, onWriteComplete, onSyncToken]; +}; + +export type SyncContext = { + observable: ObservableValue; + controller: SyncController; +}; diff --git a/src/client/syncLatestEvent.ts b/src/client/syncLatestEvent.ts new file mode 100644 index 00000000..33bbe712 --- /dev/null +++ b/src/client/syncLatestEvent.ts @@ -0,0 +1,27 @@ +import { getSync, GetSyncOK } from 'neurone/lib/api/matrix/client/v3/sync'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { getLatestEventSyncFilter } from './filter'; +import { writeJoinRoomLatestEvent } from './syncWriter/utils'; +import { ClientContext } from './context'; + +export const syncLatestEvent = async (ct: ClientContext, since?: string) => { + const resp = await ct.requestAuthJSON( + getSync({ + searchParams: { + filter: JSON.stringify(getLatestEventSyncFilter()), + since, + }, + }) + ); + const [error, syncData] = await parseMatrixResp(resp); + if (error) { + console.error(printMatrixError(error)); + return; + } + const { join } = syncData.rooms ?? {}; + if (join) { + const joinRooms = Object.keys(join); + + await writeJoinRoomLatestEvent(ct, joinRooms, join); + } +}; diff --git a/src/client/syncWriter/index.ts b/src/client/syncWriter/index.ts new file mode 100644 index 00000000..fab0462c --- /dev/null +++ b/src/client/syncWriter/index.ts @@ -0,0 +1,113 @@ +import { GetSyncOK } from 'neurone/lib/api/matrix/client/v3/sync'; +import { PresenceEvent, StatusContent } from 'neurone/lib/api/types'; +import { ClientContext } from '../context'; +import { + deleteInviteRooms, + deleteJoinRooms, + deleteKnockRooms, + deleteLeaveRooms, + getStaleJoinRooms, + getStaleInviteRooms, + getStaleKnockRooms, + getStaleLeaveRooms, + writeAccountData, + writeInviteRoomState, + writeJoinAccountData, + writeJoinRoomLatestEvent, + writeJoinRoomMember, + writeJoinRoomState, + writeJoinRoomSummary, + writeKnockRoomState, + writeLeaveAccountData, + writeLeaveRoomState, + writePresence, + writeReadReceipt, + writeUnreadNotifications, + writeUnreadThreadNotifications, +} from './utils'; +import { sessionValue } from '../storage/collections'; + +/** + * Utility function to find common element between two arrays + */ +const common = (arr1: T[], arr2: T[]): T[] => arr1.filter((item) => arr2.includes(item)); + +export const writeSync = async (ct: ClientContext, data: GetSyncOK): Promise => { + const { account_data: accountData, rooms, presence } = data; + + if (accountData?.events && accountData.events.length > 0) { + await writeAccountData(ct, accountData.events); + } + + const { join, invite, knock, leave } = rooms ?? {}; + + if (join) { + const joinRooms = Object.keys(join); + const newRooms = joinRooms.filter((r) => !ct.rooms.join.read.includes(r)); + + if (newRooms.length > 0) { + ct.storage.sessionValue.write.put({ + id: sessionValue.pendingMemberRooms.id, + value: ct.pendingMemberRooms.read.concat(newRooms), + }); + } + + await deleteJoinRooms(ct, getStaleJoinRooms(ct, joinRooms, join)); + await deleteInviteRooms(ct, common(joinRooms, ct.rooms.invite.read)); + await deleteKnockRooms(ct, common(joinRooms, ct.rooms.knock.read)); + await deleteLeaveRooms(ct, common(joinRooms, ct.rooms.leave.read)); + + await writeJoinAccountData(ct, joinRooms, join); + await writeJoinRoomSummary(ct, joinRooms, join); + await writeJoinRoomState(ct, joinRooms, join); + await writeJoinRoomMember(ct, joinRooms, join); + await writeJoinRoomLatestEvent(ct, joinRooms, join); + + await writeReadReceipt(ct, joinRooms, join); + + await writeUnreadNotifications(ct, joinRooms, join); + await writeUnreadThreadNotifications(ct, joinRooms, join); + } + + if (invite) { + const inviteRooms = Object.keys(invite); + + await deleteInviteRooms(ct, getStaleInviteRooms(ct, inviteRooms, invite)); + await deleteJoinRooms(ct, common(inviteRooms, ct.rooms.join.read)); + await deleteKnockRooms(ct, common(inviteRooms, ct.rooms.knock.read)); + await deleteLeaveRooms(ct, common(inviteRooms, ct.rooms.leave.read)); + + await writeInviteRoomState(ct, inviteRooms, invite); + } + + if (knock) { + const knockRooms = Object.keys(knock); + + await deleteKnockRooms(ct, getStaleKnockRooms(ct, knockRooms, knock)); + await deleteJoinRooms(ct, common(knockRooms, ct.rooms.join.read)); + await deleteInviteRooms(ct, common(knockRooms, ct.rooms.invite.read)); + await deleteLeaveRooms(ct, common(knockRooms, ct.rooms.leave.read)); + + await writeKnockRoomState(ct, knockRooms, knock); + } + + if (leave) { + const leaveRooms = Object.keys(leave); + + await deleteLeaveRooms(ct, getStaleLeaveRooms(ct, leaveRooms, leave)); + await deleteJoinRooms(ct, common(leaveRooms, ct.rooms.join.read)); + await deleteInviteRooms(ct, common(leaveRooms, ct.rooms.invite.read)); + await deleteKnockRooms(ct, common(leaveRooms, ct.rooms.knock.read)); + + await writeLeaveAccountData(ct, leaveRooms, leave); + await writeLeaveRoomState(ct, leaveRooms, leave); + } + + if (presence?.events) { + const presenceEvents = presence.events.filter( + (event) => event.type === 'm.presence' + ) as PresenceEvent[]; + + await writePresence(ct, presenceEvents); + } +}; diff --git a/src/client/syncWriter/utils.ts b/src/client/syncWriter/utils.ts new file mode 100644 index 00000000..ee0b0102 --- /dev/null +++ b/src/client/syncWriter/utils.ts @@ -0,0 +1,660 @@ +import { InviteRoom, JoinRoom, KnockRoom, LeaveRoom } from 'neurone/lib/api/matrix/client/v3/sync'; +import { + AccountDataEvent, + PresenceEvent, + Receipt, + ReceiptType, + StatusContent, +} from 'neurone/lib/api/types'; +import * as joinRoomUtil from 'neurone/lib/utils/sync/v3/join'; +import * as inviteRoomUtil from 'neurone/lib/utils/sync/v3/invite'; +import * as knockRoomUtil from 'neurone/lib/utils/sync/v3/knock'; +import * as leaveRoomUtil from 'neurone/lib/utils/sync/v3/leave'; +import { ObjectReader } from 'neurone/lib/utils'; +import { ClientContext } from '../context'; +import { + AccountDataObject, + equalRoomSummary, + getInviteRoomStateId, + getJoinAccountDataId, + getJoinRoomMemberId, + getJoinRoomStateId, + getKnockRoomStateId, + getLeaveAccountDataId, + getLeaveRoomStateId, + getReadReceiptId, + getUnreadThreadNotificationId, + InviteRoomStateObject, + JoinAccountDataObject, + JoinRoomLatestEventObject, + JoinRoomMemberObject, + JoinRoomStateObject, + JoinRoomSummaryObject, + KnockRoomStateObject, + LeaveAccountDataObject, + LeaveRoomStateObject, + PresenceObject, + ReadReceiptObject, + UnreadNotificationsObject, + UnreadThreadNotificationsObject, +} from '../storage/collections'; +import { ThreadId } from '../../types/matrix/room'; + +export const writeAccountData = async ( + ct: ClientContext, + events: AccountDataEvent[] +): Promise => { + if (events.length > 0) { + const accountDataObjects: AccountDataObject[] = events.map((event) => { + const obj: AccountDataObject = { + id: event.type, + event, + }; + return obj; + }); + await ct.storage.accountData.write.putMultiple(accountDataObjects); + } +}; + +export const writeJoinAccountData = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const accountDataObjects: JoinAccountDataObject[] = joinRooms.flatMap((roomId) => { + const joinRoom = join[roomId]; + const events = joinRoomUtil.roomAccountData(joinRoom); + + return events.map((event) => { + const obj: JoinAccountDataObject = { + id: getJoinAccountDataId(roomId, event.type), + roomId, + type: event.type, + event, + }; + + return obj; + }); + }); + + await ct.storage.joinAccountData.write.putMultiple(accountDataObjects); +}; + +export const writeJoinRoomSummary = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const roomSummaryObjects: JoinRoomSummaryObject[] = joinRooms.flatMap((roomId) => { + const room = join[roomId]; + const summary = joinRoomUtil.roomSummary(room); + if (!summary) return []; + + const existingSummary = ct.roomToSummary.read.get(roomId); + const summaryObj: JoinRoomSummaryObject = { + summary: { + ...existingSummary, + ...summary, + }, + id: roomId, + }; + if (existingSummary && equalRoomSummary(existingSummary, summaryObj.summary)) { + return []; + } + + return [summaryObj]; + }); + + await ct.storage.joinRoomSummary.write.putMultiple(roomSummaryObjects); +}; + +export const writeJoinRoomState = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const roomStateObjects: JoinRoomStateObject[] = joinRooms.flatMap((roomId) => { + const joinRoom = join[roomId]; + const events = joinRoomUtil + .roomState(joinRoom) + .filter((event) => event.type !== 'm.room.member'); + + return events.map((event) => { + const obj: JoinRoomStateObject = { + id: getJoinRoomStateId(roomId, event.type, event.state_key), + roomId, + type: event.type, + event, + }; + + return obj; + }); + }); + + await ct.storage.joinRoomState.write.putMultiple(roomStateObjects); +}; + +export const writeJoinRoomMember = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const roomMemberObjects: JoinRoomMemberObject[] = joinRooms.flatMap((roomId) => { + const joinRoom = join[roomId]; + const events = joinRoomUtil + .roomState(joinRoom) + .filter((event) => event.type === 'm.room.member'); + + return events.map((event) => { + const obj: JoinRoomMemberObject = { + id: getJoinRoomMemberId(roomId, event.state_key), + roomId, + userId: event.state_key, + event, + }; + + return obj; + }); + }); + + await ct.storage.joinRoomMember.write.putMultiple(roomMemberObjects); +}; + +export const writeJoinRoomLatestEvent = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const roomLatestEventObjects: JoinRoomLatestEventObject[] = []; + + joinRooms.forEach((roomId) => { + const joinRoom = join[roomId]; + const { events } = joinRoom.timeline ?? {}; + if (!events) return; + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (!('state_key' in event)) { + roomLatestEventObjects.push({ + id: roomId, + event, + }); + break; + } + } + }); + + await ct.storage.joinRoomLatestEvent.write.putMultiple(roomLatestEventObjects); +}; + +export const writeReadReceipt = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const readReceiptObjects: ReadReceiptObject[] = joinRooms.flatMap((roomId) => { + const joinRoom = join[roomId]; + const event = joinRoomUtil.roomReceiptEphemeral(joinRoomUtil.roomEphemeral(joinRoom)); + + if (!event) return []; + + const objects: ReadReceiptObject[] = []; + + const collectReceipt = ( + eventId: string, + userId: string, + receiptType: ReceiptType, + receipt: Receipt + ) => { + const threadId = receipt.thread_id ?? ThreadId.Main; + const obj: ReadReceiptObject = { + id: getReadReceiptId(roomId, userId, threadId), + roomId, + userId, + type: receiptType, + eventId, + ts: receipt.ts ?? 0, + threadId, + }; + objects.push(obj); + }; + + const threadIdToMyPrivateReceipt: Record< + string, + { + eventId: string; + receipt: Receipt; + } + > = {}; + + const eventIds = Object.keys(event.content); + eventIds.forEach((eventId) => { + const receiptContent = event.content[eventId]; + const userToPrivate = receiptContent['m.read.private']; + const receipt = userToPrivate?.[ct.session.userId]; + if (!receipt) return; + const threadId = receipt.thread_id ?? ThreadId.Main; + const existing = threadIdToMyPrivateReceipt[threadId]; + if (!existing || (receipt.ts ?? 0) > (existing.receipt.ts ?? 0)) { + threadIdToMyPrivateReceipt[threadId] = { eventId, receipt }; + } + }); + eventIds.forEach((eventId) => { + const receiptContent = event.content[eventId]; + const userToPublic = receiptContent['m.read']; + + if (userToPublic) { + Object.keys(userToPublic).forEach((userId) => { + const receipt = userToPublic[userId]; + if (userId === ct.session.userId) { + // use latest receipt between private/public + const threadId = receipt.thread_id ?? ThreadId.Main; + const privateReceipt = + threadId in threadIdToMyPrivateReceipt + ? threadIdToMyPrivateReceipt[threadId] + : undefined; + if (privateReceipt && (privateReceipt.receipt.ts ?? 0) >= (receipt.ts ?? 0)) { + collectReceipt( + privateReceipt.eventId, + userId, + 'm.read.private', + privateReceipt.receipt + ); + return; + } + } + collectReceipt(eventId, userId, 'm.read', receipt); + }); + } + }); + + return objects; + }); + + await ct.storage.readReceipt.write.putMultiple(readReceiptObjects); +}; + +export const writeUnreadNotifications = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const existing = await ct.storage.unreadNotifications.read.getMultiple(joinRooms); + + const unreadNotificationsObjects: UnreadNotificationsObject[] = joinRooms.flatMap( + (roomId, index) => { + const joinRoom = join[roomId]; + + const existingUnread = existing[index]?.unread; + const unread = joinRoomUtil.roomUnreadNotifications(joinRoom); + + if ( + !unread || + (existingUnread?.highlight_count === unread.highlight_count && + existingUnread?.notification_count === unread.notification_count) + ) { + return []; + } + const obj: UnreadNotificationsObject = { + id: roomId, + unread, + }; + return obj; + } + ); + + await ct.storage.unreadNotifications.write.putMultiple(unreadNotificationsObjects); +}; + +export const writeUnreadThreadNotifications = async ( + ct: ClientContext, + joinRooms: string[], + join: Record +): Promise => { + const unreadThreadNotificationsObjects: UnreadThreadNotificationsObject[] = joinRooms.flatMap( + (roomId) => { + const joinRoom = join[roomId]; + + const unreadThreadNotifications = joinRoomUtil.roomUnreadThreadNotifications(joinRoom); + + if (!unreadThreadNotifications) { + return []; + } + + return Object.keys(unreadThreadNotifications).map((threadId) => { + const unread = unreadThreadNotifications[threadId]; + + const obj: UnreadThreadNotificationsObject = { + id: getUnreadThreadNotificationId(roomId, threadId), + roomId, + threadId, + unread, + }; + + return obj; + }); + } + ); + + await ct.storage.unreadThreadNotifications.write.putMultiple(unreadThreadNotificationsObjects); +}; + +export const writeInviteRoomState = async ( + ct: ClientContext, + inviteRooms: string[], + invite: Record +): Promise => { + const inviteStateObjects: InviteRoomStateObject[] = inviteRooms.flatMap((roomId) => { + const inviteRoom = invite[roomId]; + + const events = inviteRoomUtil.roomInviteState(inviteRoom); + + return events.map((event) => { + const obj: InviteRoomStateObject = { + id: getInviteRoomStateId(roomId, event.type, event.state_key), + roomId, + type: event.type, + event, + }; + return obj; + }); + }); + + await ct.storage.inviteRoomState.write.putMultiple(inviteStateObjects); +}; + +export const writeKnockRoomState = async ( + ct: ClientContext, + knockRooms: string[], + knock: Record +): Promise => { + const knockStateObjects: KnockRoomStateObject[] = knockRooms.flatMap((roomId) => { + const knockRoom = knock[roomId]; + + const events = knockRoomUtil.roomKnockState(knockRoom); + + return events.map((event) => { + const obj: KnockRoomStateObject = { + id: getKnockRoomStateId(roomId, event.type, event.state_key), + roomId, + type: event.type, + event, + }; + + return obj; + }); + }); + + await ct.storage.knockRoomState.write.putMultiple(knockStateObjects); +}; + +export const writeLeaveAccountData = async ( + ct: ClientContext, + leaveRooms: string[], + leave: Record +): Promise => { + const leaveAccountDataObjects: LeaveAccountDataObject[] = leaveRooms.flatMap((roomId) => { + const leaveRoom = leave[roomId]; + + const events = leaveRoomUtil.roomAccountData(leaveRoom); + + return events.map((event) => { + const obj: LeaveAccountDataObject = { + id: getLeaveAccountDataId(roomId, event.type), + roomId, + type: event.type, + event, + }; + + return obj; + }); + }); + + await ct.storage.leaveAccountData.write.putMultiple(leaveAccountDataObjects); +}; + +export const writeLeaveRoomState = async ( + ct: ClientContext, + leaveRooms: string[], + leave: Record +): Promise => { + const leaveStateObjects: LeaveRoomStateObject[] = leaveRooms.flatMap((roomId) => { + const leaveRoom = leave[roomId]; + + const events = leaveRoomUtil.roomState(leaveRoom); + + return events.map((event) => { + const obj: LeaveRoomStateObject = { + id: getLeaveRoomStateId(roomId, event.type, event.state_key), + roomId, + type: event.type, + event, + }; + + return obj; + }); + }); + + await ct.storage.leaveRoomState.write.putMultiple(leaveStateObjects); +}; + +export const writePresence = async ( + ct: ClientContext, + events: PresenceEvent[] +): Promise => { + if (events.length > 0) { + const presenceObjects: PresenceObject[] = events.map((event) => { + const obj: PresenceObject = { + id: event.sender, + presence: event.content.presence, + content: event.content, + }; + + return obj; + }); + + await ct.storage.presence.write.putMultiple(presenceObjects); + } +}; + +export const deleteKnockRooms = async (ct: ClientContext, rooms: string[]): Promise => { + if (rooms.length === 0) return; + + const promises = rooms.map((roomId) => + ct.storage.knockRoomState.read.queryIndexKeys('roomId', roomId) + ); + + const result = await Promise.allSettled(promises); + const keys = result.filter((r) => r.status === 'fulfilled').flatMap((r) => r.value); + + await ct.storage.knockRoomState.write.deleteMultiple(keys); +}; + +export const deleteInviteRooms = async (ct: ClientContext, rooms: string[]): Promise => { + if (rooms.length === 0) return; + + const promises = rooms.map((roomId) => + ct.storage.inviteRoomState.read.queryIndexKeys('roomId', roomId) + ); + + const result = await Promise.allSettled(promises); + const keys = result.filter((r) => r.status === 'fulfilled').flatMap((r) => r.value); + + await ct.storage.inviteRoomState.write.deleteMultiple(keys); +}; + +export const deleteLeaveRooms = async (ct: ClientContext, rooms: string[]): Promise => { + if (rooms.length === 0) return; + + const accountDataObjectsId = ( + await Promise.allSettled( + rooms.map((roomId) => ct.storage.leaveAccountData.read.queryIndexKeys('roomId', roomId)) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.leaveAccountData.write.deleteMultiple(accountDataObjectsId); + + const roomStateObjectsId = ( + await Promise.allSettled( + rooms.map((roomId) => ct.storage.leaveRoomState.read.queryIndexKeys('roomId', roomId)) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.leaveRoomState.write.deleteMultiple(roomStateObjectsId); +}; + +export const deleteJoinRooms = async (ct: ClientContext, rooms: string[]): Promise => { + if (rooms.length === 0) return; + + const accountDataObjectsId = ( + await Promise.allSettled( + rooms.map((roomId) => ct.storage.joinAccountData.read.queryIndexKeys('roomId', roomId)) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.joinAccountData.write.deleteMultiple(accountDataObjectsId); + + await ct.storage.joinRoomSummary.write.deleteMultiple(rooms); + + const roomStateObjectsId = ( + await Promise.allSettled( + rooms.map((roomId) => ct.storage.joinRoomState.read.queryIndexKeys('roomId', roomId)) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.joinRoomState.write.deleteMultiple(roomStateObjectsId); + + const roomMemberObjectsId = ( + await Promise.allSettled( + rooms.map((roomId) => ct.storage.joinRoomMember.read.queryIndexKeys('roomId', roomId)) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.joinRoomMember.write.deleteMultiple(roomMemberObjectsId); + + await ct.storage.joinRoomLatestEvent.write.deleteMultiple(rooms); + + const readReceiptObjectsId = ( + await Promise.allSettled( + rooms.map((roomId) => ct.storage.readReceipt.read.queryIndexKeys('roomId', roomId)) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.readReceipt.write.deleteMultiple(readReceiptObjectsId); + + await ct.storage.unreadNotifications.write.deleteMultiple(rooms); + + const unreadThreadNotificationsObjectIds = ( + await Promise.allSettled( + rooms.map((roomId) => + ct.storage.unreadThreadNotifications.read.queryIndexKeys('roomId', roomId) + ) + ) + ) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + await ct.storage.unreadThreadNotifications.write.deleteMultiple( + unreadThreadNotificationsObjectIds + ); +}; + +export const getStaleJoinRooms = ( + ct: ClientContext, + joinRooms: string[], + join: Record +): string[] => { + const notJoinedRooms = joinRooms.filter((roomId) => { + const joinRoom = join[roomId]; + + const myMemberEvent = joinRoomUtil + .roomState(joinRoom) + .find((event) => event.type === 'm.room.member' && event.state_key === ct.session.userId); + + if (!myMemberEvent) return false; + + const membership = ObjectReader.string(myMemberEvent.content, 'membership'); + + return membership !== 'join'; + }); + + return notJoinedRooms; +}; + +export const getStaleInviteRooms = ( + ct: ClientContext, + inviteRooms: string[], + invite: Record +): string[] => { + const notInviteRooms = inviteRooms.filter((roomId) => { + const joinRoom = invite[roomId]; + + const myMemberEvent = inviteRoomUtil + .roomInviteState(joinRoom) + .find((event) => event.type === 'm.room.member' && event.state_key === ct.session.userId); + + if (!myMemberEvent) return false; + + const membership = ObjectReader.string(myMemberEvent.content, 'membership'); + + return membership !== 'invite'; + }); + + return notInviteRooms; +}; + +export const getStaleKnockRooms = ( + ct: ClientContext, + knockRooms: string[], + knock: Record +): string[] => { + const notKnockRooms = knockRooms.filter((roomId) => { + const knockRoom = knock[roomId]; + + const myMemberEvent = knockRoomUtil + .roomKnockState(knockRoom) + .find((event) => event.type === 'm.room.member' && event.state_key === ct.session.userId); + + if (!myMemberEvent) return false; + + const membership = ObjectReader.string(myMemberEvent.content, 'membership'); + + return membership !== 'knock'; + }); + + return notKnockRooms; +}; + +export const getStaleLeaveRooms = ( + ct: ClientContext, + leaveRooms: string[], + leave: Record +): string[] => { + const notLeavedRooms = leaveRooms.filter((roomId) => { + const leaveRoom = leave[roomId]; + + const myMemberEvent = leaveRoomUtil + .roomState(leaveRoom) + .find((event) => event.type === 'm.room.member' && event.state_key === ct.session.userId); + + if (!myMemberEvent) return false; + + const membership = ObjectReader.string(myMemberEvent.content, 'membership'); + + return membership !== 'leave'; + }); + + return notLeavedRooms; +}; diff --git a/src/client/txn.ts b/src/client/txn.ts new file mode 100644 index 00000000..39229963 --- /dev/null +++ b/src/client/txn.ts @@ -0,0 +1,12 @@ +export type GetTxnId = () => string; + +export const getTxnIdFunc = (): GetTxnId => { + let txnCount = 0; + + const getTxnId: GetTxnId = () => { + const id = `m${new Date().getTime()}.${(txnCount += 1)}`; + return id; + }; + + return getTxnId; +}; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 98715996..5a0897b2 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -17,6 +17,25 @@ export enum AccountDataEvent { CrossSigningUser = 'm.cross_signing.user', MegolmBackupV1 = 'm.megolm_backup.v1', } +export enum AccountDataType { + PushRules = 'm.push_rules', + Direct = 'm.direct', + IgnoredUserList = 'm.ignored_user_list', + + CinnySpaces = 'in.cinny.spaces', + + ElementRecentEmoji = 'io.element.recent_emoji', + + PoniesUserEmotes = 'im.ponies.user_emotes', + PoniesEmoteRooms = 'im.ponies.emote_rooms', + + SecretStorageDefaultKey = 'm.secret_storage.default_key', + + CrossSigningMaster = 'm.cross_signing.master', + CrossSigningSelf = 'm.cross_signing.self', + CrossSigningUser = 'm.cross_signing.user', + MegolmBackupV1 = 'm.megolm_backup.v1', +} export type MDirectContent = Record; @@ -48,3 +67,39 @@ export type SecretContent = { export type SecretAccountData = { encrypted: Record; }; + +export enum PushRuleId { + Master = '.m.rule.master', + IsUserMention = '.m.rule.is_user_mention', + IsRoomMention = '.m.rule.is_room_mention', + ContainsDisplayName = '.m.rule.contains_display_name', + ContainsUserName = '.m.rule.contains_user_name', + AtRoomNotification = '.m.rule.roomnotif', + DM = '.m.rule.room_one_to_one', + EncryptedDM = '.m.rule.encrypted_room_one_to_one', + Message = '.m.rule.message', + EncryptedMessage = '.m.rule.encrypted', + InviteToSelf = '.m.rule.invite_for_me', + MemberEvent = '.m.rule.member_event', + IncomingCall = '.m.rule.call', + SuppressNotices = '.m.rule.suppress_notices', + Tombstone = '.m.rule.tombstone', + PollStart = '.m.rule.poll_start', + PollStartUnstable = '.org.matrix.msc3930.rule.poll_start', + PollEnd = '.m.rule.poll_end', + PollEndUnstable = '.org.matrix.msc3930.rule.poll_end', + PollStartOneToOne = '.m.rule.poll_start_one_to_one', + PollStartOneToOneUnstable = '.org.matrix.msc3930.rule.poll_start_one_to_one', + PollEndOneToOne = '.m.rule.poll_end_one_to_one', + PollEndOneToOneUnstable = '.org.matrix.msc3930.rule.poll_end_one_to_one', +} +export enum ConditionKind { + EventMatch = 'event_match', + EventPropertyIs = 'event_property_is', + EventPropertyContains = 'event_property_contains', + ContainsDisplayName = 'contains_display_name', + RoomMemberCount = 'room_member_count', + SenderNotificationPermission = 'sender_notification_permission', + CallStarted = 'call_started', + CallStartedPrefix = 'org.matrix.msc3914.call_started', +} diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts index 210c711f..4065c49e 100644 --- a/src/types/matrix/common.ts +++ b/src/types/matrix/common.ts @@ -1,9 +1,55 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import { MsgType } from 'matrix-js-sdk'; export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler'; -export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason'; +export const MATRIX_SPOILER_REASON_PROPERTY_NAME = + 'page.codeberg.everypizza.msc4193.spoiler.reason'; + +export enum MsgType { + Text = 'm.text', + Emote = 'm.emote', + Notice = 'm.notice', + Image = 'm.image', + File = 'm.file', + Audio = 'm.audio', + Location = 'm.location', + Video = 'm.video', + KeyVerificationRequest = 'm.key.verification.request', +} + +export type MentionsContent = { + user_ids?: string[]; + room?: boolean; +}; + +export enum RelationType { + Annotation = 'm.annotation', + Replace = 'm.replace', + Reference = 'm.reference', + Thread = 'm.thread', +} + +export type Relation = { + 'm.relates_to': { + event_id: string; + rel_type: T; + }; +}; + +export type RelationContent = { + rel_type?: RelationType | string; + event_id?: string; + is_falling_back?: boolean; + 'm.in_reply_to'?: { + event_id?: string; + }; + key?: string; +}; + +export type BaseContent = { + 'm.mentions'?: MentionsContent; + 'm.relates_to'?: RelationContent; +}; export type IImageInfo = { w?: number; @@ -51,7 +97,7 @@ export type IImageContent = { file?: IEncryptedFile; [MATRIX_SPOILER_PROPERTY_NAME]?: boolean; [MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string; -}; +} & BaseContent; export type IVideoContent = { msgtype: MsgType.Video; @@ -62,7 +108,7 @@ export type IVideoContent = { file?: IEncryptedFile; [MATRIX_SPOILER_PROPERTY_NAME]?: boolean; [MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string; -}; +} & BaseContent; export type IAudioContent = { msgtype: MsgType.Audio; @@ -71,7 +117,7 @@ export type IAudioContent = { url?: string; info?: IAudioInfo; file?: IEncryptedFile; -}; +} & BaseContent; export type IFileContent = { msgtype: MsgType.File; @@ -80,11 +126,33 @@ export type IFileContent = { url?: string; info?: IFileInfo & IThumbnailContent; file?: IEncryptedFile; -}; +} & BaseContent; export type ILocationContent = { msgtype: MsgType.Location; body?: string; geo_uri?: string; info?: IThumbnailContent; +} & BaseContent; + +export type MessageContent = { + msgtype: MsgType; + body: string; + format?: 'org.matrix.custom.html'; + formatted_body?: string; +} & BaseContent; + +export type StickerContent = { + body: string; + info: IImageInfo; + url: string; +} & BaseContent; + +export type ReactionContent = { + 'm.relates_to': { + event_id: string; + key: string; + rel_type: RelationType.Annotation; + }; + shortcode?: string; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index b866fd77..bc4818f0 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -1,3 +1,4 @@ +import { RoomMembership } from 'neurone/lib/api/types'; import { IImageInfo } from './common'; export enum Membership { @@ -40,6 +41,30 @@ export enum StateEvent { PowerLevelTags = 'in.cinny.room.power_level_tags', } +export enum StateEventType { + RoomCanonicalAlias = 'm.room.canonical_alias', + RoomCreate = 'm.room.create', + RoomJoinRules = 'm.room.join_rules', + RoomMember = 'm.room.member', + RoomThirdPartyInvite = 'm.room.third_party_invite', + RoomPowerLevels = 'm.room.power_levels', + RoomName = 'm.room.name', + RoomTopic = 'm.room.topic', + RoomAvatar = 'm.room.avatar', + RoomPinnedEvents = 'm.room.pinned_events', + RoomEncryption = 'm.room.encryption', + RoomHistoryVisibility = 'm.room.history_visibility', + RoomGuestAccess = 'm.room.guest_access', + RoomServerAcl = 'm.room.server_acl', + RoomTombstone = 'm.room.tombstone', + + SpaceChild = 'm.space.child', + SpaceParent = 'm.space.parent', + + PoniesRoomEmotes = 'im.ponies.room_emotes', + PowerLevelTags = 'in.cinny.room.power_level_tags', +} + export enum MessageEvent { RoomMessage = 'm.room.message', RoomMessageEncrypted = 'm.room.encrypted', @@ -52,10 +77,31 @@ export enum RoomType { Space = 'm.space', } -export type MSpaceChildContent = { - via: string[]; - suggested?: boolean; +export enum ThreadId { + Main = 'main', +} + +export type JoinRule = + | 'public' + | 'knock' + | 'invite' + | 'private' + | 'restricted' + | 'knock_restricted'; + +export type AllowCondition = { + room_id: string; + type: string[]; +}; +export type JoinRulesContent = { + allow?: AllowCondition[]; + join_rule: JoinRule; +}; + +export type SpaceChildContent = { order?: string; + suggested?: boolean; + via?: string[]; }; export enum NotificationType { @@ -106,3 +152,26 @@ export type MemberPowerTag = { color?: string; icon?: MemberPowerTagIcon; }; + +export type RoomMemberContent = { + avatar_url?: string; + displayname?: string; + is_direct?: boolean; + join_authorised_via_users_server?: string; + membership: RoomMembership; + reason?: string; + third_party_invite?: { + display_name: string; + signed: { + mxid: string; + token: string; + ts: number; + }; + }; +}; + +export type RoomServerAclContent = { + allow?: string[]; + allow_ip_literals?: boolean; + deny?: string[]; +}; From 5f69cc671e2b676a47b8f3a00e11e1715fa2d471 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:21:43 +0530 Subject: [PATCH 002/158] fix merge conflict --- src/app/components/emoji-board/components/Item.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index 94dc40bb..021ce1b2 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -65,11 +65,7 @@ export function CustomEmojiItem({ url, image }: CustomEmojiItemProps) { loading="lazy" className={css.CustomEmojiImg} alt={image.body || image.shortcode} -<<<<<<< HEAD src={url ?? image.url} -======= - src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} ->>>>>>> dev /> ); @@ -98,11 +94,7 @@ export function StickerItem({ url, image }: StickerItemProps) { loading="lazy" className={css.StickerImg} alt={image.body || image.shortcode} -<<<<<<< HEAD src={url ?? image.url} -======= - src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} ->>>>>>> dev /> ); From 57ca5e461587c2ece40c397804fa647dfd8aa973 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:30:29 +0530 Subject: [PATCH 003/158] replace sdk from space lobby --- src/app/components/RoomSummaryLoader.tsx | 19 +- .../autocomplete/RoomMentionAutocomplete.tsx | 13 +- src/app/components/room-card/RoomCard.tsx | 2 +- src/app/features/lobby/HierarchyItemMenu.tsx | 45 +- src/app/features/lobby/Lobby.tsx | 471 ++++++++++-------- src/app/features/lobby/LobbyHeader.tsx | 43 +- src/app/features/lobby/LobbyHero.tsx | 34 +- src/app/features/lobby/RoomItem.tsx | 116 +++-- src/app/features/lobby/SpaceHierarchy.tsx | 53 +- src/app/features/lobby/SpaceItem.tsx | 127 +++-- src/app/features/room-nav/RoomNavItem.tsx | 1 + src/app/features/room/RoomViewHeader.tsx | 7 +- src/app/hooks/useLocalRoomSummary.ts | 6 +- src/app/hooks/usePowerLevels.ts | 66 +-- src/app/hooks/useSpaceHierarchy.ts | 18 +- src/app/pages/Router.tsx | 4 +- src/client/context.ts | 63 ++- src/client/initialize.ts | 19 +- src/client/reader/Error.ts | 6 +- src/types/matrix/room.ts | 4 +- 20 files changed, 649 insertions(+), 468 deletions(-) diff --git a/src/app/components/RoomSummaryLoader.tsx b/src/app/components/RoomSummaryLoader.tsx index 88b10446..4d025869 100644 --- a/src/app/components/RoomSummaryLoader.tsx +++ b/src/app/components/RoomSummaryLoader.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useState } from 'react'; +import { ReactElement, useCallback, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { getRoomSummary, GetRoomSummaryOK } from 'neurone/lib/api/matrix/client/v1/roomSummary'; @@ -7,10 +7,11 @@ import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types'; import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback'; import { useClient } from '../hooks/useClient'; import { useSpecVersionSupport } from '../hooks/useSpecVersions'; +import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary'; type RoomSummaryLoaderProps = { roomIdOrAlias: string; - children: (roomSummary?: GetRoomSummaryOK) => ReactNode; + children: (roomSummary?: GetRoomSummaryOK) => ReactElement; }; export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) { @@ -40,15 +41,23 @@ export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoader return children(data); } -export function LocalRoomSummaryLoader({ children }: { children: () => ReactNode }) { - return children(); +export function LocalRoomSummaryLoader({ + roomId, + children, +}: { + roomId: string; + children: (summary: LocalRoomSummary) => ReactElement; +}) { + const summary = useLocalRoomSummary(roomId); + + return children(summary); } export function HierarchyRoomSummaryLoader({ roomId, children, }: { roomId: string; - children: (state: AsyncState) => ReactNode; + children: (state: AsyncState) => ReactElement; }) { const ct = useClient(); diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index dffb4515..15a9fe1e 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -186,11 +186,20 @@ export function RoomMentionAutocomplete({ src={avatar} alt={name} renderFallback={() => ( - + )} /> ) : ( - + )} } diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 9fb283c2..0ce5f337 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -219,7 +219,7 @@ export const RoomCard = as<'div', RoomCardProps>( )} /> - {(roomType === RoomType.Space || localSummary.space) && ( + {(roomType === RoomType.Space || localSummary.roomType === RoomType.Space) && ( Space diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 58ed2109..91d9af90 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -15,8 +15,8 @@ import { Spinner, toRem, } from 'folds'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SpaceChildContent, StateEvent } from '../../../types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -27,9 +27,9 @@ import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { IPowerLevels } from '../../hooks/usePowerLevels'; -import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useClient } from '../../hooks/useClient'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -42,14 +42,19 @@ function SuggestMenuItem({ item: HierarchyItemWithParent; requestClose: () => void; }) { - const mx = useMatrixClient(); + const ct = useClient(); const { roomId, parentId, content } = item; const [toggleState, handleToggleSuggested] = useAsyncCallback( useCallback(() => { const newContent: SpaceChildContent = { ...content, suggested: !content.suggested }; - return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId); - }, [mx, parentId, roomId, content]) + return ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId: parentId, eventType: StateEvent.SpaceChild, stateKey: roomId }, + body: newContent, + }) + ); + }, [ct, parentId, roomId, content]) ); useEffect(() => { @@ -80,13 +85,19 @@ function RemoveMenuItem({ item: HierarchyItemWithParent; requestClose: () => void; }) { - const mx = useMatrixClient(); + const ct = useClient(); const { roomId, parentId } = item; const [removeState, handleRemove] = useAsyncCallback( useCallback( - () => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId), - [mx, parentId, roomId] + () => + ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId: parentId, eventType: StateEvent.SpaceChild, stateKey: roomId }, + body: {}, + }) + ), + [ct, parentId, roomId] ) ); @@ -126,8 +137,6 @@ function InviteMenuItem({ requestClose: () => void; disabled?: boolean; }) { - const mx = useMatrixClient(); - const room = mx.getRoom(item.roomId); const [invitePrompt, setInvitePrompt] = useState(false); const handleInvite = () => { @@ -143,15 +152,15 @@ function InviteMenuItem({ variant="Primary" fill="None" aria-pressed={invitePrompt} - disabled={disabled || !room} + disabled={disabled} > Invite - {invitePrompt && room && ( + {invitePrompt && ( { setInvitePrompt(false); requestClose(); @@ -173,13 +182,13 @@ function SettingsMenuItem({ }) { const openRoomSettings = useOpenRoomSettings(); const openSpaceSettings = useOpenSpaceSettings(); - const space = useSpaceOptionally(); + const spaceId = useSpaceOptionally(); const handleSettings = () => { if ('space' in item) { openSpaceSettings(item.roomId, item.parentId); } else { - openRoomSettings(item.roomId, space?.roomId); + openRoomSettings(item.roomId, spaceId); } requestClose(); }; @@ -211,15 +220,15 @@ export function HierarchyItemMenu({ pinned, onTogglePin, }: HierarchyItemMenuProps) { - const mx = useMatrixClient(); + const ct = useClient(); const [menuAnchor, setMenuAnchor] = useState(); const canInvite = (): boolean => { if (!powerLevels) return false; - const creators = getRoomCreatorsForRoomId(mx, item.roomId); + const creators = ct.roomToCreators.read.get(item.roomId); const permissions = getRoomPermissionsAPI(creators, powerLevels); - return permissions.action('invite', mx.getSafeUserId()); + return permissions.action('invite', ct.session.userId); }; const handleOpenMenu: MouseEventHandler = (evt) => { diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index c182223d..6d9ae85c 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -1,9 +1,14 @@ import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react'; import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useNavigate } from 'react-router-dom'; import produce from 'immer'; +import { AccountDataEvent, SpaceHierarchyRoomChunk } from 'neurone/lib/api/types'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { ObjectReader } from 'neurone/lib/utils'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; import { useSpace } from '../../hooks/useSpace'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page'; import { @@ -26,39 +31,46 @@ import { PowerLevelsContextProvider, usePowerLevels, useRoomsPowerLevels, + usePowerLevelsState, } from '../../hooks/usePowerLevels'; -import { mDirectAtom } from '../../state/mDirectList'; import { makeLobbyCategoryId } from '../../state/closedLobbyCategories'; import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { allRoomsAtom } from '../../state/room-list/roomList'; -import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix'; +import { rateLimitedActions } from '../../utils/matrix'; import { getSpaceRoomPath } from '../../pages/pathUtils'; -import { StateEvent } from '../../../types/matrix/room'; +import { AllowCondition, StateEvent } from '../../../types/matrix/room'; import { 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, + InCinnySpacesContent, } from '../../hooks/useSidebarItems'; import { useOrphanSpaces } from '../../state/hooks/roomList'; import { roomToParentsAtom } from '../../state/room/roomToParents'; -import { AccountDataEvent } from '../../../types/matrix/accountData'; -import { useRoomMembers } from '../../hooks/useRoomMembers'; import { SpaceHierarchy } from './SpaceHierarchy'; -import { useGetRoom } from '../../hooks/useGetRoom'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; -import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; +import { useClient } from '../../hooks/useClient'; +import { RoomProvider } from '../../hooks/useRoom'; +import { RoomMembersAPIProvider, useCreateRoomMembersAPI } from '../../hooks/useRoomMembers'; +import { usePendingMemberLoader, usePendingMembers } from '../../hooks/usePendingMembers'; +import { useAccountData } from '../../hooks/storage/useAccountData'; +import { AccountDataType } from '../../../types/matrix/accountData'; +import { useAllJoinedRoomsSet } from '../../hooks/useGetRoom'; +import { + PowerLevelTagsContextProvider, + usePowerLevelTags, + usePowerLevelTagsState, +} from '../../hooks/usePowerLevelTags'; const useCanDropLobbyItem = ( - space: Room, - roomsPowerLevels: Map, - getRoom: (roomId: string) => Room | undefined + spaceId: string, + roomsPowerLevels: Map ): CanDropCallback => { - const mx = useMatrixClient(); + const ct = useClient(); const canDropSpace: CanDropCallback = useCallback( (item, container) => { @@ -68,22 +80,20 @@ const useCanDropLobbyItem = ( return false; } - const containerSpaceId = space.roomId; - - const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {}; - const creators = getRoomCreatorsForRoomId(mx, containerSpaceId); + const powerLevels = roomsPowerLevels.get(spaceId) ?? {}; + const creators = ct.roomToCreators.read.get(spaceId); const permissions = getRoomPermissionsAPI(creators, powerLevels); if ( - getRoom(containerSpaceId) === undefined || - !permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) + !ct.rooms.join.read.includes(spaceId) || + !permissions.stateEvent(StateEvent.SpaceChild, ct.session.userId) ) { return false; } return true; }, - [space, roomsPowerLevels, getRoom, mx] + [spaceId, roomsPowerLevels, ct] ); const canDropRoom: CanDropCallback = useCallback( @@ -92,18 +102,18 @@ const useCanDropLobbyItem = ( 'space' in container.item ? container.item.roomId : container.item.parentId; const draggingOutsideSpace = item.parentId !== containerSpaceId; - const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted; + const restrictedItem = ct.roomToJoinRule.read.get(item.roomId) === 'restricted'; // check and do not allow restricted room to be dragged outside // current space if can't change `m.room.join_rules` `content.allow` if (draggingOutsideSpace && restrictedItem) { const itemPowerLevels = roomsPowerLevels.get(item.roomId) ?? {}; - const itemCreators = getRoomCreatorsForRoomId(mx, item.roomId); + const itemCreators = ct.roomToCreators.read.get(item.roomId); const itemPermissions = getRoomPermissionsAPI(itemCreators, itemPowerLevels); const canChangeJoinRuleAllow = itemPermissions.stateEvent( StateEvent.RoomJoinRules, - mx.getSafeUserId() + ct.session.userId ); if (!canChangeJoinRuleAllow) { return false; @@ -111,17 +121,17 @@ const useCanDropLobbyItem = ( } const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {}; - const creators = getRoomCreatorsForRoomId(mx, containerSpaceId); + const creators = ct.roomToCreators.read.get(containerSpaceId); const permissions = getRoomPermissionsAPI(creators, powerLevels); if ( - getRoom(containerSpaceId) === undefined || - !permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) + !ct.rooms.join.read.includes(containerSpaceId) || + !permissions.stateEvent(StateEvent.SpaceChild, ct.session.userId) ) { return false; } return true; }, - [mx, getRoom, roomsPowerLevels] + [ct, roomsPowerLevels] ); const canDrop: CanDropCallback = useCallback( @@ -145,27 +155,37 @@ const useCanDropLobbyItem = ( }; export function Lobby() { + const ct = useClient(); 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 spaceId = useSpace(); + const allJoinedRooms = useAllJoinedRoomsSet(); + + const powerLevelStateResult = usePowerLevelsState(spaceId); + const spacePowerLevels = usePowerLevels(powerLevelStateResult.value); const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []); - const members = useRoomMembers(mx, space.roomId); + + const powerLevelTagsStateResult = usePowerLevelTagsState(spaceId); + const powerLevelTags = usePowerLevelTags(spacePowerLevels, powerLevelTagsStateResult.value); + const membersAPI = useCreateRoomMembersAPI(spaceId); + const membersPending = usePendingMembers(spaceId); + usePendingMemberLoader(spaceId, membersPending); const scrollRef = useRef(null); const heroSectionRef = useRef(null); const [heroSectionHeight, setHeroSectionHeight] = useState(); - const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom); + const setSpaceRooms = useSetAtom(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 roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(ct, allRoomsAtom, roomToParents); + const { value: cinnySpacesEvent } = useAccountData>( + AccountDataType.CinnySpaces ); + const sidebarItems = useSidebarItems(orphanSpaces, cinnySpacesEvent); + const sidebarSpaces = useMemo(() => { const sideSpaces = sidebarItems.flatMap((item) => { if (typeof item === 'string') return item; @@ -175,25 +195,23 @@ export function Lobby() { return new Set(sideSpaces); }, [sidebarItems]); - const [spacesItems, setSpacesItem] = useState>(() => new Map()); + const [spacesItems, setSpacesItem] = useState>( + () => new Map() + ); useElementSizeObserver( useCallback(() => heroSectionRef.current, []), useCallback((w, height) => setHeroSectionHeight(height), []) ); - const getRoom = useGetRoom(allJoinedRooms); - const [draggingItem, setDraggingItem] = useState(); const hierarchy = useSpaceHierarchy( - space.roomId, - spaceRooms, - getRoom, + spaceId, useCallback( (childId) => - closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || + closedCategories.has(makeLobbyCategoryId(spaceId, childId)) || (draggingItem ? 'space' in draggingItem : false), - [closedCategories, space.roomId, draggingItem] + [closedCategories, spaceId, draggingItem] ) ); @@ -210,17 +228,17 @@ export function Lobby() { useMemo( () => hierarchy - .flatMap((i) => { - const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : []; + .flatMap((i): string[] => { + const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => r.roomId) : []; - return [getRoom(i.space.roomId), ...childRooms]; + return [i.space.roomId, ...childRooms]; }) - .filter((r) => !!r) as Room[], - [hierarchy, getRoom] + .filter((r) => ct.rooms.join.read.includes(r)), + [hierarchy, ct] ) ); - const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom); + const canDrop: CanDropCallback = useCanDropLobbyItem(spaceId, roomsPowerLevels); const [reorderSpaceState, reorderSpace] = useAsyncCallback( useCallback( @@ -258,25 +276,31 @@ export function Lobby() { const parentPL = roomsPowerLevels.get(reorder.item.parentId); if (!parentPL) return false; - const creators = getRoomCreatorsForRoomId(mx, reorder.item.parentId); + const creators = ct.roomToCreators.read.get(reorder.item.parentId); const permissions = getRoomPermissionsAPI(creators, parentPL); - const canEdit = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()); + const canEdit = permissions.stateEvent(StateEvent.SpaceChild, ct.session.userId); return canEdit && reorder.orderKey !== currentOrders[index]; }); if (reorders) { await rateLimitedActions(reorders, async (reorder) => { - if (!reorder.item.parentId) return; - await mx.sendStateEvent( - reorder.item.parentId, - StateEvent.SpaceChild as any, - { ...reorder.item.content, order: reorder.orderKey }, - reorder.item.roomId + if (!reorder.item.parentId) return undefined; + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { + roomId: reorder.item.parentId, + eventType: StateEvent.SpaceChild, + stateKey: reorder.item.roomId, + }, + body: { ...reorder.item.content, order: reorder.orderKey }, + }) ); + const [err] = await parseMatrixResp(resp); + return err; }); } }, - [mx, hierarchy, lex, roomsPowerLevels] + [ct, hierarchy, lex, roomsPowerLevels] ) ); const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading; @@ -284,7 +308,6 @@ export function Lobby() { const [reorderRoomState, reorderRoom] = useAsyncCallback( useCallback( async (item: HierarchyItem, containerItem: HierarchyItem) => { - const itemRoom = mx.getRoom(item.roomId); if (!item.parentId) { return; } @@ -294,49 +317,70 @@ export function Lobby() { // remove from current space if (item.parentId !== containerParentId) { - mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); + ct.requestAuthJSON( + putStateEvent({ + pathParams: { + roomId: item.parentId, + eventType: StateEvent.SpaceChild, + stateKey: item.roomId, + }, + body: {}, + }) + ); } if ( - itemRoom && - itemRoom.getJoinRule() === JoinRule.Restricted && + ct.roomToJoinRule.read.get(item.roomId) === '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(); + const joinRuleContent = ct.state.join.joinRules.read.find( + (e) => e.roomId === item.roomId && e.event.state_key === '' + )?.event.content; if (joinRuleContent) { const allow = - joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? - []; - allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); - mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { - ...joinRuleContent, - allow, - }); + ObjectReader.array(joinRuleContent, 'allow')?.filter( + (allowRule) => ObjectReader.string(allowRule, 'room_id') !== item.parentId + ) ?? []; + const allowCondition: AllowCondition = { + type: 'm.room_membership', + room_id: containerParentId, + }; + allow.push(allowCondition); + ct.requestAuthJSON( + putStateEvent({ + pathParams: { + roomId: item.roomId, + eventType: StateEvent.RoomJoinRules, + stateKey: '', + }, + body: { + ...joinRuleContent, + allow, + }, + }) + ); } } - const itemSpaces = Array.from( + const itemRooms = Array.from( hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? [] - ); + ).filter((i) => i.roomId !== item.roomId); const beforeItem: HierarchyItem | undefined = 'space' in containerItem ? undefined : containerItem; - const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId); + const beforeIndex = itemRooms.findIndex((i) => i.roomId === beforeItem?.roomId); const insertIndex = beforeIndex + 1; - itemSpaces.splice(insertIndex, 0, { + itemRooms.splice(insertIndex, 0, { ...item, parentId: containerParentId, content: { ...itemContent, order: undefined }, }); - const currentOrders = itemSpaces.map((i) => { + const currentOrders = itemRooms.map((i) => { if (typeof i.content.order === 'string' && lex.has(i.content.order)) { return i.content.order; } @@ -347,23 +391,29 @@ export function Lobby() { const reorders = newOrders ?.map((orderKey, index) => ({ - item: itemSpaces[index], + item: itemRooms[index], orderKey, })) .filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]); if (reorders) { await rateLimitedActions(reorders, async (reorder) => { - await mx.sendStateEvent( - containerParentId, - StateEvent.SpaceChild as any, - { ...reorder.item.content, order: reorder.orderKey }, - reorder.item.roomId + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { + roomId: containerParentId, + eventType: StateEvent.SpaceChild, + stateKey: reorder.item.roomId, + }, + body: { ...reorder.item.content, order: reorder.orderKey }, + }) ); + const [err] = await parseMatrixResp(resp); + return err; }); } }, - [mx, hierarchy, lex] + [ct, hierarchy, lex] ) ); const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading; @@ -388,7 +438,7 @@ export function Lobby() { ); const handleSpacesFound = useCallback( - (sItems: IHierarchyRoom[]) => { + (sItems: SpaceHierarchyRoomChunk[]) => { setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) }); setSpacesItem((current) => { const newItems = produce(current, (draft) => { @@ -407,8 +457,10 @@ export function Lobby() { const handleOpenRoom: MouseEventHandler = (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 spaceAlias = ct.roomToAlias.read.get(spaceId); + const roomAlias = ct.roomToAlias.read.get(rId); + + navigate(getSpaceRoomPath(spaceAlias ?? spaceId, roomAlias ?? rId)); }; const togglePinToSidebar = useCallback( @@ -417,127 +469,134 @@ export function Lobby() { if (!sidebarSpaces.has(rId)) { newItems.push(rId); } - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); + const newSpacesContent = makeCinnySpacesContent(newItems, cinnySpacesEvent); + ct.requestAuthJSON( + putAccountData({ + pathParams: { + userId: ct.session.userId, + type: AccountDataType.CinnySpaces, + }, + body: newSpacesContent, + }) + ); }, - [mx, sidebarItems, sidebarSpaces] + [ct, sidebarItems, sidebarSpaces, cinnySpacesEvent] ); return ( - - - - - - - - - - virtualizer.scrollToOffset(0)} - variant="SurfaceVariant" - radii="Pill" - outlined - size="300" - aria-label="Scroll to Top" - > - - - -
- - - - {vItems.map((vItem) => { - const item = hierarchy[vItem.index]; - if (!item) return null; - const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId; - - const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId); - - return ( - - - - ); - })} -
- {reordering && ( - - } + + + + + + + + + + + - Reordering - - - )} -
-
-
+ virtualizer.scrollToOffset(0)} + variant="SurfaceVariant" + radii="Pill" + outlined + size="300" + aria-label="Scroll to Top" + > + + + +
+ + + + {vItems.map((vItem) => { + const item = hierarchy[vItem.index]; + if (!item) return null; + const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId; + + const categoryId = makeLobbyCategoryId(spaceId, item.space.roomId); + + return ( + + + + ); + })} +
+ {reordering && ( + + } + > + Reordering + + + )} + + + +
+
+ {screenSize === ScreenSize.Desktop && isDrawer && ( + + + + + )}
- - {screenSize === ScreenSize.Desktop && isDrawer && ( - <> - - - - )} -
- + + + ); } diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index ed526c68..f9692525 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -20,9 +20,7 @@ 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'; @@ -32,12 +30,16 @@ import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { stopPropagation } from '../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { BackRouteHandler } from '../../components/BackRouteHandler'; -import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useClient } from '../../hooks/useClient'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../../client/mxcToHttp'; type LobbyMenuProps = { powerLevels: IPowerLevels; @@ -45,12 +47,12 @@ type LobbyMenuProps = { }; const LobbyMenu = forwardRef( ({ powerLevels, requestClose }, ref) => { - const mx = useMatrixClient(); - const space = useSpace(); - const creators = useRoomCreators(space); + const ct = useClient(); + const spaceId = useSpace(); + const creators = useRoomCreators(spaceId); const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); + const canInvite = permissions.action('invite', ct.session.userId); const openSpaceSettings = useOpenSpaceSettings(); const [invitePrompt, setInvitePrompt] = useState(false); @@ -60,7 +62,7 @@ const LobbyMenu = forwardRef( }; const handleRoomSettings = () => { - openSpaceSettings(space.roomId); + openSpaceSettings(spaceId); requestClose(); }; @@ -68,7 +70,7 @@ const LobbyMenu = forwardRef( {invitePrompt && ( { setInvitePrompt(false); requestClose(); @@ -121,7 +123,7 @@ const LobbyMenu = forwardRef( {promptLeave && ( setPromptLeave(false)} /> @@ -140,19 +142,28 @@ type LobbyHeaderProps = { powerLevels: IPowerLevels; }; export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const space = useSpace(); + const spaceId = useSpace(); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const [menuAnchor, setMenuAnchor] = useState(); const screenSize = useScreenSizeContext(); - const name = useRoomName(space); - const avatarMxc = useRoomAvatar(space); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(spaceId); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + + const avatarMxc = getRoomAvatar(spaceId); const avatarUrl = avatarMxc - ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + ? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; + const name = getRoomName(spaceId) ?? 'Unknown'; + const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; @@ -187,7 +198,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { <> {nameInitials(name)}} diff --git a/src/app/features/lobby/LobbyHero.tsx b/src/app/features/lobby/LobbyHero.tsx index 1cd0b8e2..e6fa24d0 100644 --- a/src/app/features/lobby/LobbyHero.tsx +++ b/src/app/features/lobby/LobbyHero.tsx @@ -1,9 +1,7 @@ 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'; @@ -11,25 +9,41 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer'; import * as css from './LobbyHero.css'; import { PageHero } from '../../components/page'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; -import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useClient } from '../../hooks/useClient'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; +import { useRoomTopic } from '../../hooks/storage/useRoomTopic'; +import { mxcToHttp } from '../../../client/mxcToHttp'; export function LobbyHero() { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const space = useSpace(); + const spaceId = useSpace(); - const name = useRoomName(space); - const topic = useRoomTopic(space); - const avatarMxc = useRoomAvatar(space); - const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(spaceId); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + const topic = useRoomTopic(spaceId); + + const avatarMxc = getRoomAvatar(spaceId); + const avatarUrl = avatarMxc + ? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) + : undefined; + + const name = getRoomName(spaceId) ?? 'Unknown'; return ( {nameInitials(name)}} diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..8db5506e 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react'; +import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react'; import { Avatar, Badge, @@ -13,72 +13,90 @@ import { Spinner, Text, Tooltip, - TooltipProvider, as, color, toRem, + PopOut, + RectCords, + IconButton, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; -import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { postJoin } from 'neurone/lib/api/matrix/client/v3/join'; 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 { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader'; import { UseStateProvider } from '../../components/UseStateProvider'; import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; -import { Membership } from '../../../types/matrix/room'; +import { JoinRule } from '../../../types/matrix/room'; import * as css from './RoomItem.css'; import * as styleCss from './style.css'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; -import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useClient } from '../../hooks/useClient'; +import { BreakWord } from '../../styles/Text.css'; +import { mxcToHttp } from '../../../client/mxcToHttp'; type RoomJoinButtonProps = { roomId: string; via?: string[]; }; function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const resp = await ct.requestAuthJSON( + postJoin({ + pathParams: { roomIdOrAlias: roomId }, + searchParams: { via, server_name: via }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(printMatrixError(err)); + }, [ct, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; + const [cords, setCords] = useState(); + const handleErrorMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + return ( {joinState.status === AsyncStatus.Error && ( - - - {joinState.error.data?.error || joinState.error.message} + + {typeof joinState.error === 'object' && joinState.error instanceof Error + ? joinState.error.message + : 'Unknown error'} - {joinState.error.name} } > - {(triggerRef) => ( + - )} - + + )} ( - + )} /> @@ -279,18 +293,17 @@ function RoomProfile({ type RoomItemCardProps = { item: HierarchyItem; loading: boolean; - error: Error | null; - summary: IHierarchyRoom | undefined; - dm?: boolean; + error: unknown; + summary: SpaceHierarchyRoomChunk | undefined; firstChild?: boolean; lastChild?: boolean; onOpen: MouseEventHandler; + joined?: boolean; options?: ReactNode; before?: ReactNode; after?: ReactNode; onDragging: (item?: HierarchyItem) => void; canReorder: boolean; - getRoom: (roomId: string) => Room | undefined; }; export const RoomItemCard = as<'div', RoomItemCardProps>( ( @@ -299,28 +312,24 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( loading, error, summary, - dm, onOpen, + joined, options, before, after, onDragging, canReorder, - getRoom, ...props }, ref ) => { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const { roomId, content } = item; - const room = getRoom(roomId); const targetRef = useRef(null); const targetHandleRef = useRef(null); useDraggableItem(item, targetRef, onDragging, targetHandleRef); - const joined = room?.getMyMembership() === Membership.Join; - return ( ( {before} {canReorder && } - {room ? ( - + {joined ? ( + {(localSummary) => ( ( topic={summary.topic} avatarUrl={ summary?.avatar_url - ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ?? - undefined + ? mxcToHttp( + ct.session.baseUrl, + summary.avatar_url, + useAuthentication + ).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined } memberCount={summary.num_joined_members} suggested={content.suggested} - joinRule={summary.join_rule} + joinRule={summary.join_rule as JoinRule} options={} /> )} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 280b8a5a..7178feb2 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -1,7 +1,6 @@ import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; -import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { Box, config, Text } from 'folds'; +import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types'; import { HierarchyItem, HierarchyItemRoom, @@ -9,22 +8,22 @@ import { useFetchSpaceHierarchyLevel, } from '../../hooks/useSpaceHierarchy'; import { IPowerLevels } from '../../hooks/usePowerLevels'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SpaceItemCard } from './SpaceItem'; import { AfterItemDropTarget, CanDropCallback } from './DnD'; import { HierarchyItemMenu } from './HierarchyItemMenu'; import { RoomItemCard } from './RoomItem'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { SequenceCard } from '../../components/sequence-card'; -import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; +import { useClient } from '../../hooks/useClient'; +import { rError } from '../../../client/reader'; +import { ErrorCode } from '../../cs-errorcode'; type SpaceHierarchyProps = { - summary: IHierarchyRoom | undefined; + summary: SpaceHierarchyRoomChunk | undefined; spaceItem: HierarchyItemSpace; roomItems?: HierarchyItemRoom[]; allJoinedRooms: Set; - mDirects: Set; roomsPowerLevels: Map; categoryId: string; closed: boolean; @@ -34,10 +33,9 @@ type SpaceHierarchyProps = { canDrop: CanDropCallback; disabledReorder?: boolean; nextSpaceId?: string; - getRoom: (roomId: string) => Room | undefined; pinned: boolean; togglePinToSidebar: (roomId: string) => void; - onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; + onSpacesFound: (spaceItems: SpaceHierarchyRoomChunk[]) => void; onOpenRoom: MouseEventHandler; }; export const SpaceHierarchy = forwardRef( @@ -47,7 +45,6 @@ export const SpaceHierarchy = forwardRef( spaceItem, roomItems, allJoinedRooms, - mDirects, roomsPowerLevels, categoryId, closed, @@ -57,7 +54,6 @@ export const SpaceHierarchy = forwardRef( canDrop, disabledReorder, nextSpaceId, - getRoom, pinned, togglePinToSidebar, onOpenRoom, @@ -65,12 +61,11 @@ export const SpaceHierarchy = forwardRef( }, ref ) => { - const mx = useMatrixClient(); - + const ct = useClient(); const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true); const subspaces = useMemo(() => { - const s: Map = new Map(); + const s: Map = new Map(); rooms.forEach((r) => { if (r.room_type === RoomType.Space) { s.set(r.room_id, r); @@ -80,7 +75,7 @@ export const SpaceHierarchy = forwardRef( }, [rooms]); const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId); - const spaceCreators = getRoomCreatorsForRoomId(mx, spaceItem.roomId); + const spaceCreators = ct.roomToCreators.read.get(spaceItem.roomId); const spacePermissions = spacePowerLevels && getRoomPermissionsAPI(spaceCreators, spacePowerLevels); @@ -89,21 +84,19 @@ export const SpaceHierarchy = forwardRef( const { parentId } = spaceItem; const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) : undefined; - const parentCreators = parentId ? getRoomCreatorsForRoomId(mx, parentId) : undefined; + const parentCreators = parentId ? ct.roomToCreators.read.get(parentId) : undefined; const parentPermissions = - parentCreators && - parentPowerLevels && - getRoomPermissionsAPI(parentCreators, parentPowerLevels); + parentPowerLevels && getRoomPermissionsAPI(parentCreators, parentPowerLevels); useEffect(() => { onSpacesFound(Array.from(subspaces.values())); }, [subspaces, onSpacesFound]); let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId)); - if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { + if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, ct.session.userId)) { // hide unknown rooms for normal user childItems = childItems?.filter((i) => { - const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false; + const forbidden = rError.errcode(error) === ErrorCode.M_FORBIDDEN; const inaccessibleRoom = !rooms.get(i.roomId) && !fetching && (error ? forbidden : true); return !inaccessibleRoom; }); @@ -119,11 +112,10 @@ export const SpaceHierarchy = forwardRef( categoryId={categoryId} closed={closed} handleClose={handleClose} - getRoom={getRoom} - canEditChild={!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())} + canEditChild={!!spacePermissions?.stateEvent(StateEvent.SpaceChild, ct.session.userId)} canReorder={ - parentPowerLevels && !disabledReorder && parentPermissions - ? parentPermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) + !disabledReorder && parentPermissions + ? parentPermissions.stateEvent(StateEvent.SpaceChild, ct.session.userId) : false } options={ @@ -134,7 +126,7 @@ export const SpaceHierarchy = forwardRef( powerLevels={spacePowerLevels} joined={allJoinedRooms.has(spaceItem.roomId)} canEditChild={ - !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) + !!parentPermissions?.stateEvent(StateEvent.SpaceChild, ct.session.userId) } pinned={pinned} onTogglePin={togglePinToSidebar} @@ -159,8 +151,8 @@ export const SpaceHierarchy = forwardRef( const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {}; - const lastItem = index === childItems.length; - const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId; + const lastItem = index === childItems?.length; + const nextRoomId = lastItem ? nextSpaceId : childItems?.[index + 1]?.roomId; const roomDragging = draggingItem?.roomId === roomItem.roomId && @@ -173,11 +165,10 @@ export const SpaceHierarchy = forwardRef( loading={fetching} error={error} summary={roomSummary} - dm={mDirects.has(roomItem.roomId)} onOpen={onOpenRoom} - getRoom={getRoom} + joined={allJoinedRooms.has(roomItem.roomId)} canReorder={ - !!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) && + !!spacePermissions?.stateEvent(StateEvent.SpaceChild, ct.session.userId) && !disabledReorder } options={ @@ -186,7 +177,7 @@ export const SpaceHierarchy = forwardRef( powerLevels={roomPowerLevels} joined={allJoinedRooms.has(roomItem.roomId)} canEditChild={ - !!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) + !!spacePermissions?.stateEvent(StateEvent.SpaceChild, ct.session.userId) } /> } diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index ed3eaff9..64a86d44 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -1,4 +1,11 @@ -import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react'; +import React, { + Fragment, + MouseEventHandler, + ReactNode, + useCallback, + useRef, + useState, +} from 'react'; import { Box, Avatar, @@ -18,26 +25,28 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { MatrixError, Room } from 'matrix-js-sdk'; -import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { postJoin } from 'neurone/lib/api/matrix/client/v3/join'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { RoomAvatar } from '../../components/room-avatar'; import { nameInitials } from '../../utils/common'; -import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader'; -import { getRoomAvatarUrl } from '../../utils/room'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import * as css from './SpaceItem.css'; import * as styleCss from './style.css'; import { useDraggableItem } from './DnD'; import { stopPropagation } from '../../utils/keyboard'; -import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal'; import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; +import { useClient } from '../../hooks/useClient'; +import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../../client/mxcToHttp'; function SpaceProfileLoading() { return ( @@ -110,10 +119,19 @@ function UnjoinedSpaceProfile({ avatarUrl, suggested, }: UnjoinedSpaceProfileProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const resp = await ct.requestAuthJSON( + postJoin({ + pathParams: { roomIdOrAlias: roomId }, + searchParams: { via, server_name: via }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(printMatrixError(err)); + }, [ct, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -154,7 +172,7 @@ function UnjoinedSpaceProfile({ {joinState.status === AsyncStatus.Error && ( - {joinState.error.name} + Error )} @@ -165,22 +183,28 @@ function UnjoinedSpaceProfile({ type SpaceProfileProps = { roomId: string; - name: string; - avatarUrl?: string; suggested?: boolean; closed: boolean; categoryId: string; handleClose?: MouseEventHandler; }; -function SpaceProfile({ - roomId, - name, - avatarUrl, - suggested, - closed, - categoryId, - handleClose, -}: SpaceProfileProps) { +function SpaceProfile({ roomId, suggested, closed, categoryId, handleClose }: SpaceProfileProps) { + const ct = useClient(); + const useAuthentication = useMediaAuthentication(); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(roomId); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + + const name = getRoomName(roomId) ?? 'Unknown'; + const avatar = getRoomAvatar(roomId); + const avatarUrl = avatar + ? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) + : undefined; + return ( { - openCreateSpaceModal(item.roomId as any); + openCreateSpaceModal(item.roomId); setCords(undefined); }; @@ -388,7 +412,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { } type SpaceItemCardProps = { - summary: IHierarchyRoom | undefined; + summary: SpaceHierarchyRoomChunk | undefined; loading?: boolean; item: HierarchyItem; joined?: boolean; @@ -401,7 +425,6 @@ type SpaceItemCardProps = { canEditChild: boolean; canReorder: boolean; onDragging: (item?: HierarchyItem) => void; - getRoom: (roomId: string) => Room | undefined; }; export const SpaceItemCard = as<'div', SpaceItemCardProps>( ( @@ -420,15 +443,13 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( canEditChild, canReorder, onDragging, - getRoom, ...props }, ref ) => { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const { roomId, content } = item; - const space = getRoom(roomId); const targetRef = useRef(null); useDraggableItem(item, targetRef, onDragging); @@ -444,28 +465,25 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( {before} - {space ? ( - - {(localSummary) => - item.parentId ? ( - - ) : ( - - ) - } - + {joined ? ( + <> + {item.parentId && ( + + )} + {!item.parentId && ( + + )} + ) : ( <> {!summary && @@ -484,8 +502,11 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( name={summary.name || summary.canonical_alias || roomId} avatarUrl={ summary?.avatar_url - ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ?? - undefined + ? mxcToHttp( + ct.session.baseUrl, + summary.avatar_url, + useAuthentication + ).thumbnail({ width: 96, height: 96, method: 'crop' }) ?? undefined : undefined } suggested={content.suggested} @@ -494,7 +515,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( )} - {space && canEditChild && ( + {joined && canEditChild && ( {item.parentId === undefined && } diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ff9565b2..71d0dc77 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -318,6 +318,7 @@ export function RoomNavItem({ filled={selected} size="100" joinRule={joinRule} + roomType={ct.roomToType.read.get(roomId)} /> )} diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index a18fb35d..29d5e351 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -341,7 +341,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { src={avatarUrl} alt={name} renderFallback={() => ( - + )} /> diff --git a/src/app/hooks/useLocalRoomSummary.ts b/src/app/hooks/useLocalRoomSummary.ts index 4802a3cf..9f105cff 100644 --- a/src/app/hooks/useLocalRoomSummary.ts +++ b/src/app/hooks/useLocalRoomSummary.ts @@ -13,7 +13,7 @@ export type LocalRoomSummary = { avatarUrl?: string; canonicalAlias?: string; memberCount?: number; - space?: boolean; + roomType?: string; joinRule?: JoinRule; }; export const useLocalRoomSummary = (roomId: string): LocalRoomSummary => { @@ -25,7 +25,7 @@ export const useLocalRoomSummary = (roomId: string): LocalRoomSummary => { const roomToAlias = useObservableValue(ct.roomToAlias); const roomToJoinRule = useObservableValue(ct.roomToJoinRule); const roomToSummary = useObservableValue(ct.roomToSummary); - const spaceRooms = useObservableValue(ct.spaceRooms); + const roomToType = useObservableValue(ct.roomToType); return { roomId, @@ -34,7 +34,7 @@ export const useLocalRoomSummary = (roomId: string): LocalRoomSummary => { avatarUrl: getRoomAvatar(roomId), canonicalAlias: roomToAlias.get(roomId), memberCount: roomToSummary.get(roomId)?.['m.joined_member_count'], - space: spaceRooms.has(roomId), + roomType: roomToType.get(roomId), joinRule: roomToJoinRule.get(roomId), }; }; diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index 52c9c566..631b5395 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -1,11 +1,8 @@ -import { MatrixEvent, Room } from 'matrix-js-sdk'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { MatrixEvent } from 'matrix-js-sdk'; +import { createContext, useCallback, useContext, useMemo } from 'react'; import produce from 'immer'; import { PendingObservableResult } from 'neurone/lib/utils/observable'; import { StateEvent, StateEventType } from '../../types/matrix/room'; -import { useStateEventCallback } from './useStateEventCallback'; -import { useMatrixClient } from './useMatrixClient'; -import { getStateEvent } from '../utils/room'; import { getJoinRoomStateId, JoinRoomStateObject } from '../../client/storage/collections'; import { useClient } from './useClient'; import { useObservableValuePromise } from './useObservableValue'; @@ -84,12 +81,14 @@ export function usePowerLevelsState( return useObservableValuePromise(eventObservablePromise); } -export function usePowerLevels(eventObj: JoinRoomStateObject | undefined): IPowerLevels { - const powerLevels: IPowerLevels = useMemo(() => { - const content: IPowerLevels | undefined = eventObj?.event.content; +function parsePowerLevels(eventObj?: JoinRoomStateObject): IPowerLevels { + const content: IPowerLevels | undefined = eventObj?.event.content; - return !content ? DEFAULT_POWER_LEVELS : fillMissingPowers(content); - }, [eventObj]); + return !content ? DEFAULT_POWER_LEVELS : fillMissingPowers(content); +} + +export function usePowerLevels(eventObj: JoinRoomStateObject | undefined): IPowerLevels { + const powerLevels: IPowerLevels = useMemo(() => parsePowerLevels(eventObj), [eventObj]); return powerLevels; } @@ -104,38 +103,27 @@ export const usePowerLevelsContext = (): IPowerLevels => { return pl; }; -export const useRoomsPowerLevels = (rooms: Room[]): Map => { - const mx = useMatrixClient(); - const getRoomsPowerLevels = useCallback(() => { - const rToPl = new Map(); +export const useRoomsPowerLevels = (rooms: string[]): Map => { + const ct = useClient(); + const powerLevelEventsPromise = useMemo( + () => ct.storage.joinRoomState.observableRead.queryIndex('type', StateEvent.RoomPowerLevels), + [ct] + ); + const plEventsResult = useObservableValuePromise(powerLevelEventsPromise); - rooms.forEach((room) => { - const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, ''); - rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent)); + const roomToPowerLevels: Map = useMemo(() => { + const rToP = new Map(); + + console.log(plEventsResult.value?.find((v) => v.roomId === '!KqyBSJuhpIHbWaROMB:kde.org')); + rooms.forEach((roomId) => { + const evtObj = plEventsResult.value?.find( + (o) => o.roomId === roomId && o.event.state_key === '' + ); + rToP.set(roomId, parsePowerLevels(evtObj)); }); - return rToPl; - }, [rooms]); - - const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels()); - - useStateEventCallback( - mx, - useCallback( - (event) => { - const roomId = event.getRoomId(); - if ( - roomId && - event.getType() === StateEvent.RoomPowerLevels && - event.getStateKey() === '' && - rooms.find((r) => r.roomId === roomId) - ) { - setRoomToPowerLevels(getRoomsPowerLevels()); - } - }, - [rooms, getRoomsPowerLevels] - ) - ); + return rToP; + }, [plEventsResult, rooms]); return roomToPowerLevels; }; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 8379fd86..f2031de0 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -95,7 +95,8 @@ const getSpaceHierarchy = ( rootSpaceId: string, roomToChildEvents: Map[]>, spaceRooms: Set, - spaceRoomsCache: Set + spaceRoomsCache: Set, + closedCategory: (spaceId: string) => boolean ): SpaceHierarchy[] => { const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( rootSpaceId, @@ -106,6 +107,9 @@ const getSpaceHierarchy = ( const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => { const childEvents = roomToChildEvents.get(spaceItem.roomId); + if (!spaceRooms.has(spaceItem.roomId) || closedCategory(spaceItem.roomId)) { + return { space: spaceItem }; + } const childItems: HierarchyItemRoom[] = []; childEvents?.forEach((childEvent) => { @@ -131,15 +135,19 @@ const getSpaceHierarchy = ( return hierarchy; }; -export const useSpaceHierarchy = (spaceId: string): SpaceHierarchy[] => { +export const useSpaceHierarchy = ( + spaceId: string, + closedCategory: (spaceId: string) => boolean +): SpaceHierarchy[] => { const ct = useClient(); const roomToChildEvents = useObservableValue(ct.roomToChildEvents); const spaceRooms = useObservableValue(ct.spaceRooms); const spaceRoomsCache = useAtomValue(spaceRoomsAtom); const hierarchy = useMemo( - () => getSpaceHierarchy(spaceId, roomToChildEvents, spaceRooms, spaceRoomsCache), - [spaceId, roomToChildEvents, spaceRooms, spaceRoomsCache] + () => + getSpaceHierarchy(spaceId, roomToChildEvents, spaceRooms, spaceRoomsCache, closedCategory), + [spaceId, roomToChildEvents, spaceRooms, spaceRoomsCache, closedCategory] ); return hierarchy; @@ -216,7 +224,7 @@ const PER_PAGE_COUNT = 100; const MAX_AUTO_PAGE_COUNT = 50; export type FetchSpaceHierarchyLevelData = { fetching: boolean; - error: Error | null; + error: unknown; rooms: Map; }; export const useFetchSpaceHierarchyLevel = ( diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index c5318e06..0fec4863 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -230,8 +230,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - {/* } /> - } /> */} + } /> + {/*} /> */} >; roomToHistoryVisibility: ObservableValue>; roomToJoinRule: ObservableValue>; + roomToType: ObservableValue>; roomToReplacement: ObservableValue>; roomToCreators: ObservableValue>>; roomToVersion: ObservableValue>; @@ -389,10 +390,10 @@ export const createRoomContext = async ( }; }; -export const createEncryptedRoomsObservable = async ( +export const createEncryptedRoomsObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const encryptedRoomsObservable: ObservableValue> = new ComputedObservableValue(() => { const encryptedRooms = new Set(); @@ -440,10 +441,10 @@ export const createDirectRoomsObservable = async ( return directRoomsObservable; }; -export const createRoomToReplacementObservable = async ( +export const createRoomToReplacementObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const roomToReplacementObservable: ObservableValue> = new ComputedObservableValue(() => { const roomToReplacement = new Map(); @@ -463,13 +464,13 @@ export const createRoomToReplacementObservable = async ( return roomToReplacementObservable; }; -export const createSpaceMapping = async ( +export const createSpaceMapping = ( state: StateContext, disposer: Disposer -): Promise<{ +): { roomToParents: ObservableValue>>; roomToChildEvents: ObservableValue[]>>; -}> => { +} => { const roomToParentsObservable: ObservableValue>> = new ComputedObservableValue(() => { const roomToParents = new Map>(); @@ -533,10 +534,10 @@ export const createRoomToSummaryObservable = async ( return roomToSummaryObservable; }; -export const createRoomToAliasObservable = async ( +export const createRoomToAliasObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const roomToAliasObservable: ObservableValue> = new ComputedObservableValue(() => { const roomToAlias = new Map(); @@ -570,10 +571,10 @@ export const createRoomToAliasObservable = async ( return roomToAliasObservable; }; -export const createRoomToNameObservable = async ( +export const createRoomToNameObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const roomToNameObservable: ObservableValue> = new ComputedObservableValue(() => { const roomToName = new Map(); @@ -601,10 +602,10 @@ export const createRoomToNameObservable = async ( return roomToNameObservable; }; -export const createRoomToAvatarObservable = async ( +export const createRoomToAvatarObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const roomToAvatarObservable: ObservableValue> = new ComputedObservableValue(() => { const roomToAvatar = new Map(); @@ -631,10 +632,10 @@ export const createRoomToAvatarObservable = async ( return roomToAvatarObservable; }; -export const createRoomToHistoryVisibilityObservable = async ( +export const createRoomToHistoryVisibilityObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const roomToHistoryVisibilityObservable: ObservableValue> = new ComputedObservableValue(() => { const roomToHistoryVisibility = new Map(); @@ -657,10 +658,10 @@ export const createRoomToHistoryVisibilityObservable = async ( return roomToHistoryVisibilityObservable; }; -export const createRoomToJoinRuleObservable = async ( +export const createRoomToJoinRuleObservable = ( state: StateContext, disposer: Disposer -): Promise>> => { +): ObservableValue> => { const roomToJoinRuleObservable: ObservableValue> = new ComputedObservableValue(() => { const roomToJoinRule = new Map(); @@ -689,6 +690,32 @@ export const createRoomToJoinRuleObservable = async ( return roomToJoinRuleObservable; }; +export const createRoomToTypeObservable = ( + state: StateContext, + disposer: Disposer +): ObservableValue> => { + const roomToTypeObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToJoinRule = new Map(); + + const addType = (obj: RoomStateObject) => { + const { content } = obj.event; + const type = ObjectReader.string(content, 'type'); + roomToJoinRule.set(obj.roomId, type); + }; + + state.join.create.read.forEach(addType); + state.invite.create.read.forEach(addType); + state.knock.create.read.forEach(addType); + state.leave.create.read.forEach(addType); + + return roomToJoinRule; + }, [state.join.create, state.invite.create, state.knock.create, state.leave.create]); + + disposer.collect(roomToTypeObservable); + return roomToTypeObservable; +}; + export const createRoomToLatestEventObservable = async ( storage: Storage, disposer: Disposer diff --git a/src/client/initialize.ts b/src/client/initialize.ts index 505a636d..8228ef76 100644 --- a/src/client/initialize.ts +++ b/src/client/initialize.ts @@ -19,6 +19,7 @@ import { createRoomToNameObservable, createRoomToReplacementObservable, createRoomToSummaryObservable, + createRoomToTypeObservable, createSpaceMapping, createStateContext, createSyncTokenObservable, @@ -81,17 +82,18 @@ export const initializeClient = async (session: Session): Promise state, disposer ); - const encryptedRooms = await createEncryptedRoomsObservable(state, disposer); + const encryptedRooms = createEncryptedRoomsObservable(state, disposer); const directRooms = await createDirectRoomsObservable(storage, disposer); - const { roomToParents, roomToChildEvents } = await createSpaceMapping(state, disposer); + const { roomToParents, roomToChildEvents } = createSpaceMapping(state, disposer); const roomToSummary = await createRoomToSummaryObservable(storage, disposer); - const roomToAlias = await createRoomToAliasObservable(state, disposer); - const roomToName = await createRoomToNameObservable(state, disposer); - const roomToAvatar = await createRoomToAvatarObservable(state, disposer); - const roomToHistoryVisibility = await createRoomToHistoryVisibilityObservable(state, disposer); - const roomToJoinRule = await createRoomToJoinRuleObservable(state, disposer); - const roomToReplacement = await createRoomToReplacementObservable(state, disposer); + const roomToAlias = createRoomToAliasObservable(state, disposer); + const roomToName = createRoomToNameObservable(state, disposer); + const roomToAvatar = createRoomToAvatarObservable(state, disposer); + const roomToHistoryVisibility = createRoomToHistoryVisibilityObservable(state, disposer); + const roomToJoinRule = createRoomToJoinRuleObservable(state, disposer); + const roomToType = createRoomToTypeObservable(state, disposer); + const roomToReplacement = createRoomToReplacementObservable(state, disposer); const roomToLatestEvent = await createRoomToLatestEventObservable(storage, disposer); const syncTokenObservable = await createSyncTokenObservable(storage, disposer); @@ -137,6 +139,7 @@ export const initializeClient = async (session: Session): Promise roomToAvatar, roomToHistoryVisibility, roomToJoinRule, + roomToType, roomToReplacement, roomToLatestEvent, roomToCreators, diff --git a/src/client/reader/Error.ts b/src/client/reader/Error.ts index dbbfe52d..073a50ca 100644 --- a/src/client/reader/Error.ts +++ b/src/client/reader/Error.ts @@ -1,15 +1,15 @@ import { ObjectReader } from 'neurone/lib/utils'; export class rError { - static error(data?: object): string | undefined { + static error(data?: unknown): string | undefined { return ObjectReader.string(data, 'error'); } - static errcode(data?: object): string | undefined { + static errcode(data?: unknown): string | undefined { return ObjectReader.string(data, 'errcode'); } - static retryAfterMs(data?: object): number | undefined { + static retryAfterMs(data?: unknown): number | undefined { return ObjectReader.number(data, 'retry_after_ms'); } } diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 124e9d4e..8b1bde82 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -92,9 +92,11 @@ export type JoinRule = | 'restricted' | 'knock_restricted'; +export type AllowConditionType = 'm.room_membership'; + export type AllowCondition = { room_id: string; - type: string[]; + type: AllowConditionType; }; export type JoinRulesContent = { allow?: AllowCondition[]; From 48bc35dad6e5b6db93e87b17c419a5aa543f6621 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:44:01 +0530 Subject: [PATCH 004/158] add roomToTopic map for syncronus reads --- src/app/hooks/storage/useRoomTopic.ts | 12 +++--- src/client/context.ts | 58 ++++++++++++++++++++++++++- src/client/initialize.ts | 3 ++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/storage/useRoomTopic.ts b/src/app/hooks/storage/useRoomTopic.ts index 14e1cebb..bcc5c914 100644 --- a/src/app/hooks/storage/useRoomTopic.ts +++ b/src/app/hooks/storage/useRoomTopic.ts @@ -1,11 +1,9 @@ -import { StateEventType } from '../../../types/matrix/room'; -import { useRoomStateEvent } from './useRoomStateEvent'; +import { useClient } from '../useClient'; +import { useObservableValue } from '../useObservableValue'; export const useRoomTopic = (roomId: string): string | undefined => { - const topicEventResult = useRoomStateEvent(roomId, StateEventType.RoomTopic, ''); + const ct = useClient(); + const roomToTopic = useObservableValue(ct.roomToTopic); - const content = topicEventResult.value?.event.content; - if (content && 'topic' in content && typeof content.topic === 'string') return content.topic; - - return undefined; + return roomToTopic.get(roomId); }; diff --git a/src/client/context.ts b/src/client/context.ts index 7c889907..87028ffb 100644 --- a/src/client/context.ts +++ b/src/client/context.ts @@ -32,6 +32,7 @@ export type StateContext = { spaceChild: ObservableValue; canonicalAlias: ObservableValue; name: ObservableValue; + topic: ObservableValue; avatar: ObservableValue; joinRules: ObservableValue; historyVisibility: ObservableValue; @@ -41,6 +42,7 @@ export type StateContext = { encryption: ObservableValue; canonicalAlias: ObservableValue; name: ObservableValue; + topic: ObservableValue; avatar: ObservableValue; joinRules: ObservableValue; }; @@ -49,6 +51,7 @@ export type StateContext = { encryption: ObservableValue; canonicalAlias: ObservableValue; name: ObservableValue; + topic: ObservableValue; avatar: ObservableValue; joinRules: ObservableValue; }; @@ -57,6 +60,7 @@ export type StateContext = { encryption: ObservableValue; canonicalAlias: ObservableValue; name: ObservableValue; + topic: ObservableValue; avatar: ObservableValue; joinRules: ObservableValue; }; @@ -86,6 +90,7 @@ export type ClientContext = { roomToSummary: ObservableValue>; roomToAlias: ObservableValue>; roomToName: ObservableValue>; + roomToTopic: ObservableValue>; roomToAvatar: ObservableValue>; roomToHistoryVisibility: ObservableValue>; roomToJoinRule: ObservableValue>; @@ -205,6 +210,24 @@ export async function createStateContext( ); disposer.collect(joinName, inviteName, knockName, leaveName); + const joinTopic = await storage.joinRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomTopic + ); + const inviteTopic = await storage.inviteRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomTopic + ); + const knockTopic = await storage.knockRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomTopic + ); + const leaveTopic = await storage.leaveRoomState.observableRead.queryIndex( + 'type', + StateEventType.RoomTopic + ); + disposer.collect(joinTopic, inviteTopic, knockTopic, leaveTopic); + const joinAvatar = await storage.joinRoomState.observableRead.queryIndex( 'type', StateEventType.RoomAvatar @@ -255,6 +278,7 @@ export async function createStateContext( spaceChild: joinSpaceChild, canonicalAlias: joinCanonicalAlias, name: joinName, + topic: joinTopic, avatar: joinAvatar, joinRules: joinJoinRules, historyVisibility: joinHistoryVisibility, @@ -264,6 +288,7 @@ export async function createStateContext( encryption: inviteEncryption, canonicalAlias: inviteCanonicalAlias, name: inviteName, + topic: inviteTopic, avatar: inviteAvatar, joinRules: inviteJoinRules, }, @@ -272,6 +297,7 @@ export async function createStateContext( encryption: knockEncryption, canonicalAlias: knockCanonicalAlias, name: knockName, + topic: knockTopic, avatar: knockAvatar, joinRules: knockJoinRules, }, @@ -280,6 +306,7 @@ export async function createStateContext( encryption: leaveEncryption, canonicalAlias: leaveCanonicalAlias, name: leaveName, + topic: leaveTopic, avatar: leaveAvatar, joinRules: leaveJoinRules, }, @@ -602,6 +629,33 @@ export const createRoomToNameObservable = ( return roomToNameObservable; }; +export const createRoomToTopicObservable = ( + state: StateContext, + disposer: Disposer +): ObservableValue> => { + const roomToTopicObservable: ObservableValue> = + new ComputedObservableValue(() => { + const roomToTopic = new Map(); + + const addName = (obj: RoomStateObject) => { + const { content, state_key: stateKey } = obj.event; + const topic = ObjectReader.string(content, 'topic'); + if (topic && stateKey === '') { + roomToTopic.set(obj.roomId, topic); + } + }; + state.join.topic.read.forEach(addName); + state.invite.topic.read.forEach(addName); + state.knock.topic.read.forEach(addName); + state.leave.topic.read.forEach(addName); + + return roomToTopic; + }, [state.join.topic, state.invite.topic, state.knock.topic, state.leave.topic]); + + disposer.collect(roomToTopicObservable); + return roomToTopicObservable; +}; + export const createRoomToAvatarObservable = ( state: StateContext, disposer: Disposer @@ -701,7 +755,9 @@ export const createRoomToTypeObservable = ( const addType = (obj: RoomStateObject) => { const { content } = obj.event; const type = ObjectReader.string(content, 'type'); - roomToJoinRule.set(obj.roomId, type); + if (type) { + roomToJoinRule.set(obj.roomId, type); + } }; state.join.create.read.forEach(addType); diff --git a/src/client/initialize.ts b/src/client/initialize.ts index 8228ef76..81d27aaa 100644 --- a/src/client/initialize.ts +++ b/src/client/initialize.ts @@ -19,6 +19,7 @@ import { createRoomToNameObservable, createRoomToReplacementObservable, createRoomToSummaryObservable, + createRoomToTopicObservable, createRoomToTypeObservable, createSpaceMapping, createStateContext, @@ -89,6 +90,7 @@ export const initializeClient = async (session: Session): Promise const roomToSummary = await createRoomToSummaryObservable(storage, disposer); const roomToAlias = createRoomToAliasObservable(state, disposer); const roomToName = createRoomToNameObservable(state, disposer); + const roomToTopic = createRoomToTopicObservable(state, disposer); const roomToAvatar = createRoomToAvatarObservable(state, disposer); const roomToHistoryVisibility = createRoomToHistoryVisibilityObservable(state, disposer); const roomToJoinRule = createRoomToJoinRuleObservable(state, disposer); @@ -136,6 +138,7 @@ export const initializeClient = async (session: Session): Promise roomToSummary, roomToAlias, roomToName, + roomToTopic, roomToAvatar, roomToHistoryVisibility, roomToJoinRule, From b6e68d9c193e10ecbe8254fcc298aec869e0c355 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:05:53 +0530 Subject: [PATCH 005/158] fix creators & history visibility map reactivity --- src/client/context.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/context.ts b/src/client/context.ts index 87028ffb..021d79cf 100644 --- a/src/client/context.ts +++ b/src/client/context.ts @@ -380,6 +380,8 @@ export const createRoomContext = async ( } }); } + + roomToCreators.set(obj.roomId, creators); }); return roomToCreators; @@ -706,7 +708,7 @@ export const createRoomToHistoryVisibilityObservable = ( state.join.historyVisibility.read.forEach(addHistoryVisibility); return roomToHistoryVisibility; - }, [state.join.joinRules]); + }, [state.join.historyVisibility]); disposer.collect(roomToHistoryVisibilityObservable); return roomToHistoryVisibilityObservable; From 2788c8f813c15ca30688e5aa82fa90d002fcb4d5 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:41:51 +0530 Subject: [PATCH 006/158] replace sdk in lobby add room/space and existing window --- src/app/features/add-existing/AddExisting.tsx | 111 +++++++++++------- .../features/create-room/CreateRoomModal.tsx | 9 +- .../create-space/CreateSpaceModal.tsx | 9 +- src/app/hooks/usePowerLevels.ts | 8 +- 4 files changed, 74 insertions(+), 63 deletions(-) diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..337b0c62 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -29,16 +29,14 @@ import React, { } from 'react'; import { useAtomValue } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { Room } from 'matrix-js-sdk'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { stopPropagation } from '../../utils/keyboard'; import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { mDirectAtom } from '../../state/mDirectList'; import { roomToParentsAtom } from '../../state/room/roomToParents'; -import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { VirtualTile } from '../../components/virtualizer'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { nameInitials } from '../../utils/common'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -54,6 +52,13 @@ import { StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { rateLimitedActions } from '../../utils/matrix'; import { useAlive } from '../../hooks/useAlive'; +import { useClient } from '../../hooks/useClient'; +import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { getJoinRoomStateId } from '../../../client/storage/collections'; +import { getPowerLevels } from '../../hooks/usePowerLevels'; +import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../../client/mxcToHttp'; const SEARCH_OPTS: UseAsyncSearchOptions = { limit: 500, @@ -71,33 +76,34 @@ type AddExistingModalProps = { requestClose: () => void; }; export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const alive = useAlive(); const mDirects = useAtomValue(mDirectAtom); - const spaces = useSpaces(mx, allRoomsAtom); - const rooms = useRooms(mx, allRoomsAtom, mDirects); - const directs = useDirects(mx, allRoomsAtom, mDirects); + const spaces = useSpaces(ct, allRoomsAtom); + const rooms = useRooms(ct, allRoomsAtom, mDirects); + const directs = useDirects(ct, allRoomsAtom, mDirects); const roomIdToParents = useAtomValue(roomToParentsAtom); const scrollRef = useRef(null); - const [selected, setSelected] = useState([]); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(ct.rooms.join.read); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); - const allRoomsSet = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allRoomsSet); + const [selected, setSelected] = useState([]); const allItems: string[] = useMemo(() => { const rIds = space ? [...spaces] : [...rooms, ...directs]; return rIds .filter((rId) => rId !== parentId && !roomIdToParents.get(rId)?.has(parentId)) - .sort(factoryRoomIdByAtoZ(mx)); - }, [spaces, rooms, directs, space, parentId, roomIdToParents, mx]); + .sort(factoryRoomIdByAtoZ(getRoomName)); + }, [spaces, rooms, directs, space, parentId, roomIdToParents, getRoomName]); const getRoomNameStr: SearchItemStrGetter = useCallback( - (rId) => getRoom(rId)?.name ?? rId, - [getRoom] + (rId) => getRoomName(rId) ?? rId, + [getRoomName] ); const [searchResult, searchRoom, resetSearch] = useAsyncSearch( @@ -128,25 +134,36 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM searchRoom(value); }; - const [applyState, applyChanges] = useAsyncCallback( + const [applyState, applyChanges] = useAsyncCallback( useCallback( async (selectedRooms) => { - await rateLimitedActions(selectedRooms, async (room) => { - const via = getViaServers(room); - - await mx.sendStateEvent( - parentId, - StateEvent.SpaceChild as any, - { - auto_join: false, - suggested: false, - via, - }, - room.roomId + await rateLimitedActions(selectedRooms, async (roomId) => { + const creators = ct.roomToCreators.read.get(roomId); + const powerLevelsStateObj = await ct.storage.joinRoomState.read.get( + getJoinRoomStateId(roomId, StateEvent.RoomPowerLevels, '') ); + const powerLevels = getPowerLevels(powerLevelsStateObj); + const users = (await ct.storage.joinRoomMember.read.queryIndex('roomId', roomId)).map( + (m) => m.userId + ); + const via = getViaServers(creators, powerLevels, users); + + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId: parentId, eventType: StateEvent.SpaceChild, stateKey: roomId }, + body: { + auto_join: false, + suggested: false, + via, + }, + }) + ); + + const [err] = await parseMatrixResp(resp); + return err; }); }, - [mx, parentId] + [ct, parentId] ) ); const applyingChanges = applyState.status === AsyncStatus.Loading; @@ -164,8 +181,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM }; const handleApplyChanges = () => { - const selectedRooms = selected.map((rId) => getRoom(rId)).filter((room) => room !== undefined); - applyChanges(selectedRooms).then(() => { + applyChanges([...selected]).then(() => { if (alive()) { setSelected([]); requestClose(); @@ -253,10 +269,11 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM > {vItems.map((vItem) => { const roomId = items[vItem.index]; - const room = getRoom(roomId); - if (!room) return null; const selectedItem = selected?.includes(roomId); - const dm = mDirects.has(room.roomId); + const dm = mDirects.has(roomId); + + const roomName = getRoomName(roomId) ?? 'Unknown'; + const roomAvatar = getRoomAvatar(roomId); return ( - {dm || room.isSpaceRoom() ? ( + {dm || ct.spaceRooms.read.has(roomId) ? ( ( - {nameInitials(room.name)} + {nameInitials(roomName)} )} /> ) : ( )} @@ -304,8 +325,8 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM {queryHighlighRegex - ? highlightText(queryHighlighRegex, [room.name]) - : room.name} + ? highlightText(queryHighlighRegex, [roomName]) + : roomName} diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index d5d8c92d..08d51572 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -14,7 +14,6 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { SpaceProvider } from '../../hooks/useSpace'; import { CreateRoomForm } from './CreateRoom'; import { @@ -32,12 +31,8 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { const { spaceId, type } = state; const closeDialog = useCloseCreateRoomModal(); - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); - const space = spaceId ? getRoom(spaceId) : undefined; - return ( - + }> - + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index c1bc6898..860953bc 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -14,7 +14,6 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { SpaceProvider } from '../../hooks/useSpace'; import { CreateSpaceForm } from './CreateSpace'; import { @@ -31,12 +30,8 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { const { spaceId } = state; const closeDialog = useCloseCreateSpaceModal(); - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); - const space = spaceId ? getRoom(spaceId) : undefined; - return ( - + }> - + diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index 631b5395..0516bb6a 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -6,6 +6,7 @@ import { StateEvent, StateEventType } from '../../types/matrix/room'; import { getJoinRoomStateId, JoinRoomStateObject } from '../../client/storage/collections'; import { useClient } from './useClient'; import { useObservableValuePromise } from './useObservableValue'; +import { ClientContext } from '../../client/context'; export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical'; export type PowerLevelNotificationsAction = 'room'; @@ -81,14 +82,14 @@ export function usePowerLevelsState( return useObservableValuePromise(eventObservablePromise); } -function parsePowerLevels(eventObj?: JoinRoomStateObject): IPowerLevels { +export function getPowerLevels(eventObj?: JoinRoomStateObject): IPowerLevels { const content: IPowerLevels | undefined = eventObj?.event.content; return !content ? DEFAULT_POWER_LEVELS : fillMissingPowers(content); } export function usePowerLevels(eventObj: JoinRoomStateObject | undefined): IPowerLevels { - const powerLevels: IPowerLevels = useMemo(() => parsePowerLevels(eventObj), [eventObj]); + const powerLevels: IPowerLevels = useMemo(() => getPowerLevels(eventObj), [eventObj]); return powerLevels; } @@ -114,12 +115,11 @@ export const useRoomsPowerLevels = (rooms: string[]): Map const roomToPowerLevels: Map = useMemo(() => { const rToP = new Map(); - console.log(plEventsResult.value?.find((v) => v.roomId === '!KqyBSJuhpIHbWaROMB:kde.org')); rooms.forEach((roomId) => { const evtObj = plEventsResult.value?.find( (o) => o.roomId === roomId && o.event.state_key === '' ); - rToP.set(roomId, parsePowerLevels(evtObj)); + rToP.set(roomId, getPowerLevels(evtObj)); }); return rToP; From a67f855e09d5e8355dcad1bd1ad585c14d9e040a Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:59:00 +0530 Subject: [PATCH 007/158] render create room modal --- src/app/pages/Router.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 0fec4863..1ec49ad5 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -135,11 +135,11 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > - {/* - + {/* + */} - + {/* */} From baf281bca98eec76d49fa7b146d949a2b4c4a1fb Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:01:07 +0530 Subject: [PATCH 008/158] fix restricted room wrong allow type --- src/app/components/create-room/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index 84e6c75a..1c91d0bd 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -52,7 +52,7 @@ const createRoomJoinRulesState = ( join_rule: knock ? 'knock_restricted' : 'restricted', allow: [ { - type: [StateEventType.RoomMember], + type: 'm.room_membership', room_id: parentId, }, ], From 23537b7a769bcf7f8dbac94d61a20343d0a2c43b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:33:11 +0530 Subject: [PATCH 009/158] move room user profile to new sdk --- .../components/UserRoomProfileRenderer.tsx | 11 +- src/app/components/presence/Presence.tsx | 11 +- .../components/user-profile/CreatorChip.tsx | 20 +- src/app/components/user-profile/PowerChip.tsx | 81 ++++---- src/app/components/user-profile/UserChips.tsx | 116 ++++++----- .../user-profile/UserModeration.tsx | 84 +++++--- .../user-profile/UserRoomProfile.tsx | 183 ++++++++++-------- src/app/hooks/useMemberPowerCompare.ts | 9 +- src/app/hooks/useMutualRooms.ts | 60 +++--- src/app/hooks/usePowerLevels.ts | 6 +- src/app/hooks/useUserPresence.ts | 59 ++---- src/app/pages/Router.tsx | 4 +- 12 files changed, 360 insertions(+), 284 deletions(-) diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx index ca7aa837..45b10dd9 100644 --- a/src/app/components/UserRoomProfileRenderer.tsx +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -4,22 +4,15 @@ import FocusTrap from 'focus-trap-react'; import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile'; import { UserRoomProfile } from './user-profile'; import { UserRoomProfileState } from '../state/userRoomProfile'; -import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom'; import { stopPropagation } from '../utils/keyboard'; import { SpaceProvider } from '../hooks/useSpace'; import { RoomProvider } from '../hooks/useRoom'; function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { const { roomId, spaceId, userId, cords, position } = state; - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); - const room = getRoom(roomId); - const space = spaceId ? getRoom(spaceId) : undefined; const close = useCloseUserRoomProfile(); - if (!room) return null; - return ( - - + + diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 108852f2..a3253673 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -11,13 +11,14 @@ import { toRem, } from 'folds'; import React, { ReactNode, useId } from 'react'; +import { Presence } from 'neurone/lib/api/types'; import * as css from './styles.css'; -import { Presence, usePresenceLabel } from '../../hooks/useUserPresence'; +import { usePresenceLabel } from '../../hooks/useUserPresence'; const PresenceToColor: Record = { - [Presence.Online]: 'Success', - [Presence.Unavailable]: 'Warning', - [Presence.Offline]: 'Secondary', + online: 'Success', + unavailable: 'Warning', + offline: 'Secondary', }; type PresenceBadgeProps = { @@ -51,7 +52,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { ref={triggerRef} size={size} variant={PresenceToColor[presence]} - fill={presence === Presence.Offline ? 'Soft' : 'Solid'} + fill={presence === 'offline' ? 'Soft' : 'Solid'} radii="Pill" /> )} diff --git a/src/app/components/user-profile/CreatorChip.tsx b/src/app/components/user-profile/CreatorChip.tsx index f59d2ae5..c1fdafb0 100644 --- a/src/app/components/user-profile/CreatorChip.tsx +++ b/src/app/components/user-profile/CreatorChip.tsx @@ -5,7 +5,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { PowerColorBadge, PowerIcon } from '../power'; import { getPowerTagIconSrc } from '../../hooks/useMemberPowerTag'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { stopPropagation } from '../../utils/keyboard'; import { useRoom } from '../../hooks/useRoom'; @@ -14,18 +13,19 @@ import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { SpaceSettingsPage } from '../../state/spaceSettings'; import { RoomSettingsPage } from '../../state/roomSettings'; +import { useClient } from '../../hooks/useClient'; export function CreatorChip() { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const room = useRoom(); - const space = useSpaceOptionally(); + const roomId = useRoom(); + const spaceId = useSpaceOptionally(); const openRoomSettings = useOpenRoomSettings(); const openSpaceSettings = useOpenSpaceSettings(); const [cords, setCords] = useState(); const tag = useRoomCreatorsTag(); - const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon); + const tagIconSrc = tag.icon && getPowerTagIconSrc(ct, useAuthentication, tag.icon); const open: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); @@ -58,14 +58,10 @@ export function CreatorChip() { size="300" radii="300" onClick={() => { - if (room.isSpaceRoom()) { - openSpaceSettings( - room.roomId, - space?.roomId, - SpaceSettingsPage.PermissionsPage - ); + if (ct.spaceRooms.read.has(roomId)) { + openSpaceSettings(roomId, spaceId, SpaceSettingsPage.PermissionsPage); } else { - openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage); + openRoomSettings(roomId, spaceId, RoomSettingsPage.PermissionsPage); } close(); }} diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a6758300..3a52754a 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -23,13 +23,20 @@ import { import React, { MouseEventHandler, useCallback, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; +import produce from 'immer'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { PowerColorBadge, PowerIcon } from '../power'; -import { useGetMemberPowerLevel, usePowerLevels } from '../../hooks/usePowerLevels'; -import { getPowers, usePowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { + applyPermissionPower, + PermissionLocation, + useGetMemberPowerLevel, + usePowerLevelsContext, +} from '../../hooks/usePowerLevels'; +import { getPowers, usePowerLevelTagsContext } from '../../hooks/usePowerLevelTags'; import { stopPropagation } from '../../utils/keyboard'; -import { StateEvent } from '../../../types/matrix/room'; +import { StateEvent, StateEventType } from '../../../types/matrix/room'; import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { RoomSettingsPage } from '../../state/roomSettings'; import { useRoom } from '../../hooks/useRoom'; @@ -40,9 +47,10 @@ import { SpaceSettingsPage } from '../../state/spaceSettings'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { BreakWord } from '../../styles/Text.css'; import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; -import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; +import { useClient } from '../../hooks/useClient'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; type SelfDemoteAlertProps = { power: number; @@ -145,30 +153,29 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) } export function PowerChip({ userId }: { userId: string }) { - const mx = useMatrixClient(); - const room = useRoom(); - const space = useSpaceOptionally(); + const ct = useClient(); + const roomId = useRoom(); + const spaceId = useSpaceOptionally(); const useAuthentication = useMediaAuthentication(); const openRoomSettings = useOpenRoomSettings(); const openSpaceSettings = useOpenSpaceSettings(); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - + const creators = useRoomCreators(roomId); + const powerLevels = usePowerLevelsContext(); const permissions = useRoomPermissions(creators, powerLevels); const getMemberPowerLevel = useGetMemberPowerLevel(powerLevels); const { hasMorePower } = useMemberPowerCompare(creators, powerLevels); - const powerLevelTags = usePowerLevelTags(room, powerLevels); - const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const powerLevelTags = usePowerLevelTagsContext(); + const getMemberPowerTag = useGetMemberPowerTag(creators, powerLevels); - const myUserId = mx.getSafeUserId(); + const myUserId = ct.session.userId; const canChangePowers = permissions.stateEvent(StateEvent.RoomPowerLevels, myUserId) && (myUserId === userId ? true : hasMorePower(myUserId, userId)); const tag = getMemberPowerTag(userId); - const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon); + const tagIconSrc = tag.icon && getPowerTagIconSrc(ct, useAuthentication, tag.icon); const [cords, setCords] = useState(); @@ -178,12 +185,28 @@ export function PowerChip({ userId }: { userId: string }) { const close = () => setCords(undefined); - const [powerState, changePower] = useAsyncCallback( + const [powerState, changePower] = useAsyncCallback( useCallback( async (power: number) => { - await mx.setPowerLevel(room.roomId, userId, power); + const editedPowerLevels = produce(powerLevels, (draftPowerLevels) => { + const location: PermissionLocation = { + user: true, + key: userId, + }; + applyPermissionPower(draftPowerLevels, location, power); + + return draftPowerLevels; + }); + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomPowerLevels, stateKey: '' }, + body: editedPowerLevels, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(printMatrixError(err)); }, - [mx, userId, room] + [ct, userId, roomId, powerLevels] ) ); const changing = powerState.status === AsyncStatus.Loading; @@ -196,11 +219,11 @@ export function PowerChip({ userId }: { userId: string }) { if (!canChangePowers) return; if (power === getMemberPowerLevel(userId)) return; - if (userId === mx.getSafeUserId()) { + if (userId === myUserId) { setSelfDemote(power); return; } - if (!creators.has(myUserId) && power === getMemberPowerLevel(myUserId)) { + if (!creators?.has(myUserId) && power === getMemberPowerLevel(myUserId)) { setSharedPower(power); return; } @@ -252,10 +275,10 @@ export function PowerChip({ userId }: { userId: string }) { {getPowers(powerLevelTags).map((power) => { const powerTag = powerLevelTags[power]; const powerTagIconSrc = - powerTag.icon && getPowerTagIconSrc(mx, useAuthentication, powerTag.icon); + powerTag.icon && getPowerTagIconSrc(ct, useAuthentication, powerTag.icon); const selected = getMemberPowerLevel(userId) === power; - const canAssignPower = creators.has(myUserId) + const canAssignPower = creators?.has(myUserId) ? true : power <= getMemberPowerLevel(myUserId); @@ -293,18 +316,10 @@ export function PowerChip({ userId }: { userId: string }) { size="300" radii="300" onClick={() => { - if (room.isSpaceRoom()) { - openSpaceSettings( - room.roomId, - space?.roomId, - SpaceSettingsPage.PermissionsPage - ); + if (ct.spaceRooms.read.has(roomId)) { + openSpaceSettings(roomId, spaceId, SpaceSettingsPage.PermissionsPage); } else { - openRoomSettings( - room.roomId, - space?.roomId, - RoomSettingsPage.PermissionsPage - ); + openRoomSettings(roomId, spaceId, RoomSettingsPage.PermissionsPage); } close(); }} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index c65e2cef..296c52b3 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -2,7 +2,6 @@ import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; -import { Room } from 'matrix-js-sdk'; import { PopOut, Menu, @@ -20,7 +19,7 @@ import { Scroll, Avatar, } from 'folds'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; import { getMxIdServer } from '../../utils/matrix'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { stopPropagation } from '../../utils/keyboard'; @@ -28,27 +27,27 @@ import { copyToClipboard } from '../../utils/dom'; import { getExploreServerPath } from '../../pages/pathUtils'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; -import { - useMutualRooms, - useMutualRoomsSupport, - useUnstableMutualRoomsSupport, -} from '../../hooks/useMutualRooms'; +import { useMutualRooms } from '../../hooks/useMutualRooms'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useDirectRooms } from '../../pages/client/direct/useDirectRooms'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { RoomAvatar, RoomIcon } from '../room-avatar'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { nameInitials } from '../../utils/common'; import { getMatrixToUser } from '../../plugins/matrix-to'; import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; -import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { getIgnoredUsersContent, useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; +import { useClient } from '../../hooks/useClient'; +import { AccountDataType } from '../../../types/matrix/accountData'; +import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; +import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; +import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../../client/mxcToHttp'; export function ServerChip({ server }: { server: string }) { - const mx = useMatrixClient(); - const myServer = getMxIdServer(mx.getSafeUserId()); + const ct = useClient(); + const myServer = getMxIdServer(ct.session.userId); const navigate = useNavigate(); const closeProfile = useCloseUserRoomProfile(); const [copied, setCopied] = useTimeoutToggle(); @@ -229,24 +228,26 @@ export function ShareChip({ userId }: { userId: string }) { } type MutualRoomsData = { - rooms: Room[]; - spaces: Room[]; - directs: Room[]; + rooms: string[]; + spaces: string[]; + directs: string[]; }; +const EMPTY_LIST: string[] = []; export function MutualRoomsChip({ userId }: { userId: string }) { - const mx = useMatrixClient(); - const mutualRoomSupported = useMutualRoomsSupport(); - const mutualRoomUnstable = useUnstableMutualRoomsSupport(); + const ct = useClient(); const mutualRoomsState = useMutualRooms(userId); - console.log(mutualRoomSupported, mutualRoomsState); + const { navigateRoom, navigateSpace } = useRoomNavigate(); const closeUserRoomProfile = useCloseUserRoomProfile(); const directs = useDirectRooms(); const useAuthentication = useMediaAuthentication(); - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes( + mutualRoomsState.status === AsyncStatus.Success ? mutualRoomsState.data : EMPTY_LIST + ); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); const [cords, setCords] = useState(); @@ -264,36 +265,33 @@ export function MutualRoomsChip({ userId }: { userId: string }) { }; if (mutualRoomsState.status === AsyncStatus.Success) { - const mutualRooms = mutualRoomsState.data - .sort(factoryRoomIdByAtoZ(mx)) - .map(getRoom) - .filter((room) => !!room); - mutualRooms.forEach((room) => { - if (room.isSpaceRoom()) { - data.spaces.push(room); + const mutualRooms = mutualRoomsState.data.sort(factoryRoomIdByAtoZ(getRoomName)); + + mutualRooms.forEach((roomId) => { + if (ct.spaceRooms.read.has(roomId)) { + data.spaces.push(roomId); return; } - if (directs.includes(room.roomId)) { - data.directs.push(room); + if (directs.includes(roomId)) { + data.directs.push(roomId); return; } - data.rooms.push(room); + data.rooms.push(roomId); }); } return data; - }, [mutualRoomsState, getRoom, directs, mx]); + }, [mutualRoomsState, getRoomName, directs, ct]); - if ( - userId === mx.getSafeUserId() || - (!mutualRoomSupported && !mutualRoomUnstable) || - mutualRoomsState.status === AsyncStatus.Error - ) { + if (userId === ct.session.userId || mutualRoomsState.status === AsyncStatus.Error) { return null; } - const renderItem = (room: Room) => { - const { roomId } = room; + const renderItem = (roomId: string) => { const dm = directs.includes(roomId); + const space = ct.spaceRooms.read.has(roomId); + + const avatarUrl = getRoomAvatar(roomId); + const roomName = getRoomName(roomId) ?? 'Unknown'; return ( { - if (room.isSpaceRoom()) { + if (space) { navigateSpace(roomId); } else { navigateRoom(roomId); @@ -313,29 +311,37 @@ export function MutualRoomsChip({ userId }: { userId: string }) { }} before={ - {dm || room.isSpaceRoom() ? ( + {dm || space ? ( ( - {nameInitials(room.name)} + {nameInitials(roomName)} )} /> ) : ( - + )} } > - {room.name} + {roomName} ); @@ -443,7 +449,7 @@ export function IgnoredUserAlert() { } export function OptionsChip({ userId }: { userId: string }) { - const mx = useMatrixClient(); + const ct = useClient(); const [cords, setCords] = useState(); const open: MouseEventHandler = (evt) => { @@ -459,8 +465,14 @@ export function OptionsChip({ userId }: { userId: string }) { useCallback(async () => { const users = ignoredUsers.filter((u) => u !== userId); if (!ignored) users.push(userId); - await mx.setIgnoredUsers(users); - }, [mx, ignoredUsers, userId, ignored]) + + return ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, + body: getIgnoredUsersContent(users), + }) + ); + }, [ct, ignoredUsers, userId, ignored]) ); const ignoring = ignoreState.status === AsyncStatus.Loading; diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx index 814bb5ba..0bc4d601 100644 --- a/src/app/components/user-profile/UserModeration.tsx +++ b/src/app/components/user-profile/UserModeration.tsx @@ -1,14 +1,16 @@ import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds'; import React, { useCallback, useRef } from 'react'; +import { postBan, postInvite, postKick, postUnban } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { useRoom } from '../../hooks/useRoom'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { timeDayMonYear, timeHourMinute } from '../../utils/time'; +import { useClient } from '../../hooks/useClient'; type UserKickAlertProps = { reason?: string; @@ -64,18 +66,28 @@ type UserBanAlertProps = { ts?: number; }; export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; - const [unbanState, unban] = useAsyncCallback( + const [unbanState, unban] = useAsyncCallback( useCallback(async () => { - await mx.unban(room.roomId, userId); - }, [mx, room, userId]) + const resp = await ct.requestAuthJSON( + postUnban({ + pathParams: { roomId }, + body: { user_id: userId }, + }) + ); + + const [err] = await parseMatrixResp(resp); + if (err) { + throw new Error(err.data.error ?? 'Unknown error.'); + } + }, [ct, roomId, userId]) ); const banning = unbanState.status === AsyncStatus.Loading; const error = unbanState.status === AsyncStatus.Error; @@ -139,18 +151,25 @@ type UserInviteAlertProps = { ts?: number; }; export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; - const [kickState, kick] = useAsyncCallback( + const [kickState, kick] = useAsyncCallback( useCallback(async () => { - await mx.kick(room.roomId, userId); - }, [mx, room, userId]) + const resp = await ct.requestAuthJSON( + postKick({ + pathParams: { roomId }, + body: { user_id: userId }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error); + }, [ct, roomId, userId]) ); const kicking = kickState.status === AsyncStatus.Loading; const error = kickState.status === AsyncStatus.Error; @@ -215,8 +234,8 @@ type UserModerationProps = { canInvite: boolean; }; export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const reasonInputRef = useRef(null); const getReason = useCallback((): string | undefined => { @@ -227,22 +246,43 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer return reason; }, []); - const [kickState, kick] = useAsyncCallback( + const [kickState, kick] = useAsyncCallback( useCallback(async () => { - await mx.kick(room.roomId, userId, getReason()); - }, [mx, room, userId, getReason]) + const resp = await ct.requestAuthJSON( + postKick({ + pathParams: { roomId }, + body: { user_id: userId, reason: getReason() }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error); + }, [ct, roomId, userId, getReason]) ); - const [banState, ban] = useAsyncCallback( + const [banState, ban] = useAsyncCallback( useCallback(async () => { - await mx.ban(room.roomId, userId, getReason()); - }, [mx, room, userId, getReason]) + const resp = await ct.requestAuthJSON( + postBan({ + pathParams: { roomId }, + body: { user_id: userId, reason: getReason() }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error); + }, [ct, roomId, userId, getReason]) ); - const [inviteState, invite] = useAsyncCallback( + const [inviteState, invite] = useAsyncCallback( useCallback(async () => { - await mx.invite(room.roomId, userId, getReason()); - }, [mx, room, userId, getReason]) + const resp = await ct.requestAuthJSON( + postInvite({ + pathParams: { roomId }, + body: { user_id: userId, reason: getReason() }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error); + }, [ct, roomId, userId, getReason]) ); const disabled = diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78d201ec..b96e7906 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,12 +1,15 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds'; import React from 'react'; import { useNavigate } from 'react-router-dom'; +import { ObjectReader } from 'neurone/lib/utils'; import { UserHero, UserHeroName } from './UserHero'; -import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; -import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdServer } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { usePowerLevels } from '../../hooks/usePowerLevels'; +import { + PowerLevelsContextProvider, + usePowerLevels, + usePowerLevelsState, +} from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; @@ -14,7 +17,6 @@ import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { PowerChip } from './PowerChip'; import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; -import { useMembership } from '../../hooks/useMembership'; import { Membership } from '../../../types/matrix/room'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; @@ -22,40 +24,54 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; import { CreatorChip } from './CreatorChip'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; +import { useRoomMember } from '../../hooks/useRoomMember'; +import { mxcToHttp } from '../../../client/mxcToHttp'; +import { useClient } from '../../hooks/useClient'; +import { + PowerLevelTagsContextProvider, + usePowerLevelTags, + usePowerLevelTagsState, +} from '../../hooks/usePowerLevelTags'; type UserRoomProfileProps = { userId: string; }; export function UserRoomProfile({ userId }: UserRoomProfileProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const navigate = useNavigate(); const closeUserRoomProfile = useCloseUserRoomProfile(); const ignoredUsers = useIgnoredUsers(); const ignored = ignoredUsers.includes(userId); - const room = useRoom(); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); + const roomId = useRoom(); + const stateObjResult = usePowerLevelsState(roomId); + const powerLevels = usePowerLevels(stateObjResult.value); + const creators = useRoomCreators(roomId); + const powerLevelTagsResult = usePowerLevelTagsState(roomId); + const powerLevelTags = usePowerLevelTags(powerLevels, powerLevelTagsResult.value); const permissions = useRoomPermissions(creators, powerLevels); const { hasMorePower } = useMemberPowerCompare(creators, powerLevels); - const myUserId = mx.getSafeUserId(); - const creator = creators.has(userId); + const myUserId = ct.session.userId; + const creator = creators?.has(userId); const canKickUser = permissions.action('kick', myUserId) && hasMorePower(myUserId, userId); const canBanUser = permissions.action('ban', myUserId) && hasMorePower(myUserId, userId); const canUnban = permissions.action('ban', myUserId); const canInvite = permissions.action('invite', myUserId); - const member = room.getMember(userId); - const membership = useMembership(room, userId); + const { memberEventResult, getMembership, getName, getAvatar } = useRoomMember(roomId, userId); + + const membership = getMembership(); const server = getMxIdServer(userId); - const displayName = getMemberDisplayName(room, userId); - const avatarMxc = getMemberAvatarMxc(room, userId); - const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const displayName = getName(); + const avatarMxc = getAvatar(); + const avatarUrl = avatarMxc + ? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url() + : undefined; const presence = useUserPresence(userId); @@ -68,75 +84,78 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { }; return ( - - - - - - - {userId !== myUserId && ( - - + + + + + + + + + {userId !== myUserId && ( + + + + )} + + {server && } + + {creator ? : } + {userId !== myUserId && } + {userId !== myUserId && } + + + {ignored && } + {memberEventResult.value && membership === Membership.Ban && ( + )} - - - {server && } - - {creator ? : } - {userId !== myUserId && } - {userId !== myUserId && } + {memberEventResult.value && + membership === Membership.Leave && + memberEventResult.value.event.sender !== userId && ( + + )} + {memberEventResult.value && membership === Membership.Invite && ( + + )} + - {ignored && } - {member && membership === Membership.Ban && ( - - )} - {member && - membership === Membership.Leave && - member.events.member && - member.events.member.getSender() !== userId && ( - - )} - {member && membership === Membership.Invite && ( - - )} - - - + + ); } diff --git a/src/app/hooks/useMemberPowerCompare.ts b/src/app/hooks/useMemberPowerCompare.ts index 72163edf..14f70dd3 100644 --- a/src/app/hooks/useMemberPowerCompare.ts +++ b/src/app/hooks/useMemberPowerCompare.ts @@ -1,15 +1,18 @@ import { useCallback } from 'react'; import { IPowerLevels, readPowerLevel } from './usePowerLevels'; -export const useMemberPowerCompare = (creators: Set, powerLevels: IPowerLevels) => { +export const useMemberPowerCompare = ( + creators: Set | undefined, + powerLevels: IPowerLevels +) => { /** * returns `true` if `userIdA` has more power than `userIdB` * returns `false` otherwise */ const hasMorePower = useCallback( (userIdA: string, userIdB: string): boolean => { - const aIsCreator = creators.has(userIdA); - const bIsCreator = creators.has(userIdB); + const aIsCreator = creators?.has(userIdA); + const bIsCreator = creators?.has(userIdB); if (aIsCreator && bIsCreator) return false; if (aIsCreator) return true; if (bIsCreator) return false; diff --git a/src/app/hooks/useMutualRooms.ts b/src/app/hooks/useMutualRooms.ts index 9a51e21d..893449ff 100644 --- a/src/app/hooks/useMutualRooms.ts +++ b/src/app/hooks/useMutualRooms.ts @@ -1,16 +1,15 @@ import { useCallback } from 'react'; -import { MatrixClient, Method } from 'matrix-js-sdk'; -import { useMatrixClient } from './useMatrixClient'; +import { withSearchParams } from 'neurone/lib/api/utils'; +import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { ClientContext } from '../../client/context'; import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback'; import { useUnstableFeatureSupport, useSpecVersions } from './useSpecVersions'; +import { useClient } from './useClient'; export const useUnstableMutualRoomsSupport = (): boolean => { const checkSupport = useUnstableFeatureSupport(); - const supported = - checkSupport('uk.half-shot.msc2666') || - checkSupport('uk.half-shot.msc2666.mutual_rooms') || - checkSupport('uk.half-shot.msc2666.query_mutual_rooms'); + const supported = checkSupport('uk.half-shot.msc2666.query_mutual_rooms'); return !!supported; }; @@ -31,24 +30,33 @@ type MutualRoomsOK = { count: number; }; -const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise => { +const fetchAllMutualRooms = async ( + ct: ClientContext, + userId: string, + unstable?: boolean +): Promise => { const mutualRooms: Set = new Set(); let nextBatch: string | undefined; do { // eslint-disable-next-line no-await-in-loop - const result = await mx.http.authedRequest( - Method.Get, - '/mutual_rooms', - { - user_id: userId, - from: nextBatch, - }, - undefined, - { - prefix: '/_matrix/client/v1', - } - ); + const resp = await ct.requestAuthJSON({ + method: 'GET', + path: withSearchParams( + unstable + ? '/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms' + : '/_matrix/client/v1/mutual_rooms', + { + user_id: userId, + from: nextBatch, + } + ), + requireAuthentication: true, + }); + // eslint-disable-next-line no-await-in-loop + const [err, result] = await parseMatrixResp(resp); + if (err) throw new Error(printMatrixError(err)); + result.joined.forEach((r) => mutualRooms.add(r)); nextBatch = result.next_batch; } while (typeof nextBatch === 'string'); @@ -57,17 +65,19 @@ const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise => { - const mx = useMatrixClient(); + const ct = useClient(); const unstableSupport = useUnstableMutualRoomsSupport(); const support = useMutualRoomsSupport(); const [mutualRoomsState] = useAsyncCallbackValue( - useCallback(() => { - if (support) return fetchAllMutualRooms(mx, userId); - if (unstableSupport) return mx._unstable_getSharedRooms(userId); - return Promise.resolve([]); - }, [mx, userId, unstableSupport, support]) + useCallback(async () => { + if (support) return fetchAllMutualRooms(ct, userId); + if (unstableSupport) return fetchAllMutualRooms(ct, userId, true); + + const memberships = await ct.storage.joinRoomMember.read.queryIndex('userId', userId); + return memberships.map((membership) => membership.roomId); + }, [ct, userId, unstableSupport, support]) ); return mutualRoomsState; diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index 0516bb6a..ef549c1f 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -6,7 +6,6 @@ import { StateEvent, StateEventType } from '../../types/matrix/room'; import { getJoinRoomStateId, JoinRoomStateObject } from '../../client/storage/collections'; import { useClient } from './useClient'; import { useObservableValuePromise } from './useObservableValue'; -import { ClientContext } from '../../client/context'; export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical'; export type PowerLevelNotificationsAction = 'room'; @@ -243,8 +242,13 @@ export const applyPermissionPower = ( if (typeof location.key === 'string') { const users = powerLevels.users ?? {}; users[location.key] = power; + + if (powerLevels.users_default === power) { + delete users[location.key]; + } // eslint-disable-next-line no-param-reassign powerLevels.users = users; + return powerLevels; } // eslint-disable-next-line no-param-reassign diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 5137950e..537efb1a 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,12 +1,8 @@ -import { useEffect, useMemo, useState } from 'react'; -import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; -import { useMatrixClient } from './useMatrixClient'; - -export enum Presence { - Online = 'online', - Unavailable = 'unavailable', - Offline = 'offline', -} +import { useMemo } from 'react'; +import { Presence } from 'neurone/lib/api/types'; +import { useClient } from './useClient'; +import { useObservableValuePromise } from './useObservableValue'; +import { PresenceObject } from '../../client/storage/collections'; export type UserPresence = { presence: Presence; @@ -15,44 +11,31 @@ export type UserPresence = { lastActiveTs?: number; }; -const getUserPresence = (user: User): UserPresence => ({ - presence: user.presence as Presence, - status: user.presenceStatusMsg, - active: user.currentlyActive, - lastActiveTs: user.getLastActiveTs(), +const getUserPresence = (user?: PresenceObject): UserPresence => ({ + presence: user?.presence ?? 'offline', + status: user?.content.status_msg ?? undefined, + active: user?.content.currently_active ?? false, + lastActiveTs: user?.content.last_active_ago, }); export const useUserPresence = (userId: string): UserPresence | undefined => { - const mx = useMatrixClient(); - const user = mx.getUser(userId); + const ct = useClient(); + const presencePromise = useMemo( + () => ct.storage.presence.observableRead.get(userId), + [ct, userId] + ); + const presenceResult = useObservableValuePromise(presencePromise); + const presenceObj = presenceResult.value; - const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); - - useEffect(() => { - const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { - if (u.userId === user?.userId) { - setPresence(getUserPresence(user)); - } - }; - user?.on(UserEvent.Presence, updatePresence); - user?.on(UserEvent.CurrentlyActive, updatePresence); - user?.on(UserEvent.LastPresenceTs, updatePresence); - return () => { - user?.removeListener(UserEvent.Presence, updatePresence); - user?.removeListener(UserEvent.CurrentlyActive, updatePresence); - user?.removeListener(UserEvent.LastPresenceTs, updatePresence); - }; - }, [user]); - - return presence; + return getUserPresence(presenceObj); }; export const usePresenceLabel = (): Record => useMemo( () => ({ - [Presence.Online]: 'Active', - [Presence.Unavailable]: 'Busy', - [Presence.Offline]: 'Away', + online: 'Active', + unavailable: 'Busy', + offline: 'Away', }), [] ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 1ec49ad5..afe3c4ae 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -135,8 +135,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > - {/* - */} + {/**/} + {/* From c7d4c56a5c1f3b68ba69782dc6f826b27ed74278 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:58:40 +0530 Subject: [PATCH 010/158] Create useRoomMember hook --- src/app/hooks/useRoomMember.ts | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/app/hooks/useRoomMember.ts diff --git a/src/app/hooks/useRoomMember.ts b/src/app/hooks/useRoomMember.ts new file mode 100644 index 00000000..56da8f63 --- /dev/null +++ b/src/app/hooks/useRoomMember.ts @@ -0,0 +1,50 @@ +import { useCallback, useMemo } from 'react'; +import { useClient } from './useClient'; +import { useObservableValuePromise } from './useObservableValue'; +import { getMxIdLocalPart } from '../utils/matrix'; +import { + roomMemberContentAvatarUrl, + roomMemberContentDisplayname, + roomMemberContentMembership, +} from '../utils/room'; +import { getJoinRoomMemberId } from '../../client/storage/collections'; + +export const useRoomMember = (roomId: string, userId: string) => { + const ct = useClient(); + const memberEventPromise = useMemo( + () => ct.storage.joinRoomMember.observableRead.get(getJoinRoomMemberId(roomId, userId)), + [ct, roomId, userId] + ); + + const memberEventResult = useObservableValuePromise(memberEventPromise); + + const member = memberEventResult.value; + + const getName = useCallback(() => { + const content = member?.event.content; + const displayName = content ? roomMemberContentDisplayname(content) : undefined; + + return displayName || getMxIdLocalPart(userId) || userId; + }, [member, userId]); + + const getAvatar = useCallback(() => { + const content = member?.event.content; + if (!content) return undefined; + + return roomMemberContentAvatarUrl(content); + }, [member]); + + const getMembership = useCallback(() => { + const content = member?.event.content; + if (!content) return undefined; + + return roomMemberContentMembership(content); + }, [member]); + + return { + memberEventResult, + getName, + getAvatar, + getMembership, + }; +}; From 3fbf379d97b640e9da62e83a5d46dff671a4df55 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:59:05 +0530 Subject: [PATCH 011/158] fix presence appear when doesn't exist --- src/app/hooks/useUserPresence.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 537efb1a..4edfeeb1 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -11,11 +11,11 @@ export type UserPresence = { lastActiveTs?: number; }; -const getUserPresence = (user?: PresenceObject): UserPresence => ({ - presence: user?.presence ?? 'offline', - status: user?.content.status_msg ?? undefined, - active: user?.content.currently_active ?? false, - lastActiveTs: user?.content.last_active_ago, +const getUserPresence = (user: PresenceObject): UserPresence => ({ + presence: user.presence ?? 'offline', + status: user.content.status_msg ?? undefined, + active: user.content.currently_active ?? false, + lastActiveTs: user.content.last_active_ago, }); export const useUserPresence = (userId: string): UserPresence | undefined => { @@ -27,7 +27,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { const presenceResult = useObservableValuePromise(presencePromise); const presenceObj = presenceResult.value; - return getUserPresence(presenceObj); + return presenceObj ? getUserPresence(presenceObj) : undefined; }; export const usePresenceLabel = (): Record => From b55e66da7dfe9e9d75049143715410843c82cec1 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:59:19 +0530 Subject: [PATCH 012/158] fix room header --- src/app/features/room/Room.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 1a7e2141..fa66e8c8 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -25,6 +25,7 @@ import { useObservableValue, useObservableValueOptionally } from '../../hooks/us import { useClient } from '../../hooks/useClient'; import { ThreadView } from './thread-view'; import { TimelineViewProvider } from './hooks/useTimeline'; +import { RoomViewHeader } from './RoomViewHeader'; export function Room() { const { eventId } = useParams(); @@ -67,7 +68,12 @@ export function Room() { {timeline && ( - + + + + + + {threadTimeline ? ( From 33fc3a00a948eda8413ec6acdc4351209a1c2e93 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:16:43 +0530 Subject: [PATCH 013/158] display presence status message in profile banner --- src/app/components/presence/Presence.tsx | 5 +- src/app/components/user-profile/UserHero.tsx | 15 ++++-- .../user-profile/UserRoomProfile.tsx | 2 + src/app/components/user-profile/styles.css.ts | 52 ++++++++++++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index a3253673..380ab731 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -23,10 +23,9 @@ const PresenceToColor: Record = { type PresenceBadgeProps = { presence: Presence; - status?: string; size?: '200' | '300' | '400' | '500'; }; -export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { +export function PresenceBadge({ presence, size }: PresenceBadgeProps) { const label = usePresenceLabel(); const badgeLabelId = useId(); @@ -40,8 +39,6 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { {label[presence]} - {status && •} - {status && {status}} } diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb748..0425f950 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -30,6 +30,8 @@ type UserHeroProps = { export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { const [viewAvatar, setViewAvatar] = useState(); + const { status } = presence ?? {}; + return (
- } + badge={presence && } > + {status && ( +
+
+ + {status} + +
+
+ )} {viewAvatar && ( }> diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index b96e7906..f5b98af0 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -83,6 +83,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { navigate(withSearchParam(getDirectCreatePath(), directSearchParam)); }; + ct.storage.presence.read.getAll().then((a) => console.log(a.filter((p) => p.content.status_msg))); + return ( diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 62272a2e..7ccbd2d0 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { keyframes, style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; export const UserHeader = style({ @@ -52,3 +52,53 @@ export const UserHeroAvatarImg = style({ }, }, }); + +const StatusAnime = keyframes({ + '0%': { + transform: 'translateY(2px)', + }, + '100%': { + transform: 'translateY(0)', + }, +}); + +export const UserHeroStatus = style({ + position: 'absolute', + left: toRem(78), + bottom: toRem(58), + marginRight: config.space.S100, + + animation: `${StatusAnime} 100ms`, + + selectors: { + '&::before, &::after': { + content: '', + position: 'absolute', + left: toRem(5), + bottom: toRem(-4), + + width: toRem(10), + height: toRem(10), + + background: color.Surface.Container, + borderRadius: 999, + transform: 'rotate(45deg)', + }, + '&::after': { + left: toRem(5), + bottom: toRem(-10), + + width: toRem(5), + height: toRem(5), + }, + }, +}); + +export const UserHeroStatusContent = style({ + position: 'relative', + zIndex: 1, + padding: `${config.space.S100} ${config.space.S200}`, + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + borderRadius: config.radii.R500, +}); From 12eda423bb525539887f6798b939d2d2af455ba1 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:20:27 +0530 Subject: [PATCH 014/158] remove logs and fix avatar container corner visible --- src/app/components/user-profile/UserRoomProfile.tsx | 2 -- src/app/components/user-profile/styles.css.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index f5b98af0..b96e7906 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -83,8 +83,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { navigate(withSearchParam(getDirectCreatePath(), directSearchParam)); }; - ct.storage.presence.read.getAll().then((a) => console.log(a.filter((p) => p.content.status_msg))); - return ( diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 7ccbd2d0..16acd728 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -35,7 +35,6 @@ export const UserAvatarContainer = style({ left: config.space.S400, top: 0, transform: 'translateY(-50%)', - backgroundColor: color.Surface.Container, }); export const UserHeroAvatar = style({ outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, From a981a4d47ef04a2c906a9e5633d2b168407d6b63 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:10:06 +0530 Subject: [PATCH 015/158] fix transparent avatar background color in profile --- src/app/components/user-profile/styles.css.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 16acd728..8e9392cf 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -35,6 +35,9 @@ export const UserAvatarContainer = style({ left: config.space.S400, top: 0, transform: 'translateY(-50%)', + // backgroundcolor for transparent image + backgroundColor: color.Surface.Container, + borderRadius: config.radii.R400, }); export const UserHeroAvatar = style({ outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, From edc48f7a5ac8dcecbb0a28d018bafd5e77763042 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:50:09 +0530 Subject: [PATCH 016/158] linkify user status --- src/app/components/user-profile/UserHero.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0425f950..c69e3210 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -12,6 +12,7 @@ import { } from 'folds'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; +import Linkify from 'linkify-react'; import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; @@ -21,6 +22,7 @@ import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; +import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; type UserHeroProps = { userId: string; @@ -69,7 +71,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
- {status} + {status}
From c2f483e0a9e0aaeccc92094bb5227dc9da993d6d Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:07:24 +0530 Subject: [PATCH 017/158] show presence in member drawer --- src/app/components/presence/styles.css.ts | 2 +- src/app/features/room/MembersDrawer.tsx | 34 ++++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/app/components/presence/styles.css.ts b/src/app/components/presence/styles.css.ts index 12ea7f18..0d7e91d8 100644 --- a/src/app/components/presence/styles.css.ts +++ b/src/app/components/presence/styles.css.ts @@ -15,7 +15,7 @@ export const AvatarPresenceBadge = style({ zIndex: 1, display: 'flex', - padding: config.borderWidth.B600, + padding: '5%', backgroundColor: 'inherit', borderRadius: config.radii.Pill, overflow: 'hidden', diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 7a6ff8b2..d2be4e53 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -62,6 +62,8 @@ import { JoinRoomMemberObject } from '../../../client/storage/collections'; import { ClientContext } from '../../../client/context'; import { mxcToHttp } from '../../../client/mxcToHttp'; import { usePendingMembers } from '../../hooks/usePendingMembers'; +import { useUserPresence } from '../../hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '../../components/presence'; type MemberDrawerHeaderProps = { joinedMemberCount: number; @@ -124,6 +126,8 @@ function MemberItem({ pressed, typing, }: MemberItemProps) { + const presence = useUserPresence(member.userId); + const name = getName(member.userId); const avatarMxcUrl = getAvatar(member.userId); const avatarUrl = avatarMxcUrl @@ -143,14 +147,19 @@ function MemberItem({ radii="400" onClick={onClick} before={ - - } - /> - + } + > + + } + /> + + } after={ typing && ( @@ -160,10 +169,15 @@ function MemberItem({ ) } > - - + + {name} + {presence?.status && ( + + {presence.status} + + )} ); From 5a786984d0ec48a080ca8751960d085a094f3eb6 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:44:44 +0530 Subject: [PATCH 018/158] fix room card spinner color --- src/app/components/room-card/RoomCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 0ce5f337..2cdaf40a 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -270,7 +270,7 @@ export const RoomCard = as<'div', RoomCardProps>( variant="Secondary" size="300" disabled={joining} - before={joining && } + before={joining && } > {joining ? 'Joining' : 'Join'} From 7dae0fa7cc004bbb3a0926e1ca33291ab18f2d0b Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:41:28 +0530 Subject: [PATCH 019/158] render profile banner --- src/app/components/user-profile/UserHero.tsx | 17 +++++++++++++---- .../components/user-profile/UserRoomProfile.tsx | 7 +++++++ src/app/components/user-profile/styles.css.ts | 4 +++- src/app/hooks/useUserProfile.ts | 13 ++++++++++++- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index c69e3210..73cb7cac 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -27,9 +27,10 @@ import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; type UserHeroProps = { userId: string; avatarUrl?: string; + bannerUrl?: string; presence?: UserPresence; }; -export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { +export function UserHero({ userId, avatarUrl, bannerUrl, presence }: UserHeroProps) { const [viewAvatar, setViewAvatar] = useState(); const { status } = presence ?? {}; @@ -40,11 +41,19 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { className={css.UserHeroCoverContainer} style={{ backgroundColor: colorMXID(userId), - filter: avatarUrl ? undefined : 'brightness(50%)', + filter: bannerUrl && !status ? undefined : 'brightness(50%)', }} > - {avatarUrl && ( - {userId} + {bannerUrl && ( + {userId} )}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index b96e7906..85155a26 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -32,6 +32,7 @@ import { usePowerLevelTags, usePowerLevelTagsState, } from '../../hooks/usePowerLevelTags'; +import { useUserProfile } from '../../hooks/useUserProfile'; type UserRoomProfileProps = { userId: string; @@ -74,6 +75,11 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { : undefined; const presence = useUserPresence(userId); + const profile = useUserProfile(userId); + + const bannerUrl = profile?.bannerUrl + ? mxcToHttp(ct.session.baseUrl, profile.bannerUrl, useAuthentication).url() + : avatarUrl; const handleMessage = () => { closeUserRoomProfile(); @@ -90,6 +96,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 8e9392cf..7a5578af 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -15,13 +15,15 @@ export const UserHero = style({ }); export const UserHeroCoverContainer = style({ - height: toRem(96), + height: toRem(113), overflow: 'hidden', }); export const UserHeroCover = style({ height: '100%', width: '100%', objectFit: 'cover', +}); +export const UserHeroCoverBlurred = style({ filter: 'blur(16px)', transform: 'scale(2)', }); diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index 0f8c46dd..0dfdb82e 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -7,6 +7,8 @@ import { useClient } from './useClient'; export type UserProfile = { avatarUrl?: string; displayName?: string; + bannerUrl?: string; + timezone?: string; }; export const useUserProfile = (userId: string): UserProfile | undefined => { const ct = useClient(); @@ -18,10 +20,19 @@ export const useUserProfile = (userId: string): UserProfile | undefined => { }) ); - const [, profile] = await parseMatrixResp(response); + const [, profile] = await parseMatrixResp< + GetProfileOK & { + 'chat.commet.profile_banner'?: string; + 'm.banner_url'?: string; + 'm.tz'?: string; + } + >(response); + return { avatarUrl: profile?.avatar_url || undefined, displayName: profile?.displayname || undefined, + bannerUrl: profile?.['m.banner_url'] ?? profile?.['chat.commet.profile_banner'] ?? undefined, + timezone: profile?.['m.tz'] ?? undefined, }; }, [ct, userId]); From 9a0bba256cc3d798c734cdc45c2d5ceb01ed2c8c Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:09:26 +0530 Subject: [PATCH 020/158] display user timezone in profile --- src/app/components/user-profile/UserHero.tsx | 42 +++++++++++++++++-- .../user-profile/UserRoomProfile.tsx | 6 ++- src/app/styles/Text.css.ts | 17 ++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 73cb7cac..a486779b 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -9,6 +9,8 @@ import { OverlayBackdrop, OverlayCenter, Text, + Tooltip, + TooltipProvider, } from 'folds'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; @@ -17,12 +19,14 @@ import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; import { getMxIdLocalPart } from '../../utils/matrix'; -import { BreakWord, LineClamp3 } from '../../styles/Text.css'; +import { BreakWord, DotDivider, HoverUnderline, LineClamp3 } from '../../styles/Text.css'; import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; type UserHeroProps = { userId: string; @@ -112,15 +116,45 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence }: UserHeroPro ); } +export function UserTimezone({ timezone }: { timezone: string }) { + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + + const time = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hour12: !hour24Clock, + }).format(new Date()); + + return ( + + {timezone} + + } + > + {(triggerRef) => ( + + {time} + + )} + + ); +} + type UserHeroNameProps = { displayName?: string; userId: string; + timezone?: string; }; -export function UserHeroName({ displayName, userId }: UserHeroNameProps) { +export function UserHeroName({ displayName, userId, timezone }: UserHeroNameProps) { const username = getMxIdLocalPart(userId); return ( - + @{username} + {timezone && } + {timezone && } ); diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 85155a26..97d09772 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -102,7 +102,11 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {userId !== myUserId && ( + + )} + + - - - {userId !== myUserId && ( - - - - )} - + {server && } diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 7a5578af..7ea20903 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -70,7 +70,7 @@ export const UserHeroStatus = style({ position: 'absolute', left: toRem(78), bottom: toRem(58), - marginRight: config.space.S100, + marginRight: config.space.S400, animation: `${StatusAnime} 100ms`, @@ -106,3 +106,11 @@ export const UserHeroStatusContent = style({ color: color.Surface.OnContainer, borderRadius: config.radii.R500, }); + +export const UserActionButton = style({ + position: 'absolute', + right: config.space.S400, + zIndex: 1, + transform: 'translateY(-10%)', + outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, +}); From f167780d15560e9d50521378e3047f6dd2125c0d Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:09:04 +0530 Subject: [PATCH 022/158] update profile layout and add pronouns --- src/app/components/user-profile/UserChips.tsx | 90 +----- src/app/components/user-profile/UserHero.tsx | 60 ++-- .../user-profile/UserModeration.tsx | 304 +++++++++++++----- .../user-profile/UserRoomProfile.tsx | 69 ++-- src/app/components/user-profile/styles.css.ts | 8 - src/app/hooks/useUserProfile.ts | 29 +- 6 files changed, 319 insertions(+), 241 deletions(-) diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 296c52b3..f09507f7 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { MouseEventHandler, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -19,13 +19,12 @@ import { Scroll, Avatar, } from 'folds'; -import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; import { getMxIdServer } from '../../utils/matrix'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { stopPropagation } from '../../utils/keyboard'; import { copyToClipboard } from '../../utils/dom'; import { getExploreServerPath } from '../../pages/pathUtils'; -import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { useMutualRooms } from '../../hooks/useMutualRooms'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; @@ -35,11 +34,9 @@ import { RoomAvatar, RoomIcon } from '../room-avatar'; import { nameInitials } from '../../utils/common'; import { getMatrixToUser } from '../../plugins/matrix-to'; import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; -import { getIgnoredUsersContent, useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; import { useClient } from '../../hooks/useClient'; -import { AccountDataType } from '../../../types/matrix/accountData'; import { useRoomHeroes } from '../../hooks/storage/useRoomHeros'; import { useGetRoomName } from '../../hooks/storage/useGetRoomName'; import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar'; @@ -447,86 +444,3 @@ export function IgnoredUserAlert() { ); } - -export function OptionsChip({ userId }: { userId: string }) { - const ct = useClient(); - const [cords, setCords] = useState(); - - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const close = () => setCords(undefined); - - const ignoredUsers = useIgnoredUsers(); - const ignored = ignoredUsers.includes(userId); - - const [ignoreState, toggleIgnore] = useAsyncCallback( - useCallback(async () => { - const users = ignoredUsers.filter((u) => u !== userId); - if (!ignored) users.push(userId); - - return ct.requestAuthJSON( - putAccountData({ - pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, - body: getIgnoredUsersContent(users), - }) - ); - }, [ct, ignoredUsers, userId, ignored]) - ); - const ignoring = ignoreState.status === AsyncStatus.Loading; - - return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - -
- { - toggleIgnore(); - close(); - }} - before={ - ignoring ? ( - - ) : ( - - ) - } - disabled={ignoring} - > - {ignored ? 'Unblock User' : 'Block User'} - -
-
- - } - > - - {ignoring ? ( - - ) : ( - - )} - -
- ); -} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 6684eca0..df72b9a7 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -1,7 +1,9 @@ import React, { ReactNode, useState } from 'react'; import { Avatar, + Badge, Box, + config, Icon, Icons, Modal, @@ -9,8 +11,6 @@ import { OverlayBackdrop, OverlayCenter, Text, - Tooltip, - TooltipProvider, } from 'folds'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; @@ -19,7 +19,7 @@ import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; import { getMxIdLocalPart } from '../../utils/matrix'; -import { BreakWord, DotDivider, HoverUnderline, LineClamp3 } from '../../styles/Text.css'; +import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; @@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; +import { UserPronoun } from '../../hooks/useUserProfile'; export function UserHero({ children }: { children: ReactNode }) { return ( @@ -139,47 +140,49 @@ export function UserTimezone({ timezone }: { timezone: string }) { const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); let time: string | undefined; + let offset: string | undefined; + try { - time = new Intl.DateTimeFormat('en-US', { + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hour12: !hour24Clock, - }).format(new Date()); + timeZoneName: 'shortOffset', + }).formatToParts(new Date()); + + time = parts + .filter((p) => (p.type !== 'timeZoneName' && p.type !== 'literal') || p.type === 'literal') + .map((p) => p.value) + .join('') + .trim(); + + offset = parts.find((p) => p.type === 'timeZoneName')?.value; // e.g. "GMT+5:30" } catch (e) { console.error(e); } return ( - - {timezone} - - } - > - {(triggerRef) => ( - - {time} - - )} - + + + + {time} local time{offset ? ` · ${offset}` : ` · ${timezone}`} + + ); } type UserHeroNameProps = { displayName?: string; userId: string; - timezone?: string; + pronouns?: UserPronoun[]; }; -export function UserHeroName({ displayName, userId, timezone }: UserHeroNameProps) { +export function UserHeroName({ displayName, userId, pronouns }: UserHeroNameProps) { const username = getMxIdLocalPart(userId); return ( - + - + @{username} - {timezone && } - {timezone && } + {pronouns?.map((pronoun) => + pronoun.summary ? ( + + {pronoun.summary} + + ) : undefined + )} ); diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx index 0bc4d601..0db24f4b 100644 --- a/src/app/components/user-profile/UserModeration.tsx +++ b/src/app/components/user-profile/UserModeration.tsx @@ -1,7 +1,27 @@ -import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds'; -import React, { useCallback, useRef } from 'react'; +import { + Box, + Button, + color, + config, + Icon, + Icons, + Spinner, + Text, + Input, + RectCords, + PopOut, + Menu, + MenuItem, + toRem, + Line, + IconButton, +} from 'folds'; +import React, { MouseEventHandler, useCallback, useRef, useState } from 'react'; import { postBan, postInvite, postKick, postUnban } from 'neurone/lib/api/matrix/client/v3/rooms'; import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; +import FocusTrap from 'focus-trap-react'; +import { isKeyHotkey } from 'is-hotkey'; import { useRoom } from '../../hooks/useRoom'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; @@ -11,6 +31,9 @@ import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { timeDayMonYear, timeHourMinute } from '../../utils/time'; import { useClient } from '../../hooks/useClient'; +import { getIgnoredUsersContent, useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { AccountDataType } from '../../../types/matrix/accountData'; +import { stopPropagation } from '../../utils/keyboard'; type UserKickAlertProps = { reason?: string; @@ -293,97 +316,206 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer if (!canBan && !canKick && !canInvite) return null; return ( - - - - Moderation - + + Moderation + + {kickState.status === AsyncStatus.Error && ( + + {kickState.error.message} + + )} + {banState.status === AsyncStatus.Error && ( + + {banState.error.message} + + )} + {inviteState.status === AsyncStatus.Error && ( + + {inviteState.error.message} + + )} + + + {canInvite && ( + + ) : ( + + ) + } + onClick={invite} disabled={disabled} - /> - {kickState.status === AsyncStatus.Error && ( - - {kickState.error.message} + > + + Invite to Room - )} - {banState.status === AsyncStatus.Error && ( - - {banState.error.message} + + )} + {canKick && ( + + ) : ( + + ) + } + onClick={kick} + disabled={disabled} + > + + Kick from Room - )} - {inviteState.status === AsyncStatus.Error && ( - - {inviteState.error.message} + + )} + {canBan && ( + + ) : ( + + ) + } + onClick={ban} + disabled={disabled} + > + + Ban from Room - )} - - - {canInvite && ( - - )} - {canKick && ( - - )} - {canBan && ( - - )} - + + )} ); } + +export function OptionsButton({ userId, canBan, canInvite, canKick }: UserModerationProps) { + const ct = useClient(); + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + const ignoredUsers = useIgnoredUsers(); + const ignored = ignoredUsers.includes(userId); + + const [ignoreState, toggleIgnore] = useAsyncCallback( + useCallback(async () => { + const users = ignoredUsers.filter((u) => u !== userId); + if (!ignored) users.push(userId); + + return ct.requestAuthJSON( + putAccountData({ + pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList }, + body: getIgnoredUsersContent(users), + }) + ); + }, [ct, ignoredUsers, userId, ignored]) + ); + const ignoring = ignoreState.status === AsyncStatus.Loading; + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + + + {(canInvite || canBan || canKick) && ( + <> +
+ +
+ + + )} + + { + toggleIgnore(); + close(); + }} + after={ + ignoring ? ( + + ) : ( + + ) + } + disabled={ignoring} + > + + {ignored ? 'Unblock User' : 'Block User'} + + + +
+
+ + } + > + + + +
+ ); +} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index c7a61ca0..9b4fc755 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -2,7 +2,7 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds'; import React from 'react'; import { useNavigate } from 'react-router-dom'; import { ObjectReader } from 'neurone/lib/utils'; -import { UserHero, UserHeroAvatar, UserHeroCover, UserHeroName } from './UserHero'; +import { UserHero, UserHeroAvatar, UserHeroCover, UserHeroName, UserTimezone } from './UserHero'; import { getMxIdServer } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { @@ -12,10 +12,10 @@ import { } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; -import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { IgnoredUserAlert, MutualRoomsChip, ServerChip, ShareChip } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { PowerChip } from './PowerChip'; -import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; +import { UserInviteAlert, UserBanAlert, UserKickAlert, OptionsButton } from './UserModeration'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { Membership } from '../../../types/matrix/room'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -33,7 +33,6 @@ import { usePowerLevelTagsState, } from '../../hooks/usePowerLevelTags'; import { useUserProfile } from '../../hooks/useUserProfile'; -import { UserActionButton } from './styles.css'; type UserRoomProfileProps = { userId: string; @@ -101,21 +100,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { bannerUrl={bannerUrl} hasStatus={!!presence?.status} /> - {userId !== myUserId && ( - - - - )} - + + + {userId !== myUserId && ( + + )} + + {profile?.timezone && } + + now hierarchy does not have room summary info we need to load it when we will be + rendering it + {server && } {creator ? : } {userId !== myUserId && } - {userId !== myUserId && } {ignored && } @@ -165,12 +163,21 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { ts={memberEventResult.value.event.origin_server_ts} /> )} - + {userId !== myUserId && ( + + + + )}
diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 7ea20903..c254ba42 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -106,11 +106,3 @@ export const UserHeroStatusContent = style({ color: color.Surface.OnContainer, borderRadius: config.radii.R500, }); - -export const UserActionButton = style({ - position: 'absolute', - right: config.space.S400, - zIndex: 1, - transform: 'translateY(-10%)', - outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, -}); diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index 0dfdb82e..97948e63 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -2,13 +2,27 @@ import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getProfile, GetProfileOK } from 'neurone/lib/api/matrix/client/v3/profile'; import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { ObjectReader } from 'neurone/lib/utils'; import { useClient } from './useClient'; +type PronounResponse = { + grammatical_gender?: string; + language?: string; + summary?: string; +}; + +export type UserPronoun = { + grammaticalGender?: string; + language?: string; + summary?: string; +}; + export type UserProfile = { avatarUrl?: string; displayName?: string; bannerUrl?: string; timezone?: string; + pronouns?: UserPronoun[]; }; export const useUserProfile = (userId: string): UserProfile | undefined => { const ct = useClient(); @@ -25,14 +39,25 @@ export const useUserProfile = (userId: string): UserProfile | undefined => { 'chat.commet.profile_banner'?: string; 'm.banner_url'?: string; 'm.tz'?: string; + 'io.fsky.nyx.pronouns': PronounResponse[]; + 'm.pronouns': PronounResponse[]; } >(response); + const pronouns = profile?.['m.pronouns'] ?? profile?.['io.fsky.nyx.pronouns']; + return { avatarUrl: profile?.avatar_url || undefined, displayName: profile?.displayname || undefined, - bannerUrl: profile?.['m.banner_url'] ?? profile?.['chat.commet.profile_banner'] ?? undefined, - timezone: profile?.['m.tz'] ?? undefined, + bannerUrl: + ObjectReader.string(profile, 'm.banner_url') ?? + ObjectReader.string(profile, 'chat.commet.profile_banner'), + timezone: ObjectReader.string(profile, 'm.tz'), + pronouns: pronouns?.map((p) => ({ + grammaticalGender: ObjectReader.string(p, 'grammatical_gender'), + language: ObjectReader.string(p, 'language'), + summary: ObjectReader.string(p, 'summary'), + })), }; }, [ct, userId]); From 758d328a3946258642397713f8589fa2925f4aaa Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:40:28 +0530 Subject: [PATCH 023/158] add user biography --- src/app/components/user-profile/UserHero.tsx | 16 +++++-- .../user-profile/UserRoomProfile.tsx | 14 ++++-- src/app/hooks/useUserProfile.ts | 48 ++++++++++++------- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index df72b9a7..232bdb0b 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -27,7 +27,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { UserPronoun } from '../../hooks/useUserProfile'; +import { UserBiography, UserPronoun } from '../../hooks/useUserProfile'; export function UserHero({ children }: { children: ReactNode }) { return ( @@ -165,7 +165,7 @@ export function UserTimezone({ timezone }: { timezone: string }) { return ( - + {time} local time{offset ? ` · ${offset}` : ` · ${timezone}`} @@ -198,7 +198,9 @@ export function UserHeroName({ displayName, userId, pronouns }: UserHeroNameProp {pronouns?.map((pronoun) => pronoun.summary ? ( - {pronoun.summary} + + {pronoun.summary} + ) : undefined )} @@ -206,3 +208,11 @@ export function UserHeroName({ displayName, userId, pronouns }: UserHeroNameProp
); } + +export function UserHeroBiography({ bio }: { bio: UserBiography }) { + return ( + + {bio.body} + + ); +} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 9b4fc755..74453653 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -2,7 +2,14 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds'; import React from 'react'; import { useNavigate } from 'react-router-dom'; import { ObjectReader } from 'neurone/lib/utils'; -import { UserHero, UserHeroAvatar, UserHeroCover, UserHeroName, UserTimezone } from './UserHero'; +import { + UserHero, + UserHeroAvatar, + UserHeroBiography, + UserHeroCover, + UserHeroName, + UserTimezone, +} from './UserHero'; import { getMxIdServer } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { @@ -123,11 +130,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { /> )}
+ {profile?.biography ? : null} {profile?.timezone && } - - now hierarchy does not have room summary info we need to load it when we will be - rendering it - {server && } diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index 97948e63..22de76fb 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -5,24 +5,24 @@ import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { ObjectReader } from 'neurone/lib/utils'; import { useClient } from './useClient'; -type PronounResponse = { - grammatical_gender?: string; - language?: string; - summary?: string; -}; - export type UserPronoun = { grammaticalGender?: string; language?: string; summary?: string; }; +export type UserBiography = { + body: string; + formattedBody?: string; +}; + export type UserProfile = { avatarUrl?: string; displayName?: string; bannerUrl?: string; timezone?: string; pronouns?: UserPronoun[]; + biography?: UserBiography; }; export const useUserProfile = (userId: string): UserProfile | undefined => { const ct = useClient(); @@ -36,15 +36,23 @@ export const useUserProfile = (userId: string): UserProfile | undefined => { const [, profile] = await parseMatrixResp< GetProfileOK & { - 'chat.commet.profile_banner'?: string; - 'm.banner_url'?: string; - 'm.tz'?: string; - 'io.fsky.nyx.pronouns': PronounResponse[]; - 'm.pronouns': PronounResponse[]; + 'chat.commet.profile_banner'?: unknown; + 'm.banner_url'?: unknown; + 'm.tz'?: unknown; + 'io.fsky.nyx.pronouns': unknown; + 'm.pronouns': unknown; + 'm.biography': unknown; + 'gay.fomx.biography': unknown; } >(response); const pronouns = profile?.['m.pronouns'] ?? profile?.['io.fsky.nyx.pronouns']; + const bio = profile?.['m.biography'] ?? profile?.['gay.fomx.biography']; + const bioText = ObjectReader.array(bio, 'm.text'); + + const plainBio = bioText?.find((i) => ObjectReader.string(i, 'mimetype') !== 'text/html'); + const htmlBio = bioText?.find((i) => ObjectReader.string(i, 'mimetype') === 'text/html'); + const bioBody = ObjectReader.string(plainBio, 'body') ?? ObjectReader.string(htmlBio, 'body'); return { avatarUrl: profile?.avatar_url || undefined, @@ -53,11 +61,19 @@ export const useUserProfile = (userId: string): UserProfile | undefined => { ObjectReader.string(profile, 'm.banner_url') ?? ObjectReader.string(profile, 'chat.commet.profile_banner'), timezone: ObjectReader.string(profile, 'm.tz'), - pronouns: pronouns?.map((p) => ({ - grammaticalGender: ObjectReader.string(p, 'grammatical_gender'), - language: ObjectReader.string(p, 'language'), - summary: ObjectReader.string(p, 'summary'), - })), + pronouns: Array.isArray(pronouns) + ? pronouns?.map((p) => ({ + grammaticalGender: ObjectReader.string(p, 'grammatical_gender'), + language: ObjectReader.string(p, 'language'), + summary: ObjectReader.string(p, 'summary'), + })) + : undefined, + biography: bioBody + ? { + body: bioBody, + formattedBody: ObjectReader.string(htmlBio, 'body'), + } + : undefined, }; }, [ct, userId]); From 4fc961963641dcf4f0d65af07694cc1b1528dd6a Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:28:26 +0530 Subject: [PATCH 024/158] improve timezone --- src/app/components/user-profile/UserHero.tsx | 3 ++- src/app/hooks/useUserProfile.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 232bdb0b..296f0c55 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -166,7 +166,8 @@ export function UserTimezone({ timezone }: { timezone: string }) { - {time} local time{offset ? ` · ${offset}` : ` · ${timezone}`} + {time} local time · {timezone} + {offset ? ` (${offset})` : ''} ); diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index 22de76fb..a24d12e8 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -60,7 +60,7 @@ export const useUserProfile = (userId: string): UserProfile | undefined => { bannerUrl: ObjectReader.string(profile, 'm.banner_url') ?? ObjectReader.string(profile, 'chat.commet.profile_banner'), - timezone: ObjectReader.string(profile, 'm.tz'), + timezone: ObjectReader.string(profile, 'm.tz')?.replace(/"/g, ''), // some client add quotes in timezone pronouns: Array.isArray(pronouns) ? pronouns?.map((p) => ({ grammaticalGender: ObjectReader.string(p, 'grammatical_gender'), From 9ba0a132b8b27e3d1df7434d591de90340f9dd9d Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:54:37 +0530 Subject: [PATCH 025/158] fix profile cover error state --- src/app/components/user-profile/UserHero.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 296f0c55..bf38e250 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -48,6 +48,8 @@ export function UserHeroCover({ bannerUrl?: string; hasStatus: boolean; }) { + const [err, setError] = useState(false); + return (
- {bannerUrl && ( + {!err && bannerUrl && ( {userId} setError(true)} /> )}
From a214fcca2ed36d74bc8b4a212ebae7c7016c258c Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:49:28 +0530 Subject: [PATCH 026/158] fix unexpected hdr in images --- src/index.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.css b/src/index.css index d4696367..0b4e796a 100644 --- a/src/index.css +++ b/src/index.css @@ -81,6 +81,10 @@ a:hover { pointer-events: none; } +img { + dynamic-range-limit: standard; +} + b { font-weight: 500; } From 919fe50ac092e361c78f6c56d689c5f336b73d18 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:21:30 +0530 Subject: [PATCH 027/158] add user bio viewer --- src/app/components/user-profile/UserHero.tsx | 34 ++++++++++++++++--- .../user-profile/UserRoomProfile.tsx | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index bf38e250..762d3527 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -19,7 +19,7 @@ import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; import { getMxIdLocalPart } from '../../utils/matrix'; -import { BreakWord, LineClamp3 } from '../../styles/Text.css'; +import { BreakWord, HoverUnderline, LineClamp3, PreWrap } from '../../styles/Text.css'; import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; @@ -28,6 +28,7 @@ import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { UserBiography, UserPronoun } from '../../hooks/useUserProfile'; +import { RoomTopicViewer } from '../room-topic-viewer'; export function UserHero({ children }: { children: ReactNode }) { return ( @@ -214,9 +215,34 @@ export function UserHeroName({ displayName, userId, pronouns }: UserHeroNameProp } export function UserHeroBiography({ bio }: { bio: UserBiography }) { + const [view, setView] = useState(false); + return ( - - {bio.body} - + <> + setView(true)} + className={classNames(BreakWord, PreWrap, LineClamp3, HoverUnderline)} + size="T200" + > + {bio.body} + + }> + + setView(false), + escapeDeactivates: stopPropagation, + }} + > + {/* TODO: create new component to render formatted body */} + setView(false)} /> + + + + ); } diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 74453653..cad7b60b 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -86,7 +86,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const bannerUrl = profile?.bannerUrl ? mxcToHttp(ct.session.baseUrl, profile.bannerUrl, useAuthentication).url() - : avatarUrl; + : profile && avatarUrl; const handleMessage = () => { closeUserRoomProfile(); From e56eeace64c62027336d3edce6dd57b6c6b5b865 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:01:44 +0530 Subject: [PATCH 028/158] add new sdk in general room settings --- src/app/components/JoinRulesSwitcher.tsx | 37 ++-- .../common-settings/general/RoomAddress.tsx | 61 ++--- .../general/RoomEncryption.tsx | 38 ++-- .../general/RoomHistoryVisibility.tsx | 61 +++-- .../common-settings/general/RoomJoinRules.tsx | 112 +++++----- .../common-settings/general/RoomProfile.tsx | 111 ++++++---- .../common-settings/general/RoomPublish.tsx | 35 ++- .../common-settings/general/RoomUpgrade.tsx | 99 +++++---- .../features/room-settings/RoomSettings.tsx | 209 ++++++++++-------- .../room-settings/RoomSettingsRenderer.tsx | 15 +- .../room-settings/general/General.tsx | 4 +- src/app/hooks/storage/useRoomStateEvent.ts | 14 +- src/app/hooks/useRoomAliases.ts | 174 ++++++++------- src/app/hooks/useRoomDirectoryVisibility.ts | 33 ++- src/app/pages/Router.tsx | 4 +- src/app/state/upload.ts | 2 +- src/app/styles/Text.css.ts | 4 + src/types/matrix/room.ts | 9 + 18 files changed, 578 insertions(+), 444 deletions(-) diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index bbc0a65d..58cf039b 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -13,44 +13,43 @@ import { Button, Spinner, } from 'folds'; -import { JoinRule } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../utils/keyboard'; import { getRoomIconSrc } from '../utils/room'; +import { JoinRule } from '../../types/matrix/room'; export type ExtraJoinRules = 'knock_restricted'; -export type ExtendedJoinRules = JoinRule | ExtraJoinRules; -type JoinRuleIcons = Record; +type JoinRuleIcons = Record; export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons => useMemo( () => ({ - [JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite), - [JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock), - knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), - [JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), - [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), - [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), + invite: getRoomIconSrc(Icons, roomType, 'invite'), + knock: getRoomIconSrc(Icons, roomType, 'knock'), + restricted: getRoomIconSrc(Icons, roomType, 'restricted'), + knock_restricted: getRoomIconSrc(Icons, roomType, 'knock_restricted'), + public: getRoomIconSrc(Icons, roomType, 'public'), + private: getRoomIconSrc(Icons, roomType, 'private'), }), [roomType] ); -type JoinRuleLabels = Record; +type JoinRuleLabels = Record; export const useRoomJoinRuleLabel = (): JoinRuleLabels => useMemo( () => ({ - [JoinRule.Invite]: 'Invite Only', - [JoinRule.Knock]: 'Knock & Invite', + invite: 'Invite Only', + knock: 'Knock & Invite', knock_restricted: 'Space Members or Knock', - [JoinRule.Restricted]: 'Space Members', - [JoinRule.Public]: 'Public', - [JoinRule.Private]: 'Invite Only', + restricted: 'Space Members', + public: 'Public', + private: 'Invite Only', }), [] ); -type JoinRulesSwitcherProps = { +type JoinRulesSwitcherProps = { icons: JoinRuleIcons; labels: JoinRuleLabels; rules: T; @@ -59,7 +58,7 @@ type JoinRulesSwitcherProps = { disabled?: boolean; changing?: boolean; }; -export function JoinRulesSwitcher({ +export function JoinRulesSwitcher({ icons, labels, rules, @@ -75,7 +74,7 @@ export function JoinRulesSwitcher({ }; const handleChange = useCallback( - (selectedRule: ExtendedJoinRules) => { + (selectedRule: JoinRule) => { setCords(undefined); onChange(selectedRule); }, @@ -127,7 +126,7 @@ export function JoinRulesSwitcher({ fill="Soft" radii="300" outlined - before={} + before={} after={ changing ? ( diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 767a967e..26986eef 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -14,11 +14,9 @@ import { Text, toRem, } from 'folds'; -import { MatrixError } from 'matrix-js-sdk'; import { SettingTile } from '../../../components/setting-tile'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRoom } from '../../../hooks/useRoom'; import { useLocalAliases, @@ -30,25 +28,26 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { CutoutCard } from '../../../components/cutout-card'; import { replaceSpaceWithDash } from '../../../utils/common'; import { useAlive } from '../../../hooks/useAlive'; -import { StateEvent } from '../../../../types/matrix/room'; +import { StateEventType } from '../../../../types/matrix/room'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { getMxIdServer } from '../../../utils/matrix'; +import { useClient } from '../../../hooks/useClient'; type RoomPublishedAddressesProps = { permissions: RoomPermissionsAPI; }; export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const canEditCanonical = permissions.stateEvent( - StateEvent.RoomCanonicalAlias, - mx.getSafeUserId() + StateEventType.RoomCanonicalAlias, + ct.session.userId ); - const [canonicalAlias, publishedAliases] = usePublishedAliases(room); - const setMainAlias = useSetMainAlias(room); + const [canonicalAlias, publishedAliases] = usePublishedAliases(roomId); + const setMainAlias = useSetMainAlias(roomId, publishedAliases); const [mainState, setMain] = useAsyncCallback(setMainAlias); const loading = mainState.status === AsyncStatus.Loading; @@ -120,7 +119,7 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr {mainState.status === AsyncStatus.Error && ( - {(mainState.error as MatrixError).message} + {mainState.error instanceof Error ? mainState.error.message : 'Unknown Error'} )}
@@ -131,8 +130,8 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr } function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise }) { - const mx = useMatrixClient(); - const userId = mx.getSafeUserId(); + const ct = useClient(); + const { userId } = ct.session; const server = getMxIdServer(userId); const alive = useAlive(); @@ -189,9 +188,7 @@ function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) =
{addState.status === AsyncStatus.Error && ( - {(addState.error as MatrixError).httpStatus === 409 - ? 'Address is already in use!' - : (addState.error as MatrixError).message} + {addState.error instanceof Error ? addState.error.message : 'Unknown Error'} )}
@@ -207,11 +204,15 @@ function LocalAddressesList({ removeLocalAlias: (alias: string) => Promise; canEditCanonical?: boolean; }) { - const room = useRoom(); + const roomId = useRoom(); const alive = useAlive(); - const [, publishedAliases] = usePublishedAliases(room); - const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room); + const [canonicalAlias, publishedAliases] = usePublishedAliases(roomId); + const { publishAliases, unpublishAliases } = usePublishUnpublishAliases( + roomId, + publishedAliases, + canonicalAlias + ); const [selectedAliases, setSelectedAliases] = useState([]); const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias)); @@ -261,10 +262,10 @@ function LocalAddressesList({ deleteState.status === AsyncStatus.Loading || publishState.status === AsyncStatus.Loading || unpublishState.status === AsyncStatus.Loading; - let error: MatrixError | undefined; - if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError; - if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError; - if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError; + let error: unknown; + if (deleteState.status === AsyncStatus.Error) error = deleteState.error; + if (publishState.status === AsyncStatus.Error) error = publishState.error; + if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error; return ( @@ -352,7 +353,7 @@ function LocalAddressesList({ })} {error && ( - {error.message} + {error instanceof Error ? error.message : 'Unknown Error'} )} @@ -360,17 +361,17 @@ function LocalAddressesList({ } export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissionsAPI }) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const canEditCanonical = permissions.stateEvent( - StateEvent.RoomCanonicalAlias, - mx.getSafeUserId() + StateEventType.RoomCanonicalAlias, + ct.session.userId ); const [expand, setExpand] = useState(false); - const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId); + const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(roomId); return ( - {localAliasesState.error.message} + {localAliasesState.error instanceof Error + ? localAliasesState.error.message + : 'Unkonw Error'}
)} diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx index 15b1f156..5e1e8c44 100644 --- a/src/app/features/common-settings/general/RoomEncryption.tsx +++ b/src/app/features/common-settings/general/RoomEncryption.tsx @@ -16,18 +16,19 @@ import { Text, } from 'folds'; import React, { useCallback, useState } from 'react'; -import { MatrixError } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { StateEvent } from '../../../../types/matrix/room'; +import { StateEventType } from '../../../../types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useRoom } from '../../../hooks/useRoom'; -import { useStateEvent } from '../../../hooks/useStateEvent'; import { stopPropagation } from '../../../utils/keyboard'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; +import { useClient } from '../../../hooks/useClient'; +import { useObservableValue } from '../../../hooks/useObservableValue'; const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2'; @@ -35,21 +36,26 @@ type RoomEncryptionProps = { permissions: RoomPermissionsAPI; }; export function RoomEncryption({ permissions }: RoomEncryptionProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); - const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId()); - const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{ - algorithm: string; - }>(); - const enabled = content?.algorithm === ROOM_ENC_ALGO; + const canEnable = permissions.stateEvent(StateEventType.RoomEncryption, ct.session.userId); + const encryptedRooms = useObservableValue(ct.encryptedRooms); + const enabled = encryptedRooms.has(roomId); const [enableState, enable] = useAsyncCallback( useCallback(async () => { - await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, { - algorithm: ROOM_ENC_ALGO, - }); - }, [mx, room.roomId]) + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomEncryption, stateKey: '' }, + body: { + algorithm: ROOM_ENC_ALGO, + }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); + }, [ct, roomId]) ); const enabling = enableState.status === AsyncStatus.Loading; @@ -97,7 +103,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) { > {enableState.status === AsyncStatus.Error && ( - {(enableState.error as MatrixError).message} + {enableState.error instanceof Error ? enableState.error.message : 'Unknown Error'} )} {prompt && ( diff --git a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx index 2e42785f..de13a902 100644 --- a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx +++ b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx @@ -12,55 +12,46 @@ import { Spinner, Text, } from 'folds'; -import { HistoryVisibility, MatrixError } from 'matrix-js-sdk'; -import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; +import { HistoryVisibility } from 'neurone/lib/crypto/machine'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRoom } from '../../../hooks/useRoom'; -import { StateEvent } from '../../../../types/matrix/room'; +import { RoomHistoryVisibilityContent, StateEventType } from '../../../../types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useStateEvent } from '../../../hooks/useStateEvent'; import { stopPropagation } from '../../../utils/keyboard'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; +import { useClient } from '../../../hooks/useClient'; +import { useObservableValue } from '../../../hooks/useObservableValue'; -const useVisibilityStr = () => +const useVisibilityStr = (): Record => useMemo( () => ({ - [HistoryVisibility.Invited]: 'After Invite', - [HistoryVisibility.Joined]: 'After Join', - [HistoryVisibility.Shared]: 'All Messages', - [HistoryVisibility.WorldReadable]: 'All Messages (Guests)', + invited: 'After Invite', + joined: 'After Join', + shared: 'All Messages', + world_readable: 'All Messages (Guests)', }), [] ); -const useVisibilityMenu = () => - useMemo( - () => [ - HistoryVisibility.Shared, - HistoryVisibility.Invited, - HistoryVisibility.Joined, - HistoryVisibility.WorldReadable, - ], - [] - ); +const useVisibilityMenu = (): HistoryVisibility[] => + useMemo(() => ['invited', 'joined', 'shared', 'world_readable'], []); type RoomHistoryVisibilityProps = { permissions: RoomPermissionsAPI; }; export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); - const canEdit = permissions.stateEvent(StateEvent.RoomHistoryVisibility, mx.getSafeUserId()); + const canEdit = permissions.stateEvent(StateEventType.RoomHistoryVisibility, ct.session.userId); + const roomToHistoryVisibility = useObservableValue(ct.roomToHistoryVisibility); - const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility); - const historyVisibility: HistoryVisibility = - visibilityEvent?.getContent().history_visibility ?? - HistoryVisibility.Shared; + const historyVisibility: HistoryVisibility = roomToHistoryVisibility.get(roomId) ?? 'shared'; const visibilityMenu = useVisibilityMenu(); const visibilityStr = useVisibilityStr(); @@ -73,12 +64,20 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp const [submitState, submit] = useAsyncCallback( useCallback( async (visibility: HistoryVisibility) => { - const content: RoomHistoryVisibilityEventContent = { + const content: RoomHistoryVisibilityContent = { history_visibility: visibility, }; - await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content); + + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomHistoryVisibility, stateKey: '' }, + body: content, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); }, - [mx, room.roomId] + [ct, roomId] ) ); const submitting = submitState.status === AsyncStatus.Loading; @@ -156,7 +155,7 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp > {submitState.status === AsyncStatus.Error && ( - {(submitState.error as MatrixError).message} + {submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'} )} diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index 0d352167..c4520379 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -1,10 +1,9 @@ import React, { useCallback, useMemo } from 'react'; import { color, Text } from 'folds'; -import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { useAtomValue } from 'jotai'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { - ExtendedJoinRules, JoinRulesSwitcher, useJoinRuleIcons, useRoomJoinRuleLabel, @@ -12,13 +11,15 @@ import { import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRoom } from '../../../hooks/useRoom'; -import { StateEvent } from '../../../../types/matrix/room'; -import { useStateEvent } from '../../../hooks/useStateEvent'; +import { + JoinRule, + StateEventType, + AllowCondition, + JoinRulesContent, +} from '../../../../types/matrix/room'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { getStateEvents } from '../../../utils/room'; import { useRecursiveChildSpaceScopeFactory, useSpaceChildren, @@ -31,89 +32,98 @@ import { restrictedSupported, } from '../../../utils/matrix'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; - -type RestrictedRoomAllowContent = { - room_id: string; - type: RestrictedAllowType; -}; +import { useClient } from '../../../hooks/useClient'; +import { useObservableValue } from '../../../hooks/useObservableValue'; +import { getRoomStateEventsQuery } from '../../../hooks/storage/useRoomStateEvent'; type RoomJoinRulesProps = { permissions: RoomPermissionsAPI; }; export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { - const mx = useMatrixClient(); - const room = useRoom(); - const allowKnockRestricted = knockRestrictedSupported(room.getVersion()); - const allowRestricted = restrictedSupported(room.getVersion()); - const allowKnock = knockSupported(room.getVersion()); + const ct = useClient(); + const roomId = useRoom(); + + const roomVersion = ct.roomToVersion.read.get(roomId) ?? '1'; + + const allowKnockRestricted = knockRestrictedSupported(roomVersion); + const allowRestricted = restrictedSupported(roomVersion); + const allowKnock = knockSupported(roomVersion); const roomIdToParents = useAtomValue(roomToParentsAtom); - const space = useSpaceOptionally(); - const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents); - const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope); + const spaceId = useSpaceOptionally(); + const subspacesScope = useRecursiveChildSpaceScopeFactory(ct, roomIdToParents); + const subspaces = useSpaceChildren(allRoomsAtom, spaceId ?? '', subspacesScope); - const canEdit = permissions.stateEvent(StateEvent.RoomHistoryVisibility, mx.getSafeUserId()); + const canEdit = permissions.stateEvent(StateEventType.RoomHistoryVisibility, ct.session.userId); - const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules); - const content = joinRuleEvent?.getContent(); - const rule: JoinRule = content?.join_rule ?? JoinRule.Invite; + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const rule: JoinRule = roomToJoinRule.get(roomId) ?? 'invite'; - const joinRules: Array = useMemo(() => { - const r: ExtendedJoinRules[] = [JoinRule.Invite]; + const joinRules: Array = useMemo(() => { + const r: JoinRule[] = ['invite']; if (allowKnock) { - r.push(JoinRule.Knock); + r.push('knock'); } - if (allowRestricted && space) { - r.push(JoinRule.Restricted); + if (allowRestricted && spaceId) { + r.push('restricted'); } - if (allowKnockRestricted && space) { + if (allowKnockRestricted && spaceId) { r.push('knock_restricted'); } - r.push(JoinRule.Public); + r.push('public'); return r; - }, [allowKnockRestricted, allowRestricted, allowKnock, space]); + }, [allowKnockRestricted, allowRestricted, allowKnock, spaceId]); - const icons = useJoinRuleIcons(room.getType()); + const icons = useJoinRuleIcons(ct.roomToType.read.get(roomId)); const labels = useRoomJoinRuleLabel(); const [submitState, submit] = useAsyncCallback( useCallback( - async (joinRule: ExtendedJoinRules) => { - const allow: RestrictedRoomAllowContent[] = []; - if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') { - const roomParents = roomIdToParents.get(room.roomId); + async (joinRule: JoinRule) => { + const allow: AllowCondition[] = []; + if (joinRule === 'restricted' || joinRule === 'knock_restricted') { + const roomParents = roomIdToParents.get(roomId); - const parents = getStateEvents(room, StateEvent.SpaceParent) - .map((event) => event.getStateKey()) - .filter((parentId) => typeof parentId === 'string') + const parentEvents = await ct.storage.joinRoomState.read.query( + getRoomStateEventsQuery(roomId, StateEventType.SpaceParent) + ); + const parents = parentEvents + .map((event) => event.event.state_key) .filter((parentId) => roomParents?.has(parentId)); - if (parents.length === 0 && space && roomParents) { + if (parents.length === 0 && spaceId && roomParents) { // if no m.space.parent found // find parent in current space const selectedParents = subspaces.filter((rId) => roomParents.has(rId)); - if (roomParents.has(space.roomId)) { - selectedParents.push(space.roomId); + if (roomParents.has(spaceId)) { + selectedParents.push(spaceId); } selectedParents.forEach((pId) => parents.push(pId)); } parents.forEach((parentRoomId) => { if (!parentRoomId) return; allow.push({ - type: RestrictedAllowType.RoomMembership, + type: 'm.room_membership', room_id: parentRoomId, }); }); } - const c: RoomJoinRulesEventContent = { - join_rule: joinRule as JoinRule, + const c: JoinRulesContent = { + join_rule: joinRule, }; if (allow.length > 0) c.allow = allow; - await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomJoinRules, stateKey: '' }, + body: c, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); }, - [mx, room, space, subspaces, roomIdToParents] + [ct, roomId, spaceId, subspaces, roomIdToParents] ) ); @@ -127,9 +137,9 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { gap="400" > {submitState.status === AsyncStatus.Error && ( - {(submitState.error as MatrixError).message} + {submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'} )} diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 0a67f9e0..2367ab0a 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -12,27 +12,18 @@ import { TextArea, } from 'folds'; import React, { FormEventHandler, useCallback, useMemo, useState } from 'react'; -import { useAtomValue } from 'jotai'; import Linkify from 'linkify-react'; import classNames from 'classnames'; -import { JoinRule, MatrixError } from 'matrix-js-sdk'; +import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { useRoom } from '../../../hooks/useRoom'; -import { - useRoomAvatar, - useRoomJoinRule, - useRoomName, - useRoomTopic, -} from '../../../hooks/useRoomMeta'; -import { mDirectAtom } from '../../../state/mDirectList'; import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser'; import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; -import { mxcUrlToHttp } from '../../../utils/matrix'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { StateEvent } from '../../../../types/matrix/room'; +import { JoinRule, StateEvent, StateEventType } from '../../../../types/matrix/room'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useObjectURL } from '../../../hooks/useObjectURL'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; @@ -40,6 +31,9 @@ import { useFilePicker } from '../../../hooks/useFilePicker'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; +import { useClient } from '../../../hooks/useClient'; +import { useObservableValue } from '../../../hooks/useObservableValue'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; type RoomProfileEditProps = { canEditAvatar: boolean; @@ -48,6 +42,7 @@ type RoomProfileEditProps = { avatar?: string; name: string; topic: string; + joinRule?: JoinRule; onClose: () => void; }; export function RoomProfileEdit({ @@ -57,17 +52,22 @@ export function RoomProfileEdit({ avatar, name, topic, + joinRule, onClose, }: RoomProfileEditProps) { - const room = useRoom(); - const mx = useMatrixClient(); - const alive = useAlive(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); - const joinRule = useRoomJoinRule(room); + const roomId = useRoom(); + const alive = useAlive(); + const [roomAvatar, setRoomAvatar] = useState(avatar); const avatarUrl = roomAvatar - ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined + ? mxcToHttp(ct.session.baseUrl, roomAvatar, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; const [imageFile, setImageFile] = useState(); @@ -93,18 +93,37 @@ export function RoomProfileEdit({ useCallback( async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => { if (roomAvatarMxc !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, { - url: roomAvatarMxc, - }); + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomAvatar, stateKey: '' }, + body: { url: roomAvatarMxc }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); } if (roomName !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName }); + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomName, stateKey: '' }, + body: { name: roomName }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); } if (roomTopic !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic }); + const resp = await ct.requestAuthJSON( + putStateEvent({ + pathParams: { roomId, eventType: StateEventType.RoomTopic, stateKey: '' }, + body: { topic: roomTopic }, + }) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); } }, - [mx, room.roomId] + [ct, roomId] ) ); const submitting = submitState.status === AsyncStatus.Loading; @@ -194,14 +213,14 @@ export function RoomProfileEdit({ ( )} @@ -231,7 +250,7 @@ export function RoomProfileEdit({ {submitState.status === AsyncStatus.Error && ( - {(submitState.error as MatrixError).message} + {submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'} )} @@ -264,23 +283,32 @@ type RoomProfileProps = { permissions: RoomPermissionsAPI; }; export function RoomProfile({ permissions }: RoomProfileProps) { - const mx = useMatrixClient(); + const ct = useClient(); + const myUserId = ct.session.userId; const useAuthentication = useMediaAuthentication(); - const room = useRoom(); - const directs = useAtomValue(mDirectAtom); + const roomId = useRoom(); - const avatar = useRoomAvatar(room, directs.has(room.roomId)); - const name = useRoomName(room); - const topic = useRoomTopic(room); - const joinRule = useRoomJoinRule(room); + const roomToAvatar = useObservableValue(ct.roomToAvatar); + const roomToName = useObservableValue(ct.roomToName); + const roomToTopic = useObservableValue(ct.roomToTopic); + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); - const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId()); - const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId()); - const canEditTopic = permissions.stateEvent(StateEvent.RoomTopic, mx.getSafeUserId()); + const avatar = roomToAvatar.get(roomId); + const name = roomToName.get(roomId); + const topic = roomToTopic.get(roomId); + const joinRule = roomToJoinRule.get(roomId); + + const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, myUserId); + const canEditName = permissions.stateEvent(StateEvent.RoomName, myUserId); + const canEditTopic = permissions.stateEvent(StateEvent.RoomTopic, myUserId); const canEdit = canEditAvatar || canEditName || canEditTopic; const avatarUrl = avatar - ? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined + ? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; const [edit, setEdit] = useState(false); @@ -304,6 +332,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) { avatar={avatar} name={name ?? ''} topic={topic ?? ''} + joinRule={joinRule} onClose={handleCloseEdit} /> ) : ( @@ -337,14 +366,14 @@ export function RoomProfile({ permissions }: RoomProfileProps) { ( )} diff --git a/src/app/features/common-settings/general/RoomPublish.tsx b/src/app/features/common-settings/general/RoomPublish.tsx index ce014210..6be65d8b 100644 --- a/src/app/features/common-settings/general/RoomPublish.tsx +++ b/src/app/features/common-settings/general/RoomPublish.tsx @@ -1,42 +1,37 @@ import React from 'react'; import { Box, color, Spinner, Switch, Text } from 'folds'; -import { JoinRule, MatrixError } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useRoom } from '../../../hooks/useRoom'; import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { StateEvent } from '../../../../types/matrix/room'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useStateEvent } from '../../../hooks/useStateEvent'; -import { ExtendedJoinRules } from '../../../components/JoinRulesSwitcher'; +import { JoinRule, StateEventType } from '../../../../types/matrix/room'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; +import { useClient } from '../../../hooks/useClient'; +import { useObservableValue } from '../../../hooks/useObservableValue'; type RoomPublishProps = { permissions: RoomPermissionsAPI; }; export function RoomPublish({ permissions }: RoomPublishProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const canEditCanonical = permissions.stateEvent( - StateEvent.RoomCanonicalAlias, - mx.getSafeUserId() + StateEventType.RoomCanonicalAlias, + ct.session.userId ); - const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules); - const content = joinRuleEvent?.getContent(); - const rule: ExtendedJoinRules = (content?.join_rule as ExtendedJoinRules) ?? JoinRule.Invite; + const roomToJoinRule = useObservableValue(ct.roomToJoinRule); + const rule: JoinRule = roomToJoinRule.get(roomId) ?? 'invite'; - const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId); + const { visibilityState, setVisibility } = useRoomDirectoryVisibility(roomId); const [toggleState, toggleVisibility] = useAsyncCallback(setVisibility); const loading = visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading; - const validRule = - rule === JoinRule.Public || rule === JoinRule.Knock || rule === 'knock_restricted'; + const validRule = rule === 'public' || rule === 'knock' || rule === 'knock_restricted'; return ( {visibilityState.status === AsyncStatus.Error && ( - {(visibilityState.error as MatrixError).message} + {visibilityState.error instanceof Error + ? visibilityState.error.message + : 'Unknown Error'} )} {toggleState.status === AsyncStatus.Error && ( - {(toggleState.error as MatrixError).message} + {toggleState.error instanceof Error ? toggleState.error.message : 'Unknown Error'} )} diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 45a480aa..58ce5107 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -16,16 +16,15 @@ import { Icons, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { MatrixError, Method } from 'matrix-js-sdk'; -import { RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types'; +import { ObjectReader } from 'neurone/lib/utils'; +import { postUpgrade } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useRoom } from '../../../hooks/useRoom'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { IRoomCreateContent, StateEvent } from '../../../../types/matrix/room'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useStateEvent } from '../../../hooks/useStateEvent'; +import { StateEventType } from '../../../../types/matrix/room'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useCapabilities } from '../../../hooks/useCapabilities'; import { stopPropagation } from '../../../utils/keyboard'; @@ -39,12 +38,14 @@ import { useAlive } from '../../../hooks/useAlive'; import { creatorsSupported } from '../../../utils/matrix'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { BreakWord } from '../../../styles/Text.css'; +import { useClient } from '../../../hooks/useClient'; +const EMPTY_CREATORS = new Set(); function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const alive = useAlive(); - const creators = useRoomCreators(room); + const creators = useRoomCreators(roomId) ?? EMPTY_CREATORS; const capabilities = useCapabilities(); const roomVersions = capabilities['m.room_versions']; @@ -61,12 +62,20 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const [upgradeState, upgrade] = useAsyncCallback( useCallback( async (version: string, newAdditionalCreators?: string[]) => { - await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, { - new_version: version, - additional_creators: newAdditionalCreators, - }); + const resp = await ct.requestAuthJSON( + postUpgrade({ + pathParams: { roomId }, + body: { + new_version: version, + additional_creators: newAdditionalCreators, + }, + }) + ); + + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); }, - [mx, room] + [ct, roomId] ) ); @@ -103,7 +112,9 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { size="500" > - {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} + + {ct.spaceRooms.read.has(roomId) ? 'Space Upgrade' : 'Room Upgrade'} + @@ -139,7 +150,9 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { {upgradeState.status === AsyncStatus.Error && ( - {(upgradeState.error as MatrixError).message} + {upgradeState.error instanceof Error + ? upgradeState.error.message + : 'Unknown Error'} )} @@ -163,28 +178,30 @@ type RoomUpgradeProps = { requestClose: () => void; }; export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) { - const mx = useMatrixClient(); - const room = useRoom(); + const ct = useClient(); + const roomId = useRoom(); const { navigateRoom, navigateSpace } = useRoomNavigate(); - const createContent = useStateEvent( - room, - StateEvent.RoomCreate - )?.getContent(); - const roomVersion = createContent?.room_version ?? '1'; - const predecessorRoomId = createContent?.predecessor?.room_id; - const tombstoneContent = useStateEvent( - room, - StateEvent.RoomTombstone - )?.getContent(); - const replacementRoom = tombstoneContent?.replacement_room; + const createContent = ct.state.join.create.read.find((event) => event.roomId === roomId)?.event + .content; + const tombstoneContent = ct.state.join.tombstone.read.find((event) => event.roomId === roomId) + ?.event.content; - const canUpgrade = permissions.stateEvent(StateEvent.RoomTombstone, mx.getSafeUserId()); + const roomVersion = ObjectReader.string(createContent, 'room_version') ?? '1'; + const predecessorRoomId = ObjectReader.string( + ObjectReader.object(createContent, 'predecessor'), + 'room_id' + ); + + const replacementRoom = ObjectReader.string(tombstoneContent, 'replacement_room'); + const tombstoneBody = ObjectReader.string(tombstoneContent, 'body'); + + const canUpgrade = permissions.stateEvent(StateEventType.RoomTombstone, ct.session.userId); const handleOpenRoom = () => { if (replacementRoom) { requestClose(); - if (room.isSpaceRoom()) { + if (ct.spaceRooms.read.has(roomId)) { navigateSpace(replacementRoom); } else { navigateRoom(replacementRoom); @@ -195,10 +212,14 @@ export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) { const handleOpenOldRoom = () => { if (predecessorRoomId) { requestClose(); - if (room.isSpaceRoom()) { + if (ct.spaceRooms.read.has(roomId)) { navigateSpace(predecessorRoomId); } else { - navigateRoom(predecessorRoomId, createContent.predecessor?.event_id); + const preEventId = ObjectReader.string( + ObjectReader.object(createContent, 'predecessor'), + 'event_id' + ); + navigateRoom(predecessorRoomId, preEventId); } } }; @@ -213,11 +234,11 @@ export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) { gap="400" > - {room.isSpaceRoom() ? 'Old Space' : 'Old Room'} + {ct.spaceRooms.read.has(roomId) ? 'Old Space' : 'Old Room'} )} {replacementRoom ? ( @@ -242,7 +263,9 @@ export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) { radii="300" onClick={handleOpenRoom} > - {room.isSpaceRoom() ? 'Open New Space' : 'Open New Room'} + + {ct.spaceRooms.read.has(roomId) ? 'Open New Space' : 'Open New Room'} + ) : (