mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/store: only fetch full member list when needed
This commit is contained in:
parent
0fe01a8bff
commit
e370a12b19
16 changed files with 259 additions and 112 deletions
|
@ -37,9 +37,10 @@ const (
|
||||||
FROM current_state cs
|
FROM current_state cs
|
||||||
JOIN event ON cs.event_rowid = event.rowid
|
JOIN event ON cs.event_rowid = event.rowid
|
||||||
`
|
`
|
||||||
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
|
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
|
||||||
getManyCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE (cs.room_id, cs.event_type, cs.state_key) IN (%s)`
|
getCurrentRoomStateWithoutMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type<>'m.room.member'`
|
||||||
getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
|
getManyCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE (cs.room_id, cs.event_type, cs.state_key) IN (%s)`
|
||||||
|
getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
|
||||||
)
|
)
|
||||||
|
|
||||||
var massInsertCurrentStateBuilder = dbutil.NewMassInsertBuilder[*CurrentStateEntry, [1]any](addCurrentStateQuery, "($1, $%d, $%d, $%d, $%d)")
|
var massInsertCurrentStateBuilder = dbutil.NewMassInsertBuilder[*CurrentStateEntry, [1]any](addCurrentStateQuery, "($1, $%d, $%d, $%d, $%d)")
|
||||||
|
@ -113,3 +114,7 @@ func (csq *CurrentStateQuery) Get(ctx context.Context, roomID id.RoomID, eventTy
|
||||||
func (csq *CurrentStateQuery) GetAll(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
func (csq *CurrentStateQuery) GetAll(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
||||||
return csq.QueryMany(ctx, getCurrentRoomStateQuery, roomID)
|
return csq.QueryMany(ctx, getCurrentRoomStateQuery, roomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (csq *CurrentStateQuery) GetAllExceptMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
||||||
|
return csq.QueryMany(ctx, getCurrentRoomStateWithoutMembersQuery, roomID)
|
||||||
|
}
|
||||||
|
|
|
@ -91,7 +91,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
})
|
})
|
||||||
case "get_room_state":
|
case "get_room_state":
|
||||||
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
||||||
return h.GetRoomState(ctx, params.RoomID, params.FetchMembers, params.Refetch)
|
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
|
||||||
})
|
})
|
||||||
case "get_specific_room_state":
|
case "get_specific_room_state":
|
||||||
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
|
||||||
|
@ -212,9 +212,10 @@ type getEventsByRowIDsParams struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type getRoomStateParams struct {
|
type getRoomStateParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
Refetch bool `json:"refetch"`
|
Refetch bool `json:"refetch"`
|
||||||
FetchMembers bool `json:"fetch_members"`
|
FetchMembers bool `json:"fetch_members"`
|
||||||
|
IncludeMembers bool `json:"include_members"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type getSpecificRoomStateParams struct {
|
type getSpecificRoomStateParams struct {
|
||||||
|
|
|
@ -65,64 +65,100 @@ func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.Ev
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch bool) ([]*database.Event, error) {
|
func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
|
||||||
var evts []*event.Event
|
var evts []*event.Event
|
||||||
if refetch {
|
if refetch {
|
||||||
resp, err := h.Client.StateAsArray(ctx, roomID)
|
resp, err := h.Client.StateAsArray(ctx, roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to refetch state: %w", err)
|
return fmt.Errorf("failed to refetch state: %w", err)
|
||||||
}
|
}
|
||||||
evts = resp
|
evts = resp
|
||||||
} else if fetchMembers {
|
} else if fetchMembers {
|
||||||
resp, err := h.Client.Members(ctx, roomID)
|
resp, err := h.Client.Members(ctx, roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch members: %w", err)
|
return fmt.Errorf("failed to fetch members: %w", err)
|
||||||
}
|
}
|
||||||
evts = resp.Chunk
|
evts = resp.Chunk
|
||||||
}
|
}
|
||||||
if evts != nil {
|
if evts == nil {
|
||||||
err := h.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
|
return nil
|
||||||
room, err := h.DB.Room.Get(ctx, roomID)
|
}
|
||||||
if err != nil {
|
return h.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
|
||||||
return fmt.Errorf("failed to get room from database: %w", err)
|
room, err := h.DB.Room.Get(ctx, roomID)
|
||||||
}
|
|
||||||
updatedRoom := &database.Room{
|
|
||||||
ID: room.ID,
|
|
||||||
HasMemberList: true,
|
|
||||||
}
|
|
||||||
entries := make([]*database.CurrentStateEntry, len(evts))
|
|
||||||
for i, evt := range evts {
|
|
||||||
dbEvt, err := h.processEvent(ctx, evt, room.LazyLoadSummary, nil, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to process event %s: %w", evt.ID, err)
|
|
||||||
}
|
|
||||||
entries[i] = &database.CurrentStateEntry{
|
|
||||||
EventType: evt.Type,
|
|
||||||
StateKey: *evt.StateKey,
|
|
||||||
EventRowID: dbEvt.RowID,
|
|
||||||
}
|
|
||||||
if evt.Type == event.StateMember {
|
|
||||||
entries[i].Membership = event.Membership(evt.Content.Raw["membership"].(string))
|
|
||||||
} else {
|
|
||||||
processImportantEvent(ctx, evt, room, updatedRoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = h.DB.CurrentState.AddMany(ctx, room.ID, refetch, entries)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
|
|
||||||
if roomChanged {
|
|
||||||
err = h.DB.Room.Upsert(ctx, updatedRoom)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save room data: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to get room from database: %w", err)
|
||||||
}
|
}
|
||||||
|
updatedRoom := &database.Room{
|
||||||
|
ID: room.ID,
|
||||||
|
HasMemberList: true,
|
||||||
|
}
|
||||||
|
entries := make([]*database.CurrentStateEntry, len(evts))
|
||||||
|
for i, evt := range evts {
|
||||||
|
dbEvt, err := h.processEvent(ctx, evt, room.LazyLoadSummary, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to process event %s: %w", evt.ID, err)
|
||||||
|
}
|
||||||
|
entries[i] = &database.CurrentStateEntry{
|
||||||
|
EventType: evt.Type,
|
||||||
|
StateKey: *evt.StateKey,
|
||||||
|
EventRowID: dbEvt.RowID,
|
||||||
|
}
|
||||||
|
if evt.Type == event.StateMember {
|
||||||
|
entries[i].Membership = event.Membership(evt.Content.Raw["membership"].(string))
|
||||||
|
} else {
|
||||||
|
processImportantEvent(ctx, evt, room, updatedRoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = h.DB.CurrentState.AddMany(ctx, room.ID, refetch, entries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
|
||||||
|
if roomChanged {
|
||||||
|
err = h.DB.Room.Upsert(ctx, updatedRoom)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save room data: %w", err)
|
||||||
|
}
|
||||||
|
if dispatchEvt {
|
||||||
|
h.EventHandler(&SyncComplete{
|
||||||
|
Rooms: map[id.RoomID]*SyncRoom{
|
||||||
|
roomID: {
|
||||||
|
Meta: room,
|
||||||
|
Timeline: make([]database.TimelineRowTuple, 0),
|
||||||
|
State: make(map[event.Type]map[string]database.EventRowID),
|
||||||
|
AccountData: make(map[event.Type]*database.AccountData),
|
||||||
|
Events: make([]*database.Event, 0),
|
||||||
|
Reset: false,
|
||||||
|
Notifications: make([]SyncNotification, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountData: make(map[event.Type]*database.AccountData),
|
||||||
|
LeftRooms: make([]id.RoomID, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, includeMembers, fetchMembers, refetch bool) ([]*database.Event, error) {
|
||||||
|
if fetchMembers || refetch {
|
||||||
|
if !includeMembers {
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
err := h.processGetRoomState(ctx, roomID, fetchMembers, refetch, true)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch room state in background")
|
||||||
|
}
|
||||||
|
}(context.WithoutCancel(ctx))
|
||||||
|
} else {
|
||||||
|
err := h.processGetRoomState(ctx, roomID, fetchMembers, refetch, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !includeMembers {
|
||||||
|
return h.DB.CurrentState.GetAllExceptMembers(ctx, roomID)
|
||||||
}
|
}
|
||||||
return h.DB.CurrentState.GetAll(ctx, roomID)
|
return h.DB.CurrentState.GetAll(ctx, roomID)
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,8 @@ import type {
|
||||||
export default class Client {
|
export default class Client {
|
||||||
readonly state = new CachedEventDispatcher<ClientState>()
|
readonly state = new CachedEventDispatcher<ClientState>()
|
||||||
readonly store = new StateStore()
|
readonly store = new StateStore()
|
||||||
|
#stateRequests: RoomStateGUID[] = []
|
||||||
|
#stateRequestQueued = false
|
||||||
|
|
||||||
constructor(readonly rpc: RPCClient) {
|
constructor(readonly rpc: RPCClient) {
|
||||||
this.rpc.event.listen(this.#handleEvent)
|
this.rpc.event.listen(this.#handleEvent)
|
||||||
|
@ -91,6 +93,28 @@ export default class Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestMemberEvent(room: RoomStateStore | RoomID | undefined, userID: UserID) {
|
||||||
|
if (typeof room === "string") {
|
||||||
|
room = this.store.rooms.get(room)
|
||||||
|
}
|
||||||
|
if (!room || room.state.get("m.room.member")?.has(userID) || room.requestedMembers.has(userID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room.requestedMembers.add(userID)
|
||||||
|
this.#stateRequests.push({ room_id: room.roomID, type: "m.room.member", state_key: userID })
|
||||||
|
if (!this.#stateRequestQueued) {
|
||||||
|
this.#stateRequestQueued = true
|
||||||
|
window.queueMicrotask(this.doStateRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doStateRequests = () => {
|
||||||
|
const reqs = this.#stateRequests
|
||||||
|
this.#stateRequestQueued = false
|
||||||
|
this.#stateRequests = []
|
||||||
|
this.loadSpecificRoomState(reqs).catch(err => console.error("Failed to load room state", reqs, err))
|
||||||
|
}
|
||||||
|
|
||||||
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
|
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
|
||||||
if (typeof room === "string") {
|
if (typeof room === "string") {
|
||||||
room = this.store.rooms.get(room)
|
room = this.store.rooms.get(room)
|
||||||
|
@ -219,13 +243,22 @@ export default class Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRoomState(roomID: RoomID, refetch = false): Promise<void> {
|
async loadRoomState(
|
||||||
|
roomID: RoomID, { omitMembers, refetch } = { omitMembers: true, refetch: false },
|
||||||
|
): Promise<void> {
|
||||||
const room = this.store.rooms.get(roomID)
|
const room = this.store.rooms.get(roomID)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
throw new Error("Room not found")
|
throw new Error("Room not found")
|
||||||
}
|
}
|
||||||
const state = await this.rpc.getRoomState(roomID, !room.meta.current.has_member_list, refetch)
|
if (!omitMembers) {
|
||||||
room.applyFullState(state)
|
room.membersRequested = true
|
||||||
|
console.log("Requesting full member list for", roomID)
|
||||||
|
}
|
||||||
|
const state = await this.rpc.getRoomState(roomID, !omitMembers, !room.meta.current.has_member_list, refetch)
|
||||||
|
room.applyFullState(state, omitMembers)
|
||||||
|
if (!omitMembers && !room.meta.current.has_member_list) {
|
||||||
|
room.meta.current.has_member_list = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadMoreHistory(roomID: RoomID): Promise<void> {
|
async loadMoreHistory(roomID: RoomID): Promise<void> {
|
||||||
|
|
|
@ -172,8 +172,10 @@ export default abstract class RPCClient {
|
||||||
return this.request("get_specific_room_state", { keys })
|
return this.request("get_specific_room_state", { keys })
|
||||||
}
|
}
|
||||||
|
|
||||||
getRoomState(room_id: RoomID, fetch_members = false, refetch = false): Promise<RawDBEvent[]> {
|
getRoomState(
|
||||||
return this.request("get_room_state", { room_id, fetch_members, refetch })
|
room_id: RoomID, include_members = false, fetch_members = false, refetch = false,
|
||||||
|
): Promise<RawDBEvent[]> {
|
||||||
|
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
|
||||||
}
|
}
|
||||||
|
|
||||||
getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
|
getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
|
||||||
|
|
|
@ -15,9 +15,11 @@
|
||||||
// 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 { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
|
import toSearchableString from "@/util/searchablestring.ts"
|
||||||
import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
|
import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
import {
|
import {
|
||||||
|
ContentURI,
|
||||||
DBRoom,
|
DBRoom,
|
||||||
EncryptedEventContent,
|
EncryptedEventContent,
|
||||||
EventID,
|
EventID,
|
||||||
|
@ -69,6 +71,13 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
||||||
meta1.has_member_list === meta2.has_member_list
|
meta1.has_member_list === meta2.has_member_list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteMemberEntry {
|
||||||
|
userID: UserID
|
||||||
|
displayName: string
|
||||||
|
avatarURL?: ContentURI
|
||||||
|
searchString: string
|
||||||
|
}
|
||||||
|
|
||||||
export class RoomStateStore {
|
export class RoomStateStore {
|
||||||
readonly roomID: RoomID
|
readonly roomID: RoomID
|
||||||
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
||||||
|
@ -76,15 +85,19 @@ export class RoomStateStore {
|
||||||
timelineCache: (MemDBEvent | null)[] = []
|
timelineCache: (MemDBEvent | null)[] = []
|
||||||
state: Map<EventType, Map<string, EventRowID>> = new Map()
|
state: Map<EventType, Map<string, EventRowID>> = new Map()
|
||||||
stateLoaded = false
|
stateLoaded = false
|
||||||
|
fullMembersLoaded = false
|
||||||
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
|
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
|
||||||
readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
|
readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
|
||||||
readonly timelineSub = new Subscribable()
|
readonly timelineSub = new Subscribable()
|
||||||
readonly stateSubs = new MultiSubscribable()
|
readonly stateSubs = new MultiSubscribable()
|
||||||
readonly eventSubs = new MultiSubscribable()
|
readonly eventSubs = new MultiSubscribable()
|
||||||
readonly requestedEvents: Set<EventID> = new Set()
|
readonly requestedEvents: Set<EventID> = new Set()
|
||||||
|
readonly requestedMembers: Set<UserID> = new Set()
|
||||||
readonly openNotifications: Map<EventRowID, Notification> = new Map()
|
readonly openNotifications: Map<EventRowID, Notification> = new Map()
|
||||||
readonly emojiPacks: Map<string, CustomEmojiPack | null> = new Map()
|
readonly emojiPacks: Map<string, CustomEmojiPack | null> = new Map()
|
||||||
#membersCache: MemDBEvent[] | null = null
|
#membersCache: MemDBEvent[] | null = null
|
||||||
|
#autocompleteMembersCache: AutocompleteMemberEntry[] | null = null
|
||||||
|
membersRequested: boolean = false
|
||||||
#allPacksCache: Record<string, CustomEmojiPack> | null = null
|
#allPacksCache: Record<string, CustomEmojiPack> | null = null
|
||||||
readonly pendingEvents: EventRowID[] = []
|
readonly pendingEvents: EventRowID[] = []
|
||||||
paginating = false
|
paginating = false
|
||||||
|
@ -155,34 +168,53 @@ export class RoomStateStore {
|
||||||
return this.#allPacksCache
|
return this.#allPacksCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#fillMembersCache() {
|
||||||
|
const memberEvtIDs = this.state.get("m.room.member")
|
||||||
|
if (!memberEvtIDs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const powerLevels: PowerLevelEventContent = this.getStateEvent("m.room.power_levels", "")?.content ?? {}
|
||||||
|
const membersCache = memberEvtIDs.values()
|
||||||
|
.map(rowID => this.eventsByRowID.get(rowID))
|
||||||
|
.filter((evt): evt is MemDBEvent => !!evt && evt.content.membership === "join")
|
||||||
|
.toArray()
|
||||||
|
membersCache.sort((a, b) => {
|
||||||
|
const aUserID = a.state_key as UserID
|
||||||
|
const bUserID = b.state_key as UserID
|
||||||
|
const aPower = powerLevels.users?.[aUserID] ?? powerLevels.users_default ?? 0
|
||||||
|
const bPower = powerLevels.users?.[bUserID] ?? powerLevels.users_default ?? 0
|
||||||
|
if (aPower !== bPower) {
|
||||||
|
return bPower - aPower
|
||||||
|
}
|
||||||
|
const aName = getDisplayname(aUserID, a.content as MemberEventContent).toLowerCase()
|
||||||
|
const bName = getDisplayname(bUserID, b.content as MemberEventContent).toLowerCase()
|
||||||
|
if (aName === bName) {
|
||||||
|
return aUserID.localeCompare(bUserID)
|
||||||
|
}
|
||||||
|
return aName.localeCompare(bName)
|
||||||
|
})
|
||||||
|
this.#autocompleteMembersCache = membersCache.map(evt => ({
|
||||||
|
userID: evt.state_key!,
|
||||||
|
displayName: getDisplayname(evt.state_key!, evt.content as MemberEventContent),
|
||||||
|
avatarURL: evt.content?.avatar_url,
|
||||||
|
searchString: toSearchableString(`${evt.content?.displayname ?? ""}${evt.state_key!.slice(1)}`),
|
||||||
|
}))
|
||||||
|
this.#membersCache = membersCache
|
||||||
|
return membersCache
|
||||||
|
}
|
||||||
|
|
||||||
getMembers = (): MemDBEvent[] => {
|
getMembers = (): MemDBEvent[] => {
|
||||||
if (this.#membersCache === null) {
|
if (this.#membersCache === null) {
|
||||||
const memberEvtIDs = this.state.get("m.room.member")
|
this.#fillMembersCache()
|
||||||
if (!memberEvtIDs) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const powerLevels: PowerLevelEventContent = this.getStateEvent("m.room.power_levels", "")?.content ?? {}
|
|
||||||
this.#membersCache = memberEvtIDs.values()
|
|
||||||
.map(rowID => this.eventsByRowID.get(rowID))
|
|
||||||
.filter(evt => !!evt)
|
|
||||||
.toArray()
|
|
||||||
this.#membersCache.sort((a, b) => {
|
|
||||||
const aUserID = a.state_key as UserID
|
|
||||||
const bUserID = b.state_key as UserID
|
|
||||||
const aPower = powerLevels.users?.[aUserID] ?? powerLevels.users_default ?? 0
|
|
||||||
const bPower = powerLevels.users?.[bUserID] ?? powerLevels.users_default ?? 0
|
|
||||||
if (aPower !== bPower) {
|
|
||||||
return bPower - aPower
|
|
||||||
}
|
|
||||||
const aName = getDisplayname(aUserID, a.content as MemberEventContent).toLowerCase()
|
|
||||||
const bName = getDisplayname(bUserID, b.content as MemberEventContent).toLowerCase()
|
|
||||||
if (aName === bName) {
|
|
||||||
return aUserID.localeCompare(bUserID)
|
|
||||||
}
|
|
||||||
return aName.localeCompare(bName)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return this.#membersCache
|
return this.#membersCache ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
getAutocompleteMembers = (): AutocompleteMemberEntry[] => {
|
||||||
|
if (this.#autocompleteMembersCache === null) {
|
||||||
|
this.#fillMembersCache()
|
||||||
|
}
|
||||||
|
return this.#autocompleteMembersCache ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
getPinnedEvents(): EventID[] {
|
getPinnedEvents(): EventID[] {
|
||||||
|
@ -264,8 +296,13 @@ export class RoomStateStore {
|
||||||
this.emojiPacks.delete(key)
|
this.emojiPacks.delete(key)
|
||||||
this.#allPacksCache = null
|
this.#allPacksCache = null
|
||||||
this.parent.invalidateEmojiPacksCache()
|
this.parent.invalidateEmojiPacksCache()
|
||||||
} else if (evtType === "m.room.member" || evtType === "m.room.power_levels") {
|
} else if (evtType === "m.room.member") {
|
||||||
this.#membersCache = null
|
this.#membersCache = null
|
||||||
|
this.#autocompleteMembersCache = null
|
||||||
|
this.requestedMembers.delete(key as UserID)
|
||||||
|
} else if (evtType === "m.room.power_levels") {
|
||||||
|
this.#membersCache = null
|
||||||
|
this.#autocompleteMembersCache = null
|
||||||
}
|
}
|
||||||
this.stateSubs.notify(this.stateSubKey(evtType, key))
|
this.stateSubs.notify(this.stateSubKey(evtType, key))
|
||||||
}
|
}
|
||||||
|
@ -321,7 +358,7 @@ export class RoomStateStore {
|
||||||
this.stateSubs.notify(evt.type)
|
this.stateSubs.notify(evt.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFullState(state: RawDBEvent[]) {
|
applyFullState(state: RawDBEvent[], omitMembers: boolean) {
|
||||||
const newStateMap: Map<EventType, Map<string, EventRowID>> = new Map()
|
const newStateMap: Map<EventType, Map<string, EventRowID>> = new Map()
|
||||||
for (const evt of state) {
|
for (const evt of state) {
|
||||||
if (evt.state_key === undefined) {
|
if (evt.state_key === undefined) {
|
||||||
|
@ -337,9 +374,19 @@ export class RoomStateStore {
|
||||||
}
|
}
|
||||||
this.emojiPacks.clear()
|
this.emojiPacks.clear()
|
||||||
this.#allPacksCache = null
|
this.#allPacksCache = null
|
||||||
|
if (omitMembers) {
|
||||||
|
newStateMap.set("m.room.member", this.state.get("m.room.member") ?? new Map())
|
||||||
|
} else {
|
||||||
|
this.#membersCache = null
|
||||||
|
this.#autocompleteMembersCache = null
|
||||||
|
}
|
||||||
this.state = newStateMap
|
this.state = newStateMap
|
||||||
this.stateLoaded = true
|
this.stateLoaded = true
|
||||||
|
this.fullMembersLoaded = this.fullMembersLoaded || !omitMembers
|
||||||
for (const [evtType, stateMap] of newStateMap) {
|
for (const [evtType, stateMap] of newStateMap) {
|
||||||
|
if (omitMembers && evtType === "m.room.member") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for (const [key] of stateMap) {
|
for (const [key] of stateMap) {
|
||||||
this.stateSubs.notify(this.stateSubKey(evtType, key))
|
this.stateSubs.notify(this.stateSubKey(evtType, key))
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ div.autocompletions {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
|
max-width: 30rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
> .autocompletion-item {
|
> .autocompletion-item {
|
||||||
|
@ -20,6 +21,10 @@ div.autocompletions {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .25rem;
|
gap: .25rem;
|
||||||
cursor: var(--clickable-cursor);
|
cursor: var(--clickable-cursor);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 2rem;
|
||||||
|
contain-intrinsic-height: 2rem;
|
||||||
|
content-visibility: auto;
|
||||||
|
|
||||||
&.selected, &:hover {
|
&.selected, &:hover {
|
||||||
background-color: var(--light-hover-color);
|
background-color: var(--light-hover-color);
|
||||||
|
|
|
@ -87,7 +87,10 @@ function useAutocompleter<T>({
|
||||||
}
|
}
|
||||||
}, [onSelect, setAutocomplete, params.selected, params.close])
|
}, [onSelect, setAutocomplete, params.selected, params.close])
|
||||||
const selected = params.selected !== undefined ? positiveMod(params.selected, items.length) : -1
|
const selected = params.selected !== undefined ? positiveMod(params.selected, items.length) : -1
|
||||||
return <div className={`autocompletions ${items.length === 0 ? "empty" : "has-items"}`} id="composer-autocompletions">
|
return <div
|
||||||
|
className={`autocompletions ${items.length === 0 ? "empty" : "has-items"}`}
|
||||||
|
id="composer-autocompletions"
|
||||||
|
>
|
||||||
{items.map((item, i) => <div
|
{items.map((item, i) => <div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
|
|
|
@ -378,7 +378,7 @@ const MessageComposer = () => {
|
||||||
onClose: () => textInput.current?.focus(),
|
onClose: () => textInput.current?.focus(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const Autocompleter = getAutocompleter(autocomplete)
|
const Autocompleter = getAutocompleter(autocomplete, client, room)
|
||||||
return <>
|
return <>
|
||||||
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
||||||
params={autocomplete}
|
params={autocomplete}
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
//
|
//
|
||||||
// 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 Client from "@/api/client.ts"
|
||||||
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import {
|
import {
|
||||||
AutocompleteQuery,
|
AutocompleteQuery,
|
||||||
AutocompleterProps,
|
AutocompleterProps,
|
||||||
|
@ -36,10 +38,23 @@ export function charToAutocompleteType(newChar?: string): AutocompleteQuery["typ
|
||||||
|
|
||||||
export const emojiQueryRegex = /[a-zA-Z0-9_+-]*$/
|
export const emojiQueryRegex = /[a-zA-Z0-9_+-]*$/
|
||||||
|
|
||||||
export function getAutocompleter(params: AutocompleteQuery | null): React.ElementType<AutocompleterProps> | null {
|
export function getAutocompleter(
|
||||||
|
params: AutocompleteQuery | null, client: Client, room: RoomStateStore,
|
||||||
|
): React.ElementType<AutocompleterProps> | null {
|
||||||
switch (params?.type) {
|
switch (params?.type) {
|
||||||
case "user":
|
case "user": {
|
||||||
|
const memberCount = room.state.get("m.room.member")?.size ?? 0
|
||||||
|
if (memberCount > 500 && params.query.length < 2) {
|
||||||
|
return null
|
||||||
|
} else if (memberCount > 5000 && params.query.length < 3) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!room.membersRequested) {
|
||||||
|
room.membersRequested = true
|
||||||
|
client.loadRoomState(room.roomID, { omitMembers: false, refetch: false })
|
||||||
|
}
|
||||||
return UserAutocompleter
|
return UserAutocompleter
|
||||||
|
}
|
||||||
case "emoji":
|
case "emoji":
|
||||||
if (params.query.length < 3) {
|
if (params.query.length < 3) {
|
||||||
return null
|
return null
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
// 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, useRef } from "react"
|
import { useMemo, useRef } from "react"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import type { ContentURI, MemberEventContent, UserID } from "@/api/types"
|
import type { ContentURI, UserID } from "@/api/types"
|
||||||
import toSearchableString from "@/util/searchablestring.ts"
|
import toSearchableString from "@/util/searchablestring.ts"
|
||||||
|
|
||||||
export interface AutocompleteUser {
|
export interface AutocompleteUser {
|
||||||
|
@ -34,35 +34,18 @@ export function filterAndSort(users: AutocompleteUser[], query: string): Autocom
|
||||||
.map(({ user }) => user)
|
.map(({ user }) => user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAutocompleteMemberList(room: RoomStateStore) {
|
|
||||||
const states = room.state.get("m.room.member")
|
|
||||||
if (!states) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const output = []
|
|
||||||
for (const [stateKey, rowID] of states) {
|
|
||||||
const memberEvt = room.eventsByRowID.get(rowID)
|
|
||||||
if (!memberEvt) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const content = memberEvt.content as MemberEventContent
|
|
||||||
output.push({
|
|
||||||
userID: stateKey,
|
|
||||||
displayName: content.displayname ?? stateKey,
|
|
||||||
avatarURL: content.avatar_url,
|
|
||||||
searchString: toSearchableString(`${content.displayname ?? ""}${stateKey.slice(1)}`),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
interface filteredUserCache {
|
interface filteredUserCache {
|
||||||
query: string
|
query: string
|
||||||
result: AutocompleteUser[]
|
result: AutocompleteUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilteredMembers(room: RoomStateStore, query: string): AutocompleteUser[] {
|
export function useFilteredMembers(room: RoomStateStore, query: string): AutocompleteUser[] {
|
||||||
const allMembers = useMemo(() => getAutocompleteMemberList(room), [room])
|
const allMembers = useMemo(
|
||||||
|
() => room.getAutocompleteMembers(),
|
||||||
|
// fullMembersLoaded needs to be monitored for when the member list loads
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[room, room.fullMembersLoaded],
|
||||||
|
)
|
||||||
const prev = useRef<filteredUserCache>({ query: "", result: allMembers })
|
const prev = useRef<filteredUserCache>({ query: "", result: allMembers })
|
||||||
if (!query) {
|
if (!query) {
|
||||||
prev.current.query = ""
|
prev.current.query = ""
|
||||||
|
@ -72,6 +55,9 @@ export function useFilteredMembers(room: RoomStateStore, query: string): Autocom
|
||||||
query.startsWith(prev.current.query) ? prev.current.result : allMembers,
|
query.startsWith(prev.current.query) ? prev.current.result : allMembers,
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
|
if (prev.current.result.length > 100) {
|
||||||
|
prev.current.result = prev.current.result.slice(0, 100)
|
||||||
|
}
|
||||||
prev.current.query = query
|
prev.current.query = query
|
||||||
}
|
}
|
||||||
return prev.current.result
|
return prev.current.result
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { getAvatarURL } from "@/api/media.ts"
|
||||||
import { useRoomMembers } from "@/api/statestore"
|
import { useRoomMembers } from "@/api/statestore"
|
||||||
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
|
import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
import { RoomContext } from "../roomview/roomcontext.ts"
|
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||||
|
|
||||||
|
@ -44,6 +45,10 @@ const MemberList = () => {
|
||||||
const [limit, setLimit] = useState(50)
|
const [limit, setLimit] = useState(50)
|
||||||
const increaseLimit = useCallback(() => setLimit(limit => limit + 50), [])
|
const increaseLimit = useCallback(() => setLimit(limit => limit + 50), [])
|
||||||
const roomCtx = use(RoomContext)
|
const roomCtx = use(RoomContext)
|
||||||
|
if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) {
|
||||||
|
roomCtx.store.membersRequested = true
|
||||||
|
use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false })
|
||||||
|
}
|
||||||
const memberEvents = useRoomMembers(roomCtx?.store)
|
const memberEvents = useRoomMembers(roomCtx?.store)
|
||||||
if (!roomCtx) {
|
if (!roomCtx) {
|
||||||
return null
|
return null
|
||||||
|
|
|
@ -31,6 +31,9 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
||||||
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)
|
||||||
|
if (!memberEvt) {
|
||||||
|
use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID)
|
||||||
|
}
|
||||||
const memberEvtContent = memberEvt?.content as MemberEventContent
|
const memberEvtContent = memberEvt?.content as MemberEventContent
|
||||||
if (!memberEvtContent) {
|
if (!memberEvtContent) {
|
||||||
return <NonMemberInfo userID={userID}/>
|
return <NonMemberInfo userID={userID}/>
|
||||||
|
|
|
@ -62,7 +62,7 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
}
|
}
|
||||||
}, [clearQuery])
|
}, [mainScreen, client.store, clearQuery])
|
||||||
|
|
||||||
return <div className="room-list-wrapper">
|
return <div className="room-list-wrapper">
|
||||||
<div className="room-search-wrapper">
|
<div className="room-search-wrapper">
|
||||||
|
|
|
@ -70,6 +70,9 @@ const onClickReply = (evt: React.MouseEvent) => {
|
||||||
|
|
||||||
export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBodyProps) => {
|
export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBodyProps) => {
|
||||||
const memberEvt = useRoomState(room, "m.room.member", event.sender)
|
const memberEvt = useRoomState(room, "m.room.member", event.sender)
|
||||||
|
if (!memberEvt) {
|
||||||
|
use(ClientContext)?.requestMemberEvent(room, event.sender)
|
||||||
|
}
|
||||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||||
const BodyType = getBodyType(event, true)
|
const BodyType = getBodyType(event, true)
|
||||||
const classNames = ["reply-body"]
|
const classNames = ["reply-body"]
|
||||||
|
|
|
@ -74,6 +74,9 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
|
||||||
const mainScreen = use(MainScreenContext)
|
const mainScreen = use(MainScreenContext)
|
||||||
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
||||||
const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender)
|
const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender)
|
||||||
|
if (!memberEvt) {
|
||||||
|
client.requestMemberEvent(roomCtx.store, evt.sender)
|
||||||
|
}
|
||||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||||
const BodyType = getBodyType(evt)
|
const BodyType = getBodyType(evt)
|
||||||
const eventTS = new Date(evt.timestamp)
|
const eventTS = new Date(evt.timestamp)
|
||||||
|
|
Loading…
Add table
Reference in a new issue