diff --git a/desktop/go.mod b/desktop/go.mod index ea049c1..4f60534 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -73,7 +73,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - maunium.net/go/mautrix v0.22.1-0.20241126202918-4b970e0ea7e6 // indirect + maunium.net/go/mautrix v0.22.1-0.20241205122504-933daead3b34 // indirect mvdan.cc/xurls/v2 v2.5.0 // indirect ) diff --git a/desktop/go.sum b/desktop/go.sum index 3889baa..151c356 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -250,7 +250,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.22.1-0.20241126202918-4b970e0ea7e6 h1:oAvlXBeScl5C0oIe10MC6E4BE/X6FAsDuhl4Qfgq2Z0= -maunium.net/go/mautrix v0.22.1-0.20241126202918-4b970e0ea7e6/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= +maunium.net/go/mautrix v0.22.1-0.20241205122504-933daead3b34 h1:oDfWbC8MwcMwrqtIhQ3uQBP23C/B9YqAUfKkDxToFm0= +maunium.net/go/mautrix v0.22.1-0.20241205122504-933daead3b34/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/go.mod b/go.mod index ae0f8fc..9a78a46 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( golang.org/x/text v0.20.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.22.1-0.20241126202918-4b970e0ea7e6 + maunium.net/go/mautrix v0.22.1-0.20241205122504-933daead3b34 mvdan.cc/xurls/v2 v2.5.0 ) diff --git a/go.sum b/go.sum index 6c3193d..a86545a 100644 --- a/go.sum +++ b/go.sum @@ -91,7 +91,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.22.1-0.20241126202918-4b970e0ea7e6 h1:oAvlXBeScl5C0oIe10MC6E4BE/X6FAsDuhl4Qfgq2Z0= -maunium.net/go/mautrix v0.22.1-0.20241126202918-4b970e0ea7e6/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= +maunium.net/go/mautrix v0.22.1-0.20241205122504-933daead3b34 h1:oDfWbC8MwcMwrqtIhQ3uQBP23C/B9YqAUfKkDxToFm0= +maunium.net/go/mautrix v0.22.1-0.20241205122504-933daead3b34/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index c334b6b..253dd0e 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -86,6 +86,10 @@ 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_event": return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { return h.GetEvent(ctx, params.RoomID, params.EventID) @@ -238,6 +242,11 @@ 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 new file mode 100644 index 0000000..592d49e --- /dev/null +++ b/pkg/hicli/profile.go @@ -0,0 +1,183 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package hicli + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type ProfileViewDevice struct { + DeviceID id.DeviceID `json:"device_id"` + Name string `json:"name"` + IdentityKey id.Curve25519 `json:"identity_key"` + SigningKey id.Ed25519 `json:"signing_key"` + Fingerprint string `json:"fingerprint"` + 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"` +} + +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() + } + + go func() { + defer wg.Done() + profile, err := h.Client.GetProfile(ctx, userID) + 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 + } + }() + 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.MutualRooms = mutualRooms.Joined + nextBatch = mutualRooms.NextBatch + if nextBatch == "" { + break + } + } + } + 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 + } + 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 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() + return &resp, nil +} diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index d6bc065..04d1aa5 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -25,6 +25,7 @@ import type { Mentions, MessageEventContent, PaginationResponse, + ProfileView, RPCCommand, RPCEvent, RawDBEvent, @@ -175,6 +176,10 @@ 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 }) + } + ensureGroupSessionShared(room_id: RoomID): Promise { return this.request("ensure_group_session_shared", { room_id }) } diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 52bea6a..2ac5be5 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -121,12 +121,18 @@ export class StateStore { } #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null { - if (this.#shouldHideRoom(entry)) { - return null - } if (!room) { room = this.rooms.get(entry.meta.room_id) } + if (this.#shouldHideRoom(entry)) { + if (room) { + room.hidden = true + } + return null + } + if (room?.hidden) { + room.hidden = false + } const preview_event = room?.eventsByRowID.get(entry.meta.preview_event_rowid) const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender) const name = entry.meta.name ?? "Unnamed room" diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 4ab42b4..daf9955 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -113,6 +113,7 @@ export class RoomStateStore { paginationRequestedForRow = -1 readUpToRow = -1 hasMoreHistory = true + hidden = false constructor(meta: DBRoom, private parent: StateStore) { this.roomID = meta.room_id diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 3b65e9a..b840aa3 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -16,6 +16,7 @@ import { ContentURI, CreateEventContent, + DeviceID, EncryptedEventContent, EncryptionEventContent, EventID, @@ -26,6 +27,7 @@ import { RoomID, TombstoneEventContent, UserID, + UserProfile, } from "./mxtypes.ts" export type EventRowID = number @@ -219,3 +221,30 @@ export interface JWTLoginRequest { } export type LoginRequest = PasswordLoginRequest | SSOLoginRequest | JWTLoginRequest + +export type TrustState = "blacklisted" | "unverified" | "verified" + | "cross-signed-untrusted" | "cross-signed-tofu" | "cross-signed-verified" + | "unknown-device" | "forwarded" | "invalid" + +export interface ProfileViewDevice { + device_id: DeviceID + name: string + identity_key: string + signing_key: string + fingerprint: string + trust_state: TrustState +} + +export interface ProfileView { + global_profile: UserProfile + + devices_tracked: boolean + devices: ProfileViewDevice[] + master_key: string + first_master_key: string + user_trusted: boolean + + mutual_rooms: RoomID[] + + errors: string[] +} diff --git a/web/src/icons/encrypted-off.svg b/web/src/icons/encrypted-off.svg new file mode 100644 index 0000000..d56b6fc --- /dev/null +++ b/web/src/icons/encrypted-off.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/encrypted-question.svg b/web/src/icons/encrypted-question.svg new file mode 100644 index 0000000..038a479 --- /dev/null +++ b/web/src/icons/encrypted-question.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/encrypted.svg b/web/src/icons/encrypted.svg new file mode 100644 index 0000000..b285665 --- /dev/null +++ b/web/src/icons/encrypted.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index f4adb95..d0aa31f 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -63,6 +63,7 @@ div.right-panel-content.user { justify-content: center; align-items: center; padding: 1rem; + flex-shrink: 0; > img { width: 100%; @@ -88,6 +89,84 @@ div.right-panel-content.user { -webkit-box-orient: vertical; word-break: break-word; } + + hr { + width: 100%; + opacity: .2; + } + + p { + margin: .5rem 0; + } + + h4 { + margin: 0 0 .5rem; + } + + h5 { + margin: 0 0 .25rem; + } + + button.show-more { + width: 100%; + padding: .25rem 1rem; + } + + div.full-info-loading { + display: flex; + align-items: center; + gap: .5rem; + flex-direction: column; + } + + p.verified-message { + display: flex; + gap: .25rem; + font-weight: bold; + + &.verified { + color: var(--primary-color-dark); + } + &.tofu-broken { + color: darkorange; + } + } + + div.devices > ul { + list-style-type: none; + padding: 0; + margin: 0; + gap: .25rem; + display: flex; + flex-direction: column; + + li.device { + display: flex; + align-items: center; + gap: .25rem; + + > .icon-wrapper { + height: 1.5rem; + width: 1.5rem; + + &.trust-blacklisted { + color: var(--error-color); + } + + &.trust-cross-signed-untrusted, &.trust-unverified { + color: darkorange; + } + + &.trust-verified, &.trust-cross-signed-verified { + color: var(--primary-color); + } + + &.trust-cross-signed-tofu { + color: var(--primary-color-dark); + } + } + } + } } div.right-panel-content.members { diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index aac2467..2bdeb9f 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -13,71 +13,213 @@ // // 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 } from "react-spinners" +import { use, useEffect, useMemo, useReducer, useState } from "react" +import { PuffLoader, ScaleLoader } from "react-spinners" +import Client from "@/api/client.ts" import { getAvatarURL } from "@/api/media.ts" -import { useRoomState } from "@/api/statestore" -import { MemberEventContent, UserID, UserProfile } from "@/api/types" -import { getDisplayname } from "@/util/validation.ts" +import { RoomListEntry, RoomStateStore, useRoomState } from "@/api/statestore" +import { MemberEventContent, ProfileView, ProfileViewDevice, RoomID, TrustState, UserID } from "@/api/types" +import { getLocalpart } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" -import { LightboxContext, OpenLightboxType } from "../modal/Lightbox.tsx" +import { LightboxContext } from "../modal/Lightbox.tsx" +import ListEntry from "../roomlist/Entry.tsx" import { RoomContext } from "../roomview/roomcontext.ts" +import EncryptedOffIcon from "@/icons/encrypted-off.svg?react" +import EncryptedQuestionIcon from "@/icons/encrypted-question.svg?react" +import EncryptedIcon from "@/icons/encrypted.svg?react" interface UserInfoProps { userID: UserID } const UserInfo = ({ userID }: UserInfoProps) => { + const client = use(ClientContext)! const roomCtx = use(RoomContext) const openLightbox = use(LightboxContext)! const memberEvt = useRoomState(roomCtx?.store, "m.room.member", userID) + const member = (memberEvt?.content ?? null) as MemberEventContent | null if (!memberEvt) { use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID) } - const memberEvtContent = memberEvt?.content as MemberEventContent - if (!memberEvtContent) { - return - } - return renderUserInfo({ userID, profile: memberEvtContent, error: null, openLightbox }) -} - -const NonMemberInfo = ({ userID }: UserInfoProps) => { - const openLightbox = use(LightboxContext)! - const client = use(ClientContext)! - const [profile, setProfile] = useState(null) - const [error, setError] = useState(null) + const [view, setView] = useState(null) + const [errors, setErrors] = useState([]) useEffect(() => { - client.rpc.getProfile(userID).then(setProfile, setError) - }, [userID, client]) - return renderUserInfo({ userID, profile, error, openLightbox }) -} + client.rpc.getProfileView(roomCtx?.store.roomID, userID).then( + resp => { + setView(resp) + setErrors(resp.errors) + }, + err => setErrors([`${err}`]), + ) + }, [roomCtx, userID, client]) -interface RenderUserInfoParams { - userID: UserID - profile: UserProfile | null - error: unknown - openLightbox: OpenLightboxType -} - -function renderUserInfo({ userID, profile, error, openLightbox }: RenderUserInfoParams) { - const displayname = getDisplayname(userID, profile) + const displayname = member?.displayname || view?.global_profile?.displayname || getLocalpart(userID) return <>
- {profile === null && error === null ? : }
{displayname}
{userID}
- {error ?
{`${error}`}
: null} +
+ {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 + +
+
+ + } + return <> + {view.mutual_rooms && } + + +} + +interface DeviceListProps { + view: ProfileView + room?: RoomStateStore +} + +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}
    +
  • +} + +const DeviceList = ({ view, room }: DeviceListProps) => { + 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.devices_tracked) { + return
    +

    Security

    +

    {encryptionMessage}

    +

    This user's device list is not being tracked.

    +
    + } + let verifiedMessage = null + if (view.user_trusted) { + verifiedMessage =

    + You have verified this user +

    + } else if (view.master_key) { + if (view.master_key === view.first_master_key) { + verifiedMessage =

    + Trusted master key on first use +

    + } else { + verifiedMessage =

    + Master key has changed +

    + } + } + return
    +

    Security

    +

    {encryptionMessage}

    + {verifiedMessage} +

    {view.devices.length} devices

    +
      {view.devices.map(renderDevice)}
    +
    +
    +} + +interface MutualRoomsProps { + client: Client + rooms: RoomID[] +} + +const MutualRooms = ({ client, rooms }: MutualRoomsProps) => { + const [maxCount, increaseMaxCount] = useReducer(count => count + 10, 5) + 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]) + return
    +

    Shared rooms

    + {mappedRooms.slice(0, maxCount).map(room =>
    +
    )} + {mappedRooms.length > maxCount && } +
    +
    +} + export default UserInfo