1
0
Fork 0
forked from Mirrors/gomuks

web/rightpanel: add more info to user view

This commit is contained in:
Tulir Asokan 2024-12-05 01:23:50 +02:00
parent 2f22159da3
commit 72dab88ed2
15 changed files with 500 additions and 43 deletions

View file

@ -73,7 +73,7 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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 mvdan.cc/xurls/v2 v2.5.0 // indirect
) )

View file

@ -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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.20241205122504-933daead3b34 h1:oDfWbC8MwcMwrqtIhQ3uQBP23C/B9YqAUfKkDxToFm0=
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/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

2
go.mod
View file

@ -25,7 +25,7 @@ require (
golang.org/x/text v0.20.0 golang.org/x/text v0.20.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 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 mvdan.cc/xurls/v2 v2.5.0
) )

4
go.sum
View file

@ -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= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 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.20241205122504-933daead3b34 h1:oDfWbC8MwcMwrqtIhQ3uQBP23C/B9YqAUfKkDxToFm0=
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/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -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 unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
return h.Client.GetProfile(ctx, params.UserID) 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": case "get_event":
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
return h.GetEvent(ctx, params.RoomID, params.EventID) return h.GetEvent(ctx, params.RoomID, params.EventID)
@ -238,6 +242,11 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"` UserID id.UserID `json:"user_id"`
} }
type getProfileViewParams struct {
RoomID id.RoomID `json:"room_id"`
UserID id.UserID `json:"user_id"`
}
type getEventParams struct { type getEventParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`

183
pkg/hicli/profile.go Normal file
View file

@ -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
}

View file

@ -25,6 +25,7 @@ import type {
Mentions, Mentions,
MessageEventContent, MessageEventContent,
PaginationResponse, PaginationResponse,
ProfileView,
RPCCommand, RPCCommand,
RPCEvent, RPCEvent,
RawDBEvent, RawDBEvent,
@ -175,6 +176,10 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id }) return this.request("get_profile", { user_id })
} }
getProfileView(room_id: RoomID | undefined, user_id: UserID): Promise<ProfileView> {
return this.request("get_profile_view", { room_id, user_id })
}
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> { ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
return this.request("ensure_group_session_shared", { room_id }) return this.request("ensure_group_session_shared", { room_id })
} }

View file

@ -121,12 +121,18 @@ export class StateStore {
} }
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null { #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
if (this.#shouldHideRoom(entry)) {
return null
}
if (!room) { if (!room) {
room = this.rooms.get(entry.meta.room_id) 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_event = room?.eventsByRowID.get(entry.meta.preview_event_rowid)
const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender) const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender)
const name = entry.meta.name ?? "Unnamed room" const name = entry.meta.name ?? "Unnamed room"

View file

@ -113,6 +113,7 @@ export class RoomStateStore {
paginationRequestedForRow = -1 paginationRequestedForRow = -1
readUpToRow = -1 readUpToRow = -1
hasMoreHistory = true hasMoreHistory = true
hidden = false
constructor(meta: DBRoom, private parent: StateStore) { constructor(meta: DBRoom, private parent: StateStore) {
this.roomID = meta.room_id this.roomID = meta.room_id

View file

@ -16,6 +16,7 @@
import { import {
ContentURI, ContentURI,
CreateEventContent, CreateEventContent,
DeviceID,
EncryptedEventContent, EncryptedEventContent,
EncryptionEventContent, EncryptionEventContent,
EventID, EventID,
@ -26,6 +27,7 @@ import {
RoomID, RoomID,
TombstoneEventContent, TombstoneEventContent,
UserID, UserID,
UserProfile,
} from "./mxtypes.ts" } from "./mxtypes.ts"
export type EventRowID = number export type EventRowID = number
@ -219,3 +221,30 @@ export interface JWTLoginRequest {
} }
export type LoginRequest = PasswordLoginRequest | SSOLoginRequest | 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[]
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M551-523q5-8 7-17.5t2-19.5q0-33-23.5-56.5T480-640q-10 0-19.5 2t-17.5 7l108 108Zm203 205-60-62q12-32 19-66.5t7-69.5v-189l-240-90-146 55-62-62 208-78 320 120v244q0 51-11.5 101T754-318Zm38 262L662-186q-38 39-84.5 65.5T480-80q-139-35-229.5-159.5T160-516v-172L56-792l56-56 736 736-56 56ZM430-418Zm57-170Zm-7 424q35-11 67-31t59-47L488-360h-68l10-58-190-190v92q0 121 68 220t172 132Z"/></svg>

After

Width:  |  Height:  |  Size: 501 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-139-35-229.5-159.5T160-516v-244l320-120 320 120v244q0 152-90.5 276.5T480-80Zm0-84q104-33 172-132t68-220v-189l-240-90-240 90v189q0 121 68 220t172 132Zm0-316Zm0 200q17 0 29.5-12.5T522-322q0-17-12.5-29.5T480-364q-17 0-29.5 12.5T438-322q0 17 12.5 29.5T480-280Zm-29-128h60v-22q0-11 5-21 6-14 16-23.5t21-19.5q17-17 29.5-38t12.5-46q0-45-34.5-73.5T480-680q-40 0-71.5 23T366-596l54 22q6-20 22.5-34t37.5-14q22 0 38.5 13t16.5 33q0 17-10.5 31.5T501-518q-12 11-24 22.5T458-469q-7 14-7 29.5v31.5Z"/></svg>

After

Width:  |  Height:  |  Size: 617 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M420-360h120l-23-129q20-10 31.5-29t11.5-42q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 23 11.5 42t31.5 29l-23 129Zm60 280q-139-35-229.5-159.5T160-516v-244l320-120 320 120v244q0 152-90.5 276.5T480-80Zm0-84q104-33 172-132t68-220v-189l-240-90-240 90v189q0 121 68 220t172 132Zm0-316Z"/></svg>

After

Width:  |  Height:  |  Size: 410 B

View file

@ -63,6 +63,7 @@ div.right-panel-content.user {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
flex-shrink: 0;
> img { > img {
width: 100%; width: 100%;
@ -88,6 +89,84 @@ div.right-panel-content.user {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
word-break: break-word; 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 { div.right-panel-content.members {

View file

@ -13,71 +13,213 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useEffect, useState } from "react" import { use, useEffect, useMemo, useReducer, useState } from "react"
import { PuffLoader } from "react-spinners" import { PuffLoader, ScaleLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore" import { RoomListEntry, RoomStateStore, useRoomState } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile } from "@/api/types" import { MemberEventContent, ProfileView, ProfileViewDevice, RoomID, TrustState, UserID } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts" import { getLocalpart } from "@/util/validation.ts"
import ClientContext from "../ClientContext.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 { 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 { interface UserInfoProps {
userID: UserID userID: UserID
} }
const UserInfo = ({ userID }: UserInfoProps) => { const UserInfo = ({ userID }: UserInfoProps) => {
const client = use(ClientContext)!
const roomCtx = use(RoomContext) const roomCtx = use(RoomContext)
const openLightbox = use(LightboxContext)! const openLightbox = use(LightboxContext)!
const memberEvt = useRoomState(roomCtx?.store, "m.room.member", userID) const memberEvt = useRoomState(roomCtx?.store, "m.room.member", userID)
const member = (memberEvt?.content ?? null) as MemberEventContent | null
if (!memberEvt) { if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID) use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID)
} }
const memberEvtContent = memberEvt?.content as MemberEventContent const [view, setView] = useState<ProfileView | null>(null)
if (!memberEvtContent) { const [errors, setErrors] = useState<string[]>([])
return <NonMemberInfo userID={userID}/>
}
return renderUserInfo({ userID, profile: memberEvtContent, error: null, openLightbox })
}
const NonMemberInfo = ({ userID }: UserInfoProps) => {
const openLightbox = use(LightboxContext)!
const client = use(ClientContext)!
const [profile, setProfile] = useState<UserProfile | null>(null)
const [error, setError] = useState<unknown>(null)
useEffect(() => { useEffect(() => {
client.rpc.getProfile(userID).then(setProfile, setError) client.rpc.getProfileView(roomCtx?.store.roomID, userID).then(
}, [userID, client]) resp => {
return renderUserInfo({ userID, profile, error, openLightbox }) setView(resp)
} setErrors(resp.errors)
},
err => setErrors([`${err}`]),
)
}, [roomCtx, userID, client])
interface RenderUserInfoParams { const displayname = member?.displayname || view?.global_profile?.displayname || getLocalpart(userID)
userID: UserID
profile: UserProfile | null
error: unknown
openLightbox: OpenLightboxType
}
function renderUserInfo({ userID, profile, error, openLightbox }: RenderUserInfoParams) {
const displayname = getDisplayname(userID, profile)
return <> return <>
<div className="avatar-container"> <div className="avatar-container">
{profile === null && error === null ? <PuffLoader {member === null && view === null && !errors.length ? <PuffLoader
color="var(--primary-color)" color="var(--primary-color)"
size="100%" size="100%"
className="avatar-loader" className="avatar-loader"
/> : <img /> : <img
className="avatar" className="avatar"
src={getAvatarURL(userID, profile)} src={getAvatarURL(userID, member ?? view?.global_profile)}
onClick={openLightbox} onClick={openLightbox}
alt="" alt=""
/>} />}
</div> </div>
<div className="displayname" title={displayname}>{displayname}</div> <div className="displayname" title={displayname}>{displayname}</div>
<div className="userid" title={userID}>{userID}</div> <div className="userid" title={userID}>{userID}</div>
{error ? <div className="error">{`${error}`}</div> : null} <hr/>
{renderFullInfo(client, roomCtx?.store, view, !!errors.length)}
{renderErrors(errors)}
</> </>
} }
function renderErrors(errors: string[]) {
if (!errors.length) {
return null
}
return <div className="error">{errors.map((err, i) => <p key={i}>{err}</p>)}</div>
}
function renderFullInfo(
client: Client,
room: RoomStateStore | undefined,
view: ProfileView | null,
hasErrors: boolean,
) {
if (view === null) {
if (hasErrors) {
return null
}
return <>
<div className="full-info-loading">
Loading full profile
<ScaleLoader color="var(--primary-color)"/>
</div>
<hr/>
</>
}
return <>
{view.mutual_rooms && <MutualRooms client={client} rooms={view.mutual_rooms}/>}
<DeviceList view={view} room={room}/>
</>
}
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 <li key={device.device_id} className="device">
<div
className={`icon-wrapper trust-${device.trust_state}`}
title={trustStateDescription(device.trust_state)}
><Icon/></div>
<div title={device.device_id}>{device.name || device.device_id}</div>
</li>
}
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 <div className="devices not-tracked">
<h4>Security</h4>
<p>{encryptionMessage}</p>
<p>This user's device list is not being tracked.</p>
</div>
}
let verifiedMessage = null
if (view.user_trusted) {
verifiedMessage = <p className="verified-message verified" title={view.master_key}>
<EncryptedIcon/> You have verified this user
</p>
} else if (view.master_key) {
if (view.master_key === view.first_master_key) {
verifiedMessage = <p className="verified-message tofu" title={view.master_key}>
<EncryptedIcon/> Trusted master key on first use
</p>
} else {
verifiedMessage = <p className="verified-message tofu-broken" title={view.master_key}>
<EncryptedQuestionIcon/> Master key has changed
</p>
}
}
return <div className="devices">
<h4>Security</h4>
<p>{encryptionMessage}</p>
{verifiedMessage}
<h4>{view.devices.length} devices</h4>
<ul>{view.devices.map(renderDevice)}</ul>
<hr/>
</div>
}
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 <div className="mutual-rooms">
<h4>Shared rooms</h4>
{mappedRooms.slice(0, maxCount).map(room => <div key={room.room_id}>
<ListEntry room={room} isActive={false} hidden={false}/>
</div>)}
{mappedRooms.length > maxCount && <button className="show-more" onClick={increaseMaxCount}>
Show {mappedRooms.length - maxCount} more
</button>}
<hr/>
</div>
}
export default UserInfo export default UserInfo