forked from Mirrors/gomuks
web/rightpanel: fetch different user info sections separately
This commit is contained in:
parent
838a9ce106
commit
f9ae6bd031
9 changed files with 279 additions and 293 deletions
|
@ -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 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":
|
case "get_mutual_rooms":
|
||||||
return unmarshalAndCall(req.Data, func(params *getProfileViewParams) (*ProfileViewData, error) {
|
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
|
||||||
return h.GetProfileView(ctx, params.RoomID, params.UserID)
|
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":
|
case "get_event":
|
||||||
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
||||||
|
@ -242,11 +246,6 @@ 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"`
|
||||||
|
|
|
@ -11,15 +11,34 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"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"`
|
DeviceID id.DeviceID `json:"device_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IdentityKey id.Curve25519 `json:"identity_key"`
|
IdentityKey id.Curve25519 `json:"identity_key"`
|
||||||
|
@ -28,84 +47,24 @@ type ProfileViewDevice struct {
|
||||||
Trust id.TrustState `json:"trust_state"`
|
Trust id.TrustState `json:"trust_state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileViewData struct {
|
type ProfileEncryptionInfo struct {
|
||||||
GlobalProfile *mautrix.RespUserProfile `json:"global_profile"`
|
|
||||||
|
|
||||||
DevicesTracked bool `json:"devices_tracked"`
|
DevicesTracked bool `json:"devices_tracked"`
|
||||||
Devices []*ProfileViewDevice `json:"devices"`
|
Devices []*ProfileDevice `json:"devices"`
|
||||||
MasterKey string `json:"master_key"`
|
MasterKey string `json:"master_key"`
|
||||||
FirstMasterKey string `json:"first_master_key"`
|
FirstMasterKey string `json:"first_master_key"`
|
||||||
UserTrusted bool `json:"user_trusted"`
|
UserTrusted bool `json:"user_trusted"`
|
||||||
|
|
||||||
MutualRooms []id.RoomID `json:"mutual_rooms"`
|
|
||||||
|
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const MutualRoomsBatchLimit = 5
|
func (h *HiClient) GetProfileEncryptionInfo(ctx context.Context, userID id.UserID) (*ProfileEncryptionInfo, error) {
|
||||||
|
var resp ProfileEncryptionInfo
|
||||||
func (h *HiClient) GetProfileView(ctx context.Context, roomID id.RoomID, userID id.UserID) (*ProfileViewData, error) {
|
log := zerolog.Ctx(ctx)
|
||||||
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})
|
userIDs, err := h.CryptoStore.FilterTrackedUsers(ctx, []id.UserID{userID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to check if user's devices are tracked")
|
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 nil, fmt.Errorf("failed to check if user's devices are tracked: %w", err)
|
||||||
return
|
|
||||||
} else if len(userIDs) == 0 {
|
} else if len(userIDs) == 0 {
|
||||||
return
|
return &resp, nil
|
||||||
}
|
}
|
||||||
ownKeys := h.Crypto.GetOwnCrossSigningPublicKeys(ctx)
|
ownKeys := h.Crypto.GetOwnCrossSigningPublicKeys(ctx)
|
||||||
var ownUserSigningKey id.Ed25519
|
var ownUserSigningKey id.Ed25519
|
||||||
|
@ -118,27 +77,25 @@ func (h *HiClient) GetProfileView(ctx context.Context, roomID id.RoomID, userID
|
||||||
theirSelfSignKey := csKeys[id.XSUsageSelfSigning]
|
theirSelfSignKey := csKeys[id.XSUsageSelfSigning]
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to get cross-signing keys")
|
log.Err(err).Msg("Failed to get cross-signing keys")
|
||||||
addError(fmt.Errorf("failed to get cross-signing keys: %w", err))
|
return nil, fmt.Errorf("failed to get cross-signing keys: %w", err)
|
||||||
return
|
|
||||||
} else if csKeys != nil && theirMasterKey.Key != "" {
|
} else if csKeys != nil && theirMasterKey.Key != "" {
|
||||||
resp.MasterKey = theirMasterKey.Key.Fingerprint()
|
resp.MasterKey = theirMasterKey.Key.Fingerprint()
|
||||||
resp.FirstMasterKey = theirMasterKey.First.Fingerprint()
|
resp.FirstMasterKey = theirMasterKey.First.Fingerprint()
|
||||||
selfKeySigned, err := h.CryptoStore.IsKeySignedBy(ctx, userID, theirSelfSignKey.Key, userID, theirMasterKey.Key)
|
selfKeySigned, err := h.CryptoStore.IsKeySignedBy(ctx, userID, theirSelfSignKey.Key, userID, theirMasterKey.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to check if self-signing key is signed by master key")
|
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))
|
return nil, fmt.Errorf("failed to check if self-signing key is signed by master key: %w", err)
|
||||||
} else if !selfKeySigned {
|
} else if !selfKeySigned {
|
||||||
theirSelfSignKey = id.CrossSigningKey{}
|
theirSelfSignKey = id.CrossSigningKey{}
|
||||||
addError(fmt.Errorf("self-signing key is not signed by master key"))
|
resp.Errors = append(resp.Errors, "Self-signing key is not signed by master key")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addError(fmt.Errorf("cross-signing keys not found"))
|
resp.Errors = append(resp.Errors, "Cross-signing keys not found")
|
||||||
}
|
}
|
||||||
devices, err := h.CryptoStore.GetDevices(ctx, userID)
|
devices, err := h.CryptoStore.GetDevices(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to get devices for user")
|
log.Err(err).Msg("Failed to get devices for user")
|
||||||
addError(fmt.Errorf("failed to get devices: %w", err))
|
return nil, fmt.Errorf("failed to get devices: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if userID == h.Account.UserID {
|
if userID == h.Account.UserID {
|
||||||
resp.UserTrusted, err = h.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, userID, h.Crypto.OwnIdentity().SigningKey)
|
resp.UserTrusted, err = h.CryptoStore.IsKeySignedBy(ctx, userID, theirMasterKey.Key, userID, h.Crypto.OwnIdentity().SigningKey)
|
||||||
|
@ -147,15 +104,15 @@ func (h *HiClient) GetProfileView(ctx context.Context, roomID id.RoomID, userID
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to check if user is trusted")
|
log.Err(err).Msg("Failed to check if user is trusted")
|
||||||
addError(fmt.Errorf("failed to check if user is trusted: %w", err))
|
resp.Errors = append(resp.Errors, fmt.Sprintf("Failed to check if user is trusted: %v", err))
|
||||||
}
|
}
|
||||||
resp.Devices = make([]*ProfileViewDevice, len(devices))
|
resp.Devices = make([]*ProfileDevice, len(devices))
|
||||||
i := 0
|
i := 0
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
signatures, err := h.CryptoStore.GetSignaturesForKeyBy(ctx, device.UserID, device.SigningKey, device.UserID)
|
signatures, err := h.CryptoStore.GetSignaturesForKeyBy(ctx, device.UserID, device.SigningKey, device.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Stringer("device_id", device.DeviceID).Msg("Failed to get signatures for device")
|
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))
|
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 != "" {
|
} else if _, signed := signatures[theirSelfSignKey.Key]; signed && device.Trust == id.TrustStateUnset && theirSelfSignKey.Key != "" {
|
||||||
if resp.UserTrusted {
|
if resp.UserTrusted {
|
||||||
device.Trust = id.TrustStateCrossSignedVerified
|
device.Trust = id.TrustStateCrossSignedVerified
|
||||||
|
@ -165,7 +122,7 @@ func (h *HiClient) GetProfileView(ctx context.Context, roomID id.RoomID, userID
|
||||||
device.Trust = id.TrustStateCrossSignedUntrusted
|
device.Trust = id.TrustStateCrossSignedUntrusted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp.Devices[i] = &ProfileViewDevice{
|
resp.Devices[i] = &ProfileDevice{
|
||||||
DeviceID: device.DeviceID,
|
DeviceID: device.DeviceID,
|
||||||
Name: device.Name,
|
Name: device.Name,
|
||||||
IdentityKey: device.IdentityKey,
|
IdentityKey: device.IdentityKey,
|
||||||
|
@ -175,11 +132,8 @@ func (h *HiClient) GetProfileView(ctx context.Context, roomID id.RoomID, userID
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
slices.SortFunc(resp.Devices, func(a, b *ProfileViewDevice) int {
|
slices.SortFunc(resp.Devices, func(a, b *ProfileDevice) int {
|
||||||
return strings.Compare(a.DeviceID.String(), b.DeviceID.String())
|
return strings.Compare(a.DeviceID.String(), b.DeviceID.String())
|
||||||
})
|
})
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import type {
|
||||||
Mentions,
|
Mentions,
|
||||||
MessageEventContent,
|
MessageEventContent,
|
||||||
PaginationResponse,
|
PaginationResponse,
|
||||||
ProfileView,
|
ProfileEncryptionInfo,
|
||||||
RPCCommand,
|
RPCCommand,
|
||||||
RPCEvent,
|
RPCEvent,
|
||||||
RawDBEvent,
|
RawDBEvent,
|
||||||
|
@ -176,8 +176,12 @@ 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> {
|
getMutualRooms(user_id: UserID): Promise<RoomID[]> {
|
||||||
return this.request("get_profile_view", { room_id, user_id })
|
return this.request("get_mutual_rooms", { user_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfileEncryptionInfo(user_id: UserID): Promise<ProfileEncryptionInfo> {
|
||||||
|
return this.request("get_profile_encryption_info", { user_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
|
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
|
||||||
|
|
|
@ -27,7 +27,6 @@ import {
|
||||||
RoomID,
|
RoomID,
|
||||||
TombstoneEventContent,
|
TombstoneEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
UserProfile,
|
|
||||||
} from "./mxtypes.ts"
|
} from "./mxtypes.ts"
|
||||||
|
|
||||||
export type EventRowID = number
|
export type EventRowID = number
|
||||||
|
@ -226,7 +225,7 @@ export type TrustState = "blacklisted" | "unverified" | "verified"
|
||||||
| "cross-signed-untrusted" | "cross-signed-tofu" | "cross-signed-verified"
|
| "cross-signed-untrusted" | "cross-signed-tofu" | "cross-signed-verified"
|
||||||
| "unknown-device" | "forwarded" | "invalid"
|
| "unknown-device" | "forwarded" | "invalid"
|
||||||
|
|
||||||
export interface ProfileViewDevice {
|
export interface ProfileDevice {
|
||||||
device_id: DeviceID
|
device_id: DeviceID
|
||||||
name: string
|
name: string
|
||||||
identity_key: string
|
identity_key: string
|
||||||
|
@ -235,16 +234,11 @@ export interface ProfileViewDevice {
|
||||||
trust_state: TrustState
|
trust_state: TrustState
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileView {
|
export interface ProfileEncryptionInfo {
|
||||||
global_profile: UserProfile
|
|
||||||
|
|
||||||
devices_tracked: boolean
|
devices_tracked: boolean
|
||||||
devices: ProfileViewDevice[]
|
devices: ProfileDevice[]
|
||||||
master_key: string
|
master_key: string
|
||||||
first_master_key: string
|
first_master_key: string
|
||||||
user_trusted: boolean
|
user_trusted: boolean
|
||||||
|
|
||||||
mutual_rooms: RoomID[]
|
|
||||||
|
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,11 +112,9 @@ div.right-panel-content.user {
|
||||||
padding: .25rem 1rem;
|
padding: .25rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.full-info-loading {
|
.user-info-loader {
|
||||||
display: flex;
|
display: flex !important;
|
||||||
align-items: center;
|
justify-content: center;
|
||||||
gap: .5rem;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p.verified-message {
|
p.verified-message {
|
||||||
|
|
|
@ -14,18 +14,17 @@
|
||||||
// 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, useState } from "react"
|
||||||
import { PuffLoader, ScaleLoader } from "react-spinners"
|
import { PuffLoader } from "react-spinners"
|
||||||
import Client from "@/api/client.ts"
|
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore, useRoomState } from "@/api/statestore"
|
import { useRoomState } from "@/api/statestore"
|
||||||
import { MemberEventContent, ProfileView, UserID } from "@/api/types"
|
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
|
||||||
import { getLocalpart } from "@/util/validation.ts"
|
import { getLocalpart } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import { LightboxContext } from "../modal/Lightbox.tsx"
|
import { LightboxContext } from "../modal/Lightbox.tsx"
|
||||||
import { RoomContext } from "../roomview/roomcontext.ts"
|
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||||
import DeviceList from "./UserInfoDeviceList.tsx"
|
import DeviceList from "./UserInfoDeviceList.tsx"
|
||||||
|
import UserInfoError from "./UserInfoError.tsx"
|
||||||
import MutualRooms from "./UserInfoMutualRooms.tsx"
|
import MutualRooms from "./UserInfoMutualRooms.tsx"
|
||||||
import ErrorIcon from "@/icons/error.svg?react"
|
|
||||||
|
|
||||||
interface UserInfoProps {
|
interface UserInfoProps {
|
||||||
userID: UserID
|
userID: UserID
|
||||||
|
@ -40,30 +39,27 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
||||||
if (!memberEvt) {
|
if (!memberEvt) {
|
||||||
use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID)
|
use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID)
|
||||||
}
|
}
|
||||||
const [view, setView] = useState<ProfileView | null>(null)
|
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
|
||||||
const [errors, setErrors] = useState<string[]>([])
|
const [errors, setErrors] = useState<string[] | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrors([])
|
setErrors(null)
|
||||||
setView(null)
|
setGlobalProfile(null)
|
||||||
client.rpc.getProfileView(roomCtx?.store.roomID, userID).then(
|
client.rpc.getProfile(userID).then(
|
||||||
resp => {
|
setGlobalProfile,
|
||||||
setView(resp)
|
|
||||||
setErrors(resp.errors)
|
|
||||||
},
|
|
||||||
err => setErrors([`${err}`]),
|
err => setErrors([`${err}`]),
|
||||||
)
|
)
|
||||||
}, [roomCtx, userID, client])
|
}, [roomCtx, userID, client])
|
||||||
|
|
||||||
const displayname = member?.displayname || view?.global_profile?.displayname || getLocalpart(userID)
|
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
|
||||||
return <>
|
return <>
|
||||||
<div className="avatar-container">
|
<div className="avatar-container">
|
||||||
{member === null && view === null && !errors.length ? <PuffLoader
|
{member === null && globalProfile === null && errors == null ? <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, member ?? view?.global_profile)}
|
src={getAvatarURL(userID, member ?? globalProfile)}
|
||||||
onClick={openLightbox}
|
onClick={openLightbox}
|
||||||
alt=""
|
alt=""
|
||||||
/>}
|
/>}
|
||||||
|
@ -71,45 +67,14 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
||||||
<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>
|
||||||
<hr/>
|
<hr/>
|
||||||
{renderFullInfo(client, roomCtx?.store, view, !!errors.length)}
|
{userID !== client.userID && <>
|
||||||
{renderErrors(errors)}
|
<MutualRooms client={client} userID={userID}/>
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderErrors(errors: string[]) {
|
|
||||||
if (!errors.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return <div className="errors">{errors.map((err, i) => <div className="error" key={i}>
|
|
||||||
<div className="icon"><ErrorIcon /></div>
|
|
||||||
<p>{err}</p>
|
|
||||||
</div>)}</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/>
|
<hr/>
|
||||||
|
</>}
|
||||||
|
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
||||||
|
<hr/>
|
||||||
|
<UserInfoError errors={errors}/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
return <>
|
|
||||||
{view.mutual_rooms && <MutualRooms client={client} rooms={view.mutual_rooms}/>}
|
|
||||||
<DeviceList view={view} room={room}/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default UserInfo
|
export default UserInfo
|
||||||
|
|
|
@ -13,63 +13,54 @@
|
||||||
//
|
//
|
||||||
// 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 { useEffect, useState } from "react"
|
||||||
|
import { ScaleLoader } from "react-spinners"
|
||||||
|
import Client from "@/api/client.ts"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
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 EncryptedOffIcon from "@/icons/encrypted-off.svg?react"
|
||||||
import EncryptedQuestionIcon from "@/icons/encrypted-question.svg?react"
|
import EncryptedQuestionIcon from "@/icons/encrypted-question.svg?react"
|
||||||
import EncryptedIcon from "@/icons/encrypted.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 <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>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeviceListProps {
|
interface DeviceListProps {
|
||||||
view: ProfileView
|
client: Client
|
||||||
room?: RoomStateStore
|
room?: RoomStateStore
|
||||||
|
userID: UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeviceList = ({ view, room }: DeviceListProps) => {
|
const DeviceList = ({ client, room, userID }: DeviceListProps) => {
|
||||||
|
const [view, setEncryptionInfo] = useState<ProfileEncryptionInfo | null>(null)
|
||||||
|
const [errors, setErrors] = useState<string[] | null>(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 isEncrypted = room?.meta.current.encryption_event?.algorithm === "m.megolm.v1.aes-sha2"
|
||||||
const encryptionMessage = isEncrypted
|
const encryptionMessage = isEncrypted
|
||||||
? "Messages in this room are end-to-end encrypted."
|
? "Messages in this room are end-to-end encrypted."
|
||||||
: "Messages in this room are not end-to-end encrypted."
|
: "Messages in this room are not end-to-end encrypted."
|
||||||
|
if (view === null) {
|
||||||
|
return <div className="devices not-tracked">
|
||||||
|
<h4>Security</h4>
|
||||||
|
<p>{encryptionMessage}</p>
|
||||||
|
{!errors ? <ScaleLoader className="user-info-loader" color="var(--primary-color)"/> : null}
|
||||||
|
<UserInfoError errors={errors}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
if (!view.devices_tracked) {
|
if (!view.devices_tracked) {
|
||||||
return <div className="devices not-tracked">
|
return <div className="devices not-tracked">
|
||||||
<h4>Security</h4>
|
<h4>Security</h4>
|
||||||
<p>{encryptionMessage}</p>
|
<p>{encryptionMessage}</p>
|
||||||
<p>This user's device list is not being tracked.</p>
|
<p>This user's device list is not being tracked.</p>
|
||||||
<hr/>
|
<UserInfoError errors={errors}/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
let verifiedMessage = null
|
let verifiedMessage = null
|
||||||
|
@ -96,8 +87,43 @@ const DeviceList = ({ view, room }: DeviceListProps) => {
|
||||||
<summary><h4>{view.devices.length} devices</h4></summary>
|
<summary><h4>{view.devices.length} devices</h4></summary>
|
||||||
<ul>{view.devices.map(renderDevice)}</ul>
|
<ul>{view.devices.map(renderDevice)}</ul>
|
||||||
</details>
|
</details>
|
||||||
<hr/>
|
<UserInfoError errors={errors}/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
export default DeviceList
|
||||||
|
|
28
web/src/ui/rightpanel/UserInfoError.tsx
Normal file
28
web/src/ui/rightpanel/UserInfoError.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
import ErrorIcon from "@/icons/error.svg?react"
|
||||||
|
|
||||||
|
const UserInfoError = ({ errors }: { errors: string[] | null }) => {
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <div className="errors">{errors.map((err, i) => <div className="error" key={i}>
|
||||||
|
<div className="icon"><ErrorIcon/></div>
|
||||||
|
<p>{err}</p>
|
||||||
|
</div>)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserInfoError
|
|
@ -13,20 +13,27 @@
|
||||||
//
|
//
|
||||||
// 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 { useMemo, useReducer } from "react"
|
import { useEffect, useReducer, useState } from "react"
|
||||||
|
import { ScaleLoader } from "react-spinners"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
import { RoomListEntry } from "@/api/statestore"
|
import { RoomListEntry } from "@/api/statestore"
|
||||||
import { RoomID } from "@/api/types"
|
import { UserID } from "@/api/types"
|
||||||
import ListEntry from "../roomlist/Entry.tsx"
|
import ListEntry from "../roomlist/Entry.tsx"
|
||||||
|
import UserInfoError from "./UserInfoError.tsx"
|
||||||
|
|
||||||
interface MutualRoomsProps {
|
interface MutualRoomsProps {
|
||||||
client: Client
|
client: Client
|
||||||
rooms: RoomID[]
|
userID: UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
const MutualRooms = ({ client, rooms }: MutualRoomsProps) => {
|
const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
|
||||||
const [maxCount, increaseMaxCount] = useReducer(count => count + 10, 3)
|
const [rooms, setRooms] = useState<RoomListEntry[] | null>(null)
|
||||||
const mappedRooms = useMemo(() => rooms.map((roomID): RoomListEntry | null => {
|
const [errors, setErrors] = useState<string[] | null>(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)
|
const roomData = client.store.rooms.get(roomID)
|
||||||
if (!roomData || roomData.hidden) {
|
if (!roomData || roomData.hidden) {
|
||||||
return null
|
return null
|
||||||
|
@ -44,16 +51,27 @@ const MutualRooms = ({ client, rooms }: MutualRoomsProps) => {
|
||||||
unread_highlights: 0,
|
unread_highlights: 0,
|
||||||
marked_unread: false,
|
marked_unread: false,
|
||||||
}
|
}
|
||||||
}).filter((data): data is RoomListEntry => !!data), [client, rooms])
|
}).filter((data): data is RoomListEntry => !!data)),
|
||||||
|
err => setErrors([`${err}`]),
|
||||||
|
)
|
||||||
|
}, [client, userID])
|
||||||
|
const [maxCount, increaseMaxCount] = useReducer(count => count + 10, 3)
|
||||||
|
if (!rooms) {
|
||||||
return <div className="mutual-rooms">
|
return <div className="mutual-rooms">
|
||||||
<h4>Shared rooms</h4>
|
<h4>Shared rooms</h4>
|
||||||
{mappedRooms.slice(0, maxCount).map(room => <div key={room.room_id}>
|
{rooms === undefined && <ScaleLoader className="user-info-loader" color="var(--primary-color)"/>}
|
||||||
|
<UserInfoError errors={errors}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
return <div className="mutual-rooms">
|
||||||
|
<h4>Shared rooms</h4>
|
||||||
|
{rooms.slice(0, maxCount).map(room => <div key={room.room_id}>
|
||||||
<ListEntry room={room} isActive={false} hidden={false}/>
|
<ListEntry room={room} isActive={false} hidden={false}/>
|
||||||
</div>)}
|
</div>)}
|
||||||
{mappedRooms.length > maxCount && <button className="show-more" onClick={increaseMaxCount}>
|
{rooms.length > maxCount && <button className="show-more" onClick={increaseMaxCount}>
|
||||||
Show {mappedRooms.length - maxCount} more
|
Show {rooms.length - maxCount} more
|
||||||
</button>}
|
</button>}
|
||||||
<hr/>
|
<UserInfoError errors={errors}/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue