From f9ae6bd0315a969c4976007af1543a9d464a7a63 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 6 Dec 2024 18:37:16 +0200 Subject: [PATCH] web/rightpanel: fetch different user info sections separately --- pkg/hicli/json-commands.go | 15 +- pkg/hicli/profile.go | 248 +++++++----------- web/src/api/rpc.ts | 10 +- web/src/api/types/hitypes.ts | 12 +- web/src/ui/rightpanel/RightPanel.css | 8 +- web/src/ui/rightpanel/UserInfo.tsx | 73 ++---- web/src/ui/rightpanel/UserInfoDeviceList.tsx | 106 +++++--- web/src/ui/rightpanel/UserInfoError.tsx | 28 ++ web/src/ui/rightpanel/UserInfoMutualRooms.tsx | 72 +++-- 9 files changed, 279 insertions(+), 293 deletions(-) create mode 100644 web/src/ui/rightpanel/UserInfoError.tsx diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 253dd0e..c09d4c0 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -86,9 +86,13 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) { return h.Client.GetProfile(ctx, params.UserID) }) - case "get_profile_view": - return unmarshalAndCall(req.Data, func(params *getProfileViewParams) (*ProfileViewData, error) { - return h.GetProfileView(ctx, params.RoomID, params.UserID) + case "get_mutual_rooms": + return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { + return h.GetMutualRooms(ctx, params.UserID) + }) + case "get_profile_encryption_info": + return unmarshalAndCall(req.Data, func(params *getProfileParams) (*ProfileEncryptionInfo, error) { + return h.GetProfileEncryptionInfo(ctx, params.UserID) }) case "get_event": return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { @@ -242,11 +246,6 @@ type getProfileParams struct { UserID id.UserID `json:"user_id"` } -type getProfileViewParams struct { - RoomID id.RoomID `json:"room_id"` - UserID id.UserID `json:"user_id"` -} - type getEventParams struct { RoomID id.RoomID `json:"room_id"` EventID id.EventID `json:"event_id"` diff --git a/pkg/hicli/profile.go b/pkg/hicli/profile.go index 5149794..44b8fe8 100644 --- a/pkg/hicli/profile.go +++ b/pkg/hicli/profile.go @@ -11,15 +11,34 @@ import ( "fmt" "slices" "strings" - "sync" "github.com/rs/zerolog" - "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) -type ProfileViewDevice struct { +const MutualRoomsBatchLimit = 5 + +func (h *HiClient) GetMutualRooms(ctx context.Context, userID id.UserID) (output []id.RoomID, err error) { + var nextBatch string + for i := 0; i < MutualRoomsBatchLimit; i++ { + mutualRooms, err := h.Client.GetMutualRooms(ctx, userID, mautrix.ReqMutualRooms{From: nextBatch}) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("from_batch_token", nextBatch).Msg("Failed to get mutual rooms") + return nil, err + } + output = append(output, mutualRooms.Joined...) + nextBatch = mutualRooms.NextBatch + if nextBatch == "" { + break + } + } + slices.Sort(output) + output = slices.Compact(output) + return +} + +type ProfileDevice struct { DeviceID id.DeviceID `json:"device_id"` Name string `json:"name"` IdentityKey id.Curve25519 `json:"identity_key"` @@ -28,158 +47,93 @@ type ProfileViewDevice struct { Trust id.TrustState `json:"trust_state"` } -type ProfileViewData struct { - GlobalProfile *mautrix.RespUserProfile `json:"global_profile"` - - DevicesTracked bool `json:"devices_tracked"` - Devices []*ProfileViewDevice `json:"devices"` - MasterKey string `json:"master_key"` - FirstMasterKey string `json:"first_master_key"` - UserTrusted bool `json:"user_trusted"` - - MutualRooms []id.RoomID `json:"mutual_rooms"` - - Errors []string `json:"errors"` +type ProfileEncryptionInfo struct { + DevicesTracked bool `json:"devices_tracked"` + Devices []*ProfileDevice `json:"devices"` + MasterKey string `json:"master_key"` + FirstMasterKey string `json:"first_master_key"` + UserTrusted bool `json:"user_trusted"` + Errors []string `json:"errors"` } -const MutualRoomsBatchLimit = 5 - -func (h *HiClient) GetProfileView(ctx context.Context, roomID id.RoomID, userID id.UserID) (*ProfileViewData, error) { - log := zerolog.Ctx(ctx).With(). - Stringer("room_id", roomID). - Stringer("target_user_id", userID). - Logger() - var resp ProfileViewData - resp.Devices = make([]*ProfileViewDevice, 0) - resp.GlobalProfile = &mautrix.RespUserProfile{} - resp.Errors = make([]string, 0) - - var wg sync.WaitGroup - wg.Add(3) - - var errorsLock sync.Mutex - addError := func(err error) { - errorsLock.Lock() - resp.Errors = append(resp.Errors, err.Error()) - errorsLock.Unlock() +func (h *HiClient) GetProfileEncryptionInfo(ctx context.Context, userID id.UserID) (*ProfileEncryptionInfo, error) { + var resp ProfileEncryptionInfo + log := zerolog.Ctx(ctx) + userIDs, err := h.CryptoStore.FilterTrackedUsers(ctx, []id.UserID{userID}) + if err != nil { + log.Err(err).Msg("Failed to check if user's devices are tracked") + return nil, fmt.Errorf("failed to check if user's devices are tracked: %w", err) + } else if len(userIDs) == 0 { + return &resp, nil } - - go func() { - defer wg.Done() - profile, err := h.Client.GetProfile(ctx, userID) + ownKeys := h.Crypto.GetOwnCrossSigningPublicKeys(ctx) + var ownUserSigningKey id.Ed25519 + if ownKeys != nil { + ownUserSigningKey = ownKeys.UserSigningKey + } + resp.DevicesTracked = true + csKeys, err := h.CryptoStore.GetCrossSigningKeys(ctx, userID) + theirMasterKey := csKeys[id.XSUsageMaster] + theirSelfSignKey := csKeys[id.XSUsageSelfSigning] + if err != nil { + log.Err(err).Msg("Failed to get cross-signing keys") + return nil, fmt.Errorf("failed to get cross-signing keys: %w", err) + } else if csKeys != nil && theirMasterKey.Key != "" { + resp.MasterKey = theirMasterKey.Key.Fingerprint() + resp.FirstMasterKey = theirMasterKey.First.Fingerprint() + selfKeySigned, err := h.CryptoStore.IsKeySignedBy(ctx, userID, theirSelfSignKey.Key, userID, theirMasterKey.Key) if err != nil { - log.Err(err).Msg("Failed to get global profile") - addError(fmt.Errorf("failed to get global profile: %w", err)) - } else { - resp.GlobalProfile = profile + log.Err(err).Msg("Failed to check if self-signing key is signed by master key") + return nil, fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err) + } else if !selfKeySigned { + theirSelfSignKey = id.CrossSigningKey{} + resp.Errors = append(resp.Errors, "Self-signing key is not signed by master key") } - }() - go func() { - defer wg.Done() - if userID == h.Account.UserID { - return - } - var nextBatch string - for i := 0; i < MutualRoomsBatchLimit; i++ { - mutualRooms, err := h.Client.GetMutualRooms(ctx, userID, mautrix.ReqMutualRooms{From: nextBatch}) - if err != nil { - log.Err(err).Str("from_batch_token", nextBatch).Msg("Failed to get mutual rooms") - addError(fmt.Errorf("failed to get mutual rooms: %w", err)) - break + } else { + resp.Errors = append(resp.Errors, "Cross-signing keys not found") + } + devices, err := h.CryptoStore.GetDevices(ctx, userID) + if err != nil { + log.Err(err).Msg("Failed to get devices for user") + return nil, fmt.Errorf("failed to get devices: %w", err) + } + if userID == h.Account.UserID { + resp.UserTrusted, err = h.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, userID, h.Crypto.OwnIdentity().SigningKey) + } else if ownUserSigningKey != "" && theirMasterKey.Key != "" { + resp.UserTrusted, err = h.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, h.Account.UserID, ownUserSigningKey) + } + if err != nil { + log.Err(err).Msg("Failed to check if user is trusted") + resp.Errors = append(resp.Errors, fmt.Sprintf("Failed to check if user is trusted: %v", err)) + } + resp.Devices = make([]*ProfileDevice, len(devices)) + i := 0 + for _, device := range devices { + signatures, err := h.CryptoStore.GetSignaturesForKeyBy(ctx, device.UserID, device.SigningKey, device.UserID) + if err != nil { + log.Err(err).Stringer("device_id", device.DeviceID).Msg("Failed to get signatures for device") + resp.Errors = append(resp.Errors, fmt.Sprintf("Failed to get signatures for device %s: %v", device.DeviceID, err)) + } else if _, signed := signatures[theirSelfSignKey.Key]; signed && device.Trust == id.TrustStateUnset && theirSelfSignKey.Key != "" { + if resp.UserTrusted { + device.Trust = id.TrustStateCrossSignedVerified + } else if theirMasterKey.Key == theirMasterKey.First { + device.Trust = id.TrustStateCrossSignedTOFU } else { - resp.MutualRooms = mutualRooms.Joined - nextBatch = mutualRooms.NextBatch - if nextBatch == "" { - break - } + device.Trust = id.TrustStateCrossSignedUntrusted } } - slices.Sort(resp.MutualRooms) - resp.MutualRooms = slices.Compact(resp.MutualRooms) - }() - go func() { - defer wg.Done() - userIDs, err := h.CryptoStore.FilterTrackedUsers(ctx, []id.UserID{userID}) - if err != nil { - log.Err(err).Msg("Failed to check if user's devices are tracked") - addError(fmt.Errorf("failed to check if user's devices are tracked: %w", err)) - return - } else if len(userIDs) == 0 { - return + resp.Devices[i] = &ProfileDevice{ + DeviceID: device.DeviceID, + Name: device.Name, + IdentityKey: device.IdentityKey, + SigningKey: device.SigningKey, + Fingerprint: device.Fingerprint(), + Trust: device.Trust, } - ownKeys := h.Crypto.GetOwnCrossSigningPublicKeys(ctx) - var ownUserSigningKey id.Ed25519 - if ownKeys != nil { - ownUserSigningKey = ownKeys.UserSigningKey - } - resp.DevicesTracked = true - csKeys, err := h.CryptoStore.GetCrossSigningKeys(ctx, userID) - theirMasterKey := csKeys[id.XSUsageMaster] - theirSelfSignKey := csKeys[id.XSUsageSelfSigning] - if err != nil { - log.Err(err).Msg("Failed to get cross-signing keys") - addError(fmt.Errorf("failed to get cross-signing keys: %w", err)) - return - } else if csKeys != nil && theirMasterKey.Key != "" { - resp.MasterKey = theirMasterKey.Key.Fingerprint() - resp.FirstMasterKey = theirMasterKey.First.Fingerprint() - selfKeySigned, err := h.CryptoStore.IsKeySignedBy(ctx, userID, theirSelfSignKey.Key, userID, theirMasterKey.Key) - if err != nil { - log.Err(err).Msg("Failed to check if self-signing key is signed by master key") - addError(fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err)) - } else if !selfKeySigned { - theirSelfSignKey = id.CrossSigningKey{} - addError(fmt.Errorf("self-signing key is not signed by master key")) - } - } else { - addError(fmt.Errorf("cross-signing keys not found")) - } - devices, err := h.CryptoStore.GetDevices(ctx, userID) - if err != nil { - log.Err(err).Msg("Failed to get devices for user") - addError(fmt.Errorf("failed to get devices: %w", err)) - return - } - if userID == h.Account.UserID { - resp.UserTrusted, err = h.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, userID, h.Crypto.OwnIdentity().SigningKey) - } else if ownUserSigningKey != "" && theirMasterKey.Key != "" { - resp.UserTrusted, err = h.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, h.Account.UserID, ownUserSigningKey) - } - if err != nil { - log.Err(err).Msg("Failed to check if user is trusted") - addError(fmt.Errorf("failed to check if user is trusted: %w", err)) - } - resp.Devices = make([]*ProfileViewDevice, len(devices)) - i := 0 - for _, device := range devices { - signatures, err := h.CryptoStore.GetSignaturesForKeyBy(ctx, device.UserID, device.SigningKey, device.UserID) - if err != nil { - log.Err(err).Stringer("device_id", device.DeviceID).Msg("Failed to get signatures for device") - addError(fmt.Errorf("failed to get signatures for device %s: %w", device.DeviceID, err)) - } else if _, signed := signatures[theirSelfSignKey.Key]; signed && device.Trust == id.TrustStateUnset && theirSelfSignKey.Key != "" { - if resp.UserTrusted { - device.Trust = id.TrustStateCrossSignedVerified - } else if theirMasterKey.Key == theirMasterKey.First { - device.Trust = id.TrustStateCrossSignedTOFU - } else { - device.Trust = id.TrustStateCrossSignedUntrusted - } - } - resp.Devices[i] = &ProfileViewDevice{ - DeviceID: device.DeviceID, - Name: device.Name, - IdentityKey: device.IdentityKey, - SigningKey: device.SigningKey, - Fingerprint: device.Fingerprint(), - Trust: device.Trust, - } - i++ - } - slices.SortFunc(resp.Devices, func(a, b *ProfileViewDevice) int { - return strings.Compare(a.DeviceID.String(), b.DeviceID.String()) - }) - }() - - wg.Wait() + i++ + } + slices.SortFunc(resp.Devices, func(a, b *ProfileDevice) int { + return strings.Compare(a.DeviceID.String(), b.DeviceID.String()) + }) return &resp, nil } diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 04d1aa5..0eb06de 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -25,7 +25,7 @@ import type { Mentions, MessageEventContent, PaginationResponse, - ProfileView, + ProfileEncryptionInfo, RPCCommand, RPCEvent, RawDBEvent, @@ -176,8 +176,12 @@ export default abstract class RPCClient { return this.request("get_profile", { user_id }) } - getProfileView(room_id: RoomID | undefined, user_id: UserID): Promise { - return this.request("get_profile_view", { room_id, user_id }) + getMutualRooms(user_id: UserID): Promise { + return this.request("get_mutual_rooms", { user_id }) + } + + getProfileEncryptionInfo(user_id: UserID): Promise { + return this.request("get_profile_encryption_info", { user_id }) } ensureGroupSessionShared(room_id: RoomID): Promise { diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index b840aa3..c4bdd49 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -27,7 +27,6 @@ import { RoomID, TombstoneEventContent, UserID, - UserProfile, } from "./mxtypes.ts" export type EventRowID = number @@ -226,7 +225,7 @@ export type TrustState = "blacklisted" | "unverified" | "verified" | "cross-signed-untrusted" | "cross-signed-tofu" | "cross-signed-verified" | "unknown-device" | "forwarded" | "invalid" -export interface ProfileViewDevice { +export interface ProfileDevice { device_id: DeviceID name: string identity_key: string @@ -235,16 +234,11 @@ export interface ProfileViewDevice { trust_state: TrustState } -export interface ProfileView { - global_profile: UserProfile - +export interface ProfileEncryptionInfo { devices_tracked: boolean - devices: ProfileViewDevice[] + devices: ProfileDevice[] master_key: string first_master_key: string user_trusted: boolean - - mutual_rooms: RoomID[] - errors: string[] } diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index 1db79b5..6ce57e3 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -112,11 +112,9 @@ div.right-panel-content.user { padding: .25rem 1rem; } - div.full-info-loading { - display: flex; - align-items: center; - gap: .5rem; - flex-direction: column; + .user-info-loader { + display: flex !important; + justify-content: center; } p.verified-message { diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index 5f07d09..e75ac97 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -14,18 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { use, useEffect, useState } from "react" -import { PuffLoader, ScaleLoader } from "react-spinners" -import Client from "@/api/client.ts" +import { PuffLoader } from "react-spinners" import { getAvatarURL } from "@/api/media.ts" -import { RoomStateStore, useRoomState } from "@/api/statestore" -import { MemberEventContent, ProfileView, UserID } from "@/api/types" +import { useRoomState } from "@/api/statestore" +import { MemberEventContent, UserID, UserProfile } from "@/api/types" import { getLocalpart } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import { LightboxContext } from "../modal/Lightbox.tsx" import { RoomContext } from "../roomview/roomcontext.ts" import DeviceList from "./UserInfoDeviceList.tsx" +import UserInfoError from "./UserInfoError.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx" -import ErrorIcon from "@/icons/error.svg?react" interface UserInfoProps { userID: UserID @@ -40,30 +39,27 @@ const UserInfo = ({ userID }: UserInfoProps) => { if (!memberEvt) { use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID) } - const [view, setView] = useState(null) - const [errors, setErrors] = useState([]) + const [globalProfile, setGlobalProfile] = useState(null) + const [errors, setErrors] = useState(null) useEffect(() => { - setErrors([]) - setView(null) - client.rpc.getProfileView(roomCtx?.store.roomID, userID).then( - resp => { - setView(resp) - setErrors(resp.errors) - }, + setErrors(null) + setGlobalProfile(null) + client.rpc.getProfile(userID).then( + setGlobalProfile, err => setErrors([`${err}`]), ) }, [roomCtx, userID, client]) - const displayname = member?.displayname || view?.global_profile?.displayname || getLocalpart(userID) + const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID) return <>
- {member === null && view === null && !errors.length ? : } @@ -71,45 +67,14 @@ const UserInfo = ({ userID }: UserInfoProps) => {
{displayname}
{userID}

- {renderFullInfo(client, roomCtx?.store, view, !!errors.length)} - {renderErrors(errors)} - -} - -function renderErrors(errors: string[]) { - if (!errors.length) { - return null - } - return
{errors.map((err, i) =>
-
-

{err}

-
)}
-} - -function renderFullInfo( - client: Client, - room: RoomStateStore | undefined, - view: ProfileView | null, - hasErrors: boolean, -) { - if (view === null) { - if (hasErrors) { - return null - } - return <> -
- Loading full profile - -
+ {userID !== client.userID && <> +
- - } - return <> - {view.mutual_rooms && } - + } + +
+ } - - export default UserInfo diff --git a/web/src/ui/rightpanel/UserInfoDeviceList.tsx b/web/src/ui/rightpanel/UserInfoDeviceList.tsx index 8b0edd6..16bef63 100644 --- a/web/src/ui/rightpanel/UserInfoDeviceList.tsx +++ b/web/src/ui/rightpanel/UserInfoDeviceList.tsx @@ -13,63 +13,54 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { useEffect, useState } from "react" +import { ScaleLoader } from "react-spinners" +import Client from "@/api/client.ts" import { RoomStateStore } from "@/api/statestore" -import { ProfileView, ProfileViewDevice, TrustState } from "@/api/types" +import { ProfileDevice, ProfileEncryptionInfo, TrustState, UserID } from "@/api/types" +import UserInfoError from "./UserInfoError.tsx" import EncryptedOffIcon from "@/icons/encrypted-off.svg?react" import EncryptedQuestionIcon from "@/icons/encrypted-question.svg?react" import EncryptedIcon from "@/icons/encrypted.svg?react" -function trustStateDescription(state: TrustState): string { - switch (state) { - case "blacklisted": - return "Device has been blacklisted manually" - case "unverified": - return "Device has not been verified by cross-signing keys, or cross-signing keys were not found" - case "verified": - return "Device was verified manually" - case "cross-signed-untrusted": - return "Device is cross-signed, cross-signing keys are NOT trusted" - case "cross-signed-tofu": - return "Device is cross-signed, cross-signing keys were trusted on first use" - case "cross-signed-verified": - return "Device is cross-signed, cross-signing keys were verified manually" - default: - return "Invalid trust state" - } -} - -function renderDevice(device: ProfileViewDevice) { - let Icon = EncryptedIcon - if (device.trust_state === "blacklisted") { - Icon = EncryptedOffIcon - } else if (device.trust_state === "cross-signed-untrusted" || device.trust_state === "unverified") { - Icon = EncryptedQuestionIcon - } - return
  • -
    -
    {device.name || device.device_id}
    -
  • -} - interface DeviceListProps { - view: ProfileView + client: Client room?: RoomStateStore + userID: UserID } -const DeviceList = ({ view, room }: DeviceListProps) => { +const DeviceList = ({ client, room, userID }: DeviceListProps) => { + const [view, setEncryptionInfo] = useState(null) + const [errors, setErrors] = useState(null) + useEffect(() => { + setEncryptionInfo(null) + setErrors(null) + client.rpc.getProfileEncryptionInfo(userID).then( + resp => { + setEncryptionInfo(resp) + setErrors(resp.errors) + }, + err => setErrors([`${err}`]), + ) + }, [client, userID]) const isEncrypted = room?.meta.current.encryption_event?.algorithm === "m.megolm.v1.aes-sha2" const encryptionMessage = isEncrypted ? "Messages in this room are end-to-end encrypted." : "Messages in this room are not end-to-end encrypted." + if (view === null) { + return
    +

    Security

    +

    {encryptionMessage}

    + {!errors ? : null} + +
    + } if (!view.devices_tracked) { return

    Security

    {encryptionMessage}

    This user's device list is not being tracked.

    -
    +
    } let verifiedMessage = null @@ -96,8 +87,43 @@ const DeviceList = ({ view, room }: DeviceListProps) => {

    {view.devices.length} devices

      {view.devices.map(renderDevice)}
    -
    +
    } +function renderDevice(device: ProfileDevice) { + let Icon = EncryptedIcon + if (device.trust_state === "blacklisted") { + Icon = EncryptedOffIcon + } else if (device.trust_state === "cross-signed-untrusted" || device.trust_state === "unverified") { + Icon = EncryptedQuestionIcon + } + return
  • +
    +
    {device.name || device.device_id}
    +
  • +} + +function trustStateDescription(state: TrustState): string { + switch (state) { + case "blacklisted": + return "Device has been blacklisted manually" + case "unverified": + return "Device has not been verified by cross-signing keys, or cross-signing keys were not found" + case "verified": + return "Device was verified manually" + case "cross-signed-untrusted": + return "Device is cross-signed, cross-signing keys are NOT trusted" + case "cross-signed-tofu": + return "Device is cross-signed, cross-signing keys were trusted on first use" + case "cross-signed-verified": + return "Device is cross-signed, cross-signing keys were verified manually" + default: + return "Invalid trust state" + } +} + export default DeviceList diff --git a/web/src/ui/rightpanel/UserInfoError.tsx b/web/src/ui/rightpanel/UserInfoError.tsx new file mode 100644 index 0000000..9d87c35 --- /dev/null +++ b/web/src/ui/rightpanel/UserInfoError.tsx @@ -0,0 +1,28 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import ErrorIcon from "@/icons/error.svg?react" + +const UserInfoError = ({ errors }: { errors: string[] | null }) => { + if (!errors?.length) { + return null + } + return
    {errors.map((err, i) =>
    +
    +

    {err}

    +
    )}
    +} + +export default UserInfoError diff --git a/web/src/ui/rightpanel/UserInfoMutualRooms.tsx b/web/src/ui/rightpanel/UserInfoMutualRooms.tsx index 51cb0f0..3238699 100644 --- a/web/src/ui/rightpanel/UserInfoMutualRooms.tsx +++ b/web/src/ui/rightpanel/UserInfoMutualRooms.tsx @@ -13,47 +13,65 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useMemo, useReducer } from "react" +import { useEffect, useReducer, useState } from "react" +import { ScaleLoader } from "react-spinners" import Client from "@/api/client.ts" import { RoomListEntry } from "@/api/statestore" -import { RoomID } from "@/api/types" +import { UserID } from "@/api/types" import ListEntry from "../roomlist/Entry.tsx" +import UserInfoError from "./UserInfoError.tsx" interface MutualRoomsProps { client: Client - rooms: RoomID[] + userID: UserID } -const MutualRooms = ({ client, rooms }: MutualRoomsProps) => { +const MutualRooms = ({ client, userID }: MutualRoomsProps) => { + const [rooms, setRooms] = useState(null) + const [errors, setErrors] = useState(null) + useEffect(() => { + setRooms(null) + setErrors(null) + client.rpc.getMutualRooms(userID).then( + rooms => setRooms(rooms.map((roomID): RoomListEntry | null => { + const roomData = client.store.rooms.get(roomID) + if (!roomData || roomData.hidden) { + return null + } + return { + room_id: roomID, + dm_user_id: roomData.meta.current.lazy_load_summary?.heroes?.length === 1 + ? roomData.meta.current.lazy_load_summary.heroes[0] : undefined, + name: roomData.meta.current.name ?? "Unnamed room", + avatar: roomData.meta.current.avatar, + search_name: "", + sorting_timestamp: 0, + unread_messages: 0, + unread_notifications: 0, + unread_highlights: 0, + marked_unread: false, + } + }).filter((data): data is RoomListEntry => !!data)), + err => setErrors([`${err}`]), + ) + }, [client, userID]) const [maxCount, increaseMaxCount] = useReducer(count => count + 10, 3) - const mappedRooms = useMemo(() => rooms.map((roomID): RoomListEntry | null => { - const roomData = client.store.rooms.get(roomID) - if (!roomData || roomData.hidden) { - return null - } - return { - room_id: roomID, - dm_user_id: roomData.meta.current.lazy_load_summary?.heroes?.length === 1 - ? roomData.meta.current.lazy_load_summary.heroes[0] : undefined, - name: roomData.meta.current.name ?? "Unnamed room", - avatar: roomData.meta.current.avatar, - search_name: "", - sorting_timestamp: 0, - unread_messages: 0, - unread_notifications: 0, - unread_highlights: 0, - marked_unread: false, - } - }).filter((data): data is RoomListEntry => !!data), [client, rooms]) + if (!rooms) { + return
    +

    Shared rooms

    + {rooms === undefined && } + +
    + } return

    Shared rooms

    - {mappedRooms.slice(0, maxCount).map(room =>
    + {rooms.slice(0, maxCount).map(room =>
    )} - {mappedRooms.length > maxCount && } -
    +
    }