Merge branch 'main' into nexy7574/extended-profiles

This commit is contained in:
nexy7574 2024-12-29 21:55:29 +00:00
commit f5e2584754
40 changed files with 1100 additions and 172 deletions

View file

@ -125,7 +125,7 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) {
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="%s"/>
<rect x="0" y="0" width="1000" height="1000" fill="%s"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>%s</text>

View file

@ -17,16 +17,17 @@ import (
type Database struct {
*dbutil.Database
Account AccountQuery
AccountData AccountDataQuery
Room RoomQuery
InvitedRoom InvitedRoomQuery
Event EventQuery
CurrentState CurrentStateQuery
Timeline TimelineQuery
SessionRequest SessionRequestQuery
Receipt ReceiptQuery
Media MediaQuery
Account *AccountQuery
AccountData *AccountDataQuery
Room *RoomQuery
InvitedRoom *InvitedRoomQuery
Event *EventQuery
CurrentState *CurrentStateQuery
Timeline *TimelineQuery
SessionRequest *SessionRequestQuery
Receipt *ReceiptQuery
Media *MediaQuery
SpaceEdge *SpaceEdgeQuery
}
func New(rawDB *dbutil.Database) *Database {
@ -35,16 +36,17 @@ func New(rawDB *dbutil.Database) *Database {
return &Database{
Database: rawDB,
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: EventQuery{QueryHelper: eventQH},
CurrentState: CurrentStateQuery{QueryHelper: eventQH},
Timeline: TimelineQuery{QueryHelper: eventQH},
SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: &EventQuery{QueryHelper: eventQH},
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
Timeline: &TimelineQuery{QueryHelper: eventQH},
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
}
}
@ -79,3 +81,7 @@ func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData {
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
return &Account{}
}
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{}
}

View file

@ -27,6 +27,7 @@ const (
FROM room
`
getRoomsBySortingTimestampQuery = getRoomBaseQuery + `WHERE sorting_timestamp < $1 AND sorting_timestamp > 0 ORDER BY sorting_timestamp DESC LIMIT $2`
getRoomsByTypeQuery = getRoomBaseQuery + `WHERE room_type = $1`
getRoomByIDQuery = getRoomBaseQuery + `WHERE room_id = $1`
ensureRoomExistsQuery = `
INSERT INTO room (room_id) VALUES ($1)
@ -34,7 +35,8 @@ const (
`
upsertRoomFromSyncQuery = `
UPDATE room
SET creation_content = COALESCE(room.creation_content, $2),
SET room_type = COALESCE(room.room_type, json($2)->>'$.type'),
creation_content = COALESCE(room.creation_content, $2),
tombstone_content = COALESCE(room.tombstone_content, $3),
name = COALESCE($4, room.name),
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
@ -95,6 +97,10 @@ func (rq *RoomQuery) GetBySortTS(ctx context.Context, maxTS time.Time, limit int
return rq.QueryMany(ctx, getRoomsBySortingTimestampQuery, maxTS.UnixMilli(), limit)
}
func (rq *RoomQuery) GetAllSpaces(ctx context.Context) ([]*Room, error) {
return rq.QueryMany(ctx, getRoomsByTypeQuery, event.RoomTypeSpace)
}
func (rq *RoomQuery) Upsert(ctx context.Context, room *Room) error {
return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...)
}

236
pkg/hicli/database/space.go Normal file
View file

@ -0,0 +1,236 @@
// 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 database
import (
"context"
"database/sql"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getAllSpaceChildren = `
SELECT space_id, child_id, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated
FROM space_edge
-- This check should be redundant thanks to parent_validated and validation before insert for children
--INNER JOIN room ON space_id = room.room_id AND room.room_type = 'm.space'
WHERE (space_id = $1 OR $1 = '') AND (child_event_rowid IS NOT NULL OR parent_validated)
ORDER BY space_id, "order", child_id
`
getTopLevelSpaces = `
SELECT space_id
FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge
LEFT JOIN room_account_data ON
room_account_data.user_id = $1
AND room_account_data.room_id = outeredge.space_id
AND room_account_data.type = 'org.matrix.msc3230.space_order'
WHERE NOT EXISTS(
SELECT 1
FROM space_edge inneredge
INNER JOIN room ON inneredge.space_id = room.room_id
WHERE inneredge.child_id = outeredge.space_id
AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated)
) AND EXISTS(SELECT 1 FROM room WHERE room_id = space_id AND room_type = 'm.space')
ORDER BY room_account_data.content->>'$.order' NULLS LAST, space_id
`
revalidateAllParents = `
UPDATE space_edge
SET parent_validated=(SELECT EXISTS(
SELECT 1
FROM room
INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = ''
INNER JOIN event pls ON cs.event_rowid = pls.rowid
INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid
WHERE room.room_id = space_edge.space_id
AND room.room_type = 'm.space'
AND COALESCE(
(
SELECT value
FROM json_each(pls.content, 'users')
WHERE key=edgeevt.sender AND type='integer'
),
pls.content->>'$.users_default',
0
) >= COALESCE(
pls.content->>'$.events."m.space.child"',
pls.content->>'$.state_default',
50
)
))
WHERE parent_event_rowid IS NOT NULL
`
revalidateAllParentsPointingAtSpaceQuery = revalidateAllParents + ` AND space_id=$1`
revalidateAllParentsOfRoomQuery = revalidateAllParents + ` AND child_id=$1`
revalidateSpecificParentQuery = revalidateAllParents + ` AND space_id=$1 AND child_id=$2`
clearSpaceChildrenQuery = `
UPDATE space_edge SET child_event_rowid=NULL, "order"=NULL, suggested=false
WHERE space_id=$1
`
clearSpaceParentsQuery = `
UPDATE space_edge SET parent_event_rowid=NULL, canonical=false, parent_validated=false
WHERE child_id=$1
`
deleteEmptySpaceEdgeRowsQuery = `
DELETE FROM space_edge WHERE child_event_rowid IS NULL AND parent_event_rowid IS NULL
`
addSpaceChildQuery = `
INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (space_id, child_id) DO UPDATE
SET child_event_rowid=EXCLUDED.child_event_rowid,
"order"=EXCLUDED."order",
suggested=EXCLUDED.suggested
`
addSpaceParentQuery = `
INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical)
VALUES ($1, $2, $3, $4)
ON CONFLICT (space_id, child_id) DO UPDATE
SET parent_event_rowid=EXCLUDED.parent_event_rowid,
canonical=EXCLUDED.canonical,
parent_validated=false
`
)
var massInsertSpaceParentBuilder = dbutil.NewMassInsertBuilder[SpaceParentEntry, [1]any](addSpaceParentQuery, "($%d, $1, $%d, $%d)")
var massInsertSpaceChildBuilder = dbutil.NewMassInsertBuilder[SpaceChildEntry, [1]any](addSpaceChildQuery, "($1, $%d, $%d, $%d, $%d)")
type SpaceEdgeQuery struct {
*dbutil.QueryHelper[*SpaceEdge]
}
func (seq *SpaceEdgeQuery) AddChild(ctx context.Context, spaceID, childID id.RoomID, childEventRowID EventRowID, order string, suggested bool) error {
return seq.Exec(ctx, addSpaceChildQuery, spaceID, childID, childEventRowID, order, suggested)
}
func (seq *SpaceEdgeQuery) AddParent(ctx context.Context, spaceID, childID id.RoomID, parentEventRowID EventRowID, canonical bool) error {
return seq.Exec(ctx, addSpaceParentQuery, spaceID, childID, parentEventRowID, canonical)
}
type SpaceParentEntry struct {
ParentID id.RoomID
EventRowID EventRowID
Canonical bool
}
func (spe SpaceParentEntry) GetMassInsertValues() [3]any {
return [...]any{spe.ParentID, spe.EventRowID, spe.Canonical}
}
type SpaceChildEntry struct {
ChildID id.RoomID
EventRowID EventRowID
Order string
Suggested bool
}
func (sce SpaceChildEntry) GetMassInsertValues() [4]any {
return [...]any{sce.ChildID, sce.EventRowID, sce.Order, sce.Suggested}
}
func (seq *SpaceEdgeQuery) SetChildren(ctx context.Context, spaceID id.RoomID, children []SpaceChildEntry, removedChildren []id.RoomID, clear bool) error {
if clear {
err := seq.Exec(ctx, clearSpaceChildrenQuery, spaceID)
if err != nil {
return err
}
} else {
}
if len(removedChildren) > 0 {
err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery, spaceID)
if err != nil {
return err
}
}
if len(children) == 0 {
return nil
}
query, params := massInsertSpaceChildBuilder.Build([1]any{spaceID}, children)
return seq.Exec(ctx, query, params)
}
func (seq *SpaceEdgeQuery) SetParents(ctx context.Context, childID id.RoomID, parents []SpaceParentEntry, removedParents []id.RoomID, clear bool) error {
if clear {
err := seq.Exec(ctx, clearSpaceParentsQuery, childID)
if err != nil {
return err
}
}
if len(removedParents) > 0 {
err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery)
if err != nil {
return err
}
}
if len(parents) == 0 {
return nil
}
query, params := massInsertSpaceParentBuilder.Build([1]any{childID}, parents)
return seq.Exec(ctx, query, params)
}
func (seq *SpaceEdgeQuery) RevalidateAllChildrenOfParentValidity(ctx context.Context, spaceID id.RoomID) error {
return seq.Exec(ctx, revalidateAllParentsPointingAtSpaceQuery, spaceID)
}
func (seq *SpaceEdgeQuery) RevalidateAllParentsOfRoomValidity(ctx context.Context, childID id.RoomID) error {
return seq.Exec(ctx, revalidateAllParentsOfRoomQuery, childID)
}
func (seq *SpaceEdgeQuery) RevalidateSpecificParentValidity(ctx context.Context, spaceID, childID id.RoomID) error {
return seq.Exec(ctx, revalidateSpecificParentQuery, spaceID, childID)
}
func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[id.RoomID][]*SpaceEdge, error) {
edges := make(map[id.RoomID][]*SpaceEdge)
err := seq.QueryManyIter(ctx, getAllSpaceChildren, spaceID).Iter(func(edge *SpaceEdge) (bool, error) {
edges[edge.SpaceID] = append(edges[edge.SpaceID], edge)
edge.SpaceID = ""
if !edge.ParentValidated {
edge.ParentEventRowID = 0
edge.Canonical = false
}
return true, nil
})
return edges, err
}
var roomIDScanner = dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID])
func (seq *SpaceEdgeQuery) GetTopLevelIDs(ctx context.Context, userID id.UserID) ([]id.RoomID, error) {
return roomIDScanner.NewRowIter(seq.GetDB().Query(ctx, getTopLevelSpaces, userID)).AsList()
}
type SpaceEdge struct {
SpaceID id.RoomID `json:"space_id,omitempty"`
ChildID id.RoomID `json:"child_id"`
ChildEventRowID EventRowID `json:"child_event_rowid,omitempty"`
Order string `json:"order,omitempty"`
Suggested bool `json:"suggested,omitempty"`
ParentEventRowID EventRowID `json:"parent_event_rowid,omitempty"`
Canonical bool `json:"canonical,omitempty"`
ParentValidated bool `json:"-"`
}
func (se *SpaceEdge) Scan(row dbutil.Scannable) (*SpaceEdge, error) {
var childRowID, parentRowID sql.NullInt64
err := row.Scan(
&se.SpaceID, &se.ChildID,
&childRowID, &se.Order, &se.Suggested,
&parentRowID, &se.Canonical, &se.ParentValidated,
)
if err != nil {
return nil, err
}
se.ChildEventRowID = EventRowID(childRowID.Int64)
se.ParentEventRowID = EventRowID(parentRowID.Int64)
return se, nil
}

View file

@ -1,4 +1,4 @@
-- v0 -> v9 (compatible with v5+): Latest revision
-- v0 -> v10 (compatible with v10+): Latest revision
CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
@ -10,6 +10,7 @@ CREATE TABLE account (
CREATE TABLE room (
room_id TEXT NOT NULL PRIMARY KEY,
room_type TEXT,
creation_content TEXT,
tombstone_content TEXT,
@ -35,7 +36,7 @@ CREATE TABLE room (
CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL
) STRICT;
CREATE INDEX room_type_idx ON room (creation_content ->> 'type');
CREATE INDEX room_type_idx ON room (room_type);
CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC);
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0);
@ -278,3 +279,24 @@ CREATE TABLE receipt (
CONSTRAINT receipt_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE
-- note: there's no foreign key on event ID because receipts could point at events that are too far in history.
) STRICT;
CREATE TABLE space_edge (
space_id TEXT NOT NULL,
child_id TEXT NOT NULL,
-- m.space.child fields
child_event_rowid INTEGER,
"order" TEXT NOT NULL DEFAULT '',
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
-- m.space.parent fields
parent_event_rowid INTEGER,
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
PRIMARY KEY (space_id, child_id),
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id);

View file

@ -0,0 +1,83 @@
-- v10 (compatible with v10+): Add support for spaces
ALTER TABLE room ADD COLUMN room_type TEXT;
UPDATE room SET room_type=COALESCE(creation_content->>'$.type', '');
DROP INDEX room_type_idx;
CREATE INDEX room_type_idx ON room (room_type);
CREATE TABLE space_edge (
space_id TEXT NOT NULL,
child_id TEXT NOT NULL,
-- m.space.child fields
child_event_rowid INTEGER,
"order" TEXT NOT NULL DEFAULT '',
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
-- m.space.parent fields
parent_event_rowid INTEGER,
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
PRIMARY KEY (space_id, child_id),
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested)
SELECT
event.room_id,
event.state_key,
event.rowid,
CASE WHEN typeof(content->>'$.order')='TEXT' THEN content->>'$.order' ELSE '' END,
CASE WHEN json_type(content, '$.suggested') IN ('true', 'false') THEN content->>'$.suggested' ELSE false END
FROM current_state
INNER JOIN event ON current_state.event_rowid = event.rowid
LEFT JOIN room ON current_state.room_id = room.room_id
WHERE type = 'm.space.child'
AND json_array_length(event.content, '$.via') > 0
AND event.state_key LIKE '!%'
AND (room.room_id IS NULL OR room.room_type = 'm.space');
INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical)
SELECT
event.state_key,
event.room_id,
event.rowid,
CASE WHEN json_type(content, '$.canonical') IN ('true', 'false') THEN content->>'$.canonical' ELSE false END
FROM current_state
INNER JOIN event ON current_state.event_rowid = event.rowid
LEFT JOIN room ON event.state_key = room.room_id
WHERE type = 'm.space.parent'
AND json_array_length(event.content, '$.via') > 0
AND event.state_key LIKE '!%'
AND (room.room_id IS NULL OR room.room_type = 'm.space')
ON CONFLICT (space_id, child_id) DO UPDATE
SET parent_event_rowid = excluded.parent_event_rowid,
canonical = excluded.canonical;
UPDATE space_edge
SET parent_validated=(SELECT EXISTS(
SELECT 1
FROM room
INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = ''
INNER JOIN event pls ON cs.event_rowid = pls.rowid
INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid
WHERE room.room_id = space_edge.space_id
AND room.room_type = 'm.space'
AND COALESCE(
(
SELECT value
FROM json_each(pls.content, '$.users')
WHERE key=edgeevt.sender AND type='integer'
),
pls.content->>'$.users_default',
0
) >= COALESCE(
pls.content->>'$.events."m.space.child"',
pls.content->>'$.state_default',
50
)
))
WHERE parent_event_rowid IS NOT NULL;

View file

@ -31,12 +31,14 @@ type SyncNotification struct {
}
type SyncComplete struct {
Since *string `json:"since,omitempty"`
ClearState bool `json:"clear_state,omitempty"`
AccountData map[event.Type]*database.AccountData `json:"account_data"`
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
LeftRooms []id.RoomID `json:"left_rooms"`
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
Since *string `json:"since,omitempty"`
ClearState bool `json:"clear_state,omitempty"`
AccountData map[event.Type]*database.AccountData `json:"account_data"`
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
LeftRooms []id.RoomID `json:"left_rooms"`
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
}
func (c *SyncComplete) IsEmpty() bool {

View file

@ -14,12 +14,9 @@ import (
func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom {
syncRoom := &SyncRoom{
Meta: room,
Events: make([]*database.Event, 0, 2),
Timeline: make([]database.TimelineRowTuple, 0),
State: map[event.Type]map[string]database.EventRowID{},
Notifications: make([]SyncNotification, 0),
Receipts: make(map[id.EventID][]*database.Receipt),
Meta: room,
Events: make([]*database.Event, 0, 2),
State: map[event.Type]map[string]database.EventRowID{},
}
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
if err != nil {
@ -27,7 +24,6 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
if ctx.Err() != nil {
return nil
}
syncRoom.AccountData = make(map[event.Type]*database.AccountData)
} else {
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
for _, data := range ad {
@ -70,6 +66,49 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*SyncComplete] {
return func(yield func(*SyncComplete) bool) {
maxTS := time.Now().Add(1 * time.Hour)
{
spaces, err := h.DB.Room.GetAllSpaces(ctx)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get initial spaces to send to client")
}
return
}
payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(spaces)),
}
for _, room := range spaces {
payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room)
if ctx.Err() != nil {
return
}
}
payload.TopLevelSpaces, err = h.DB.SpaceEdge.GetTopLevelIDs(ctx, h.Account.UserID)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get top-level space IDs to send to client")
}
return
}
payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "")
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client")
}
return
}
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
}
return
}
payload.ClearState = true
if !yield(&payload) {
return
}
}
for i := 0; ; i++ {
rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize)
if err != nil {
@ -79,22 +118,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return
}
payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)-1),
LeftRooms: make([]id.RoomID, 0),
AccountData: make(map[event.Type]*database.AccountData),
}
if i == 0 {
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
}
return
}
payload.ClearState = true
}
if payload.InvitedRooms == nil {
payload.InvitedRooms = make([]*database.InvitedRoom, 0)
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
}
for _, room := range rooms {
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
@ -106,7 +130,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return
}
}
if !yield(&payload) || len(rooms) < batchSize {
if !yield(&payload) {
return
} else if len(rooms) < batchSize {
break
}
}
@ -117,10 +143,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return
}
payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom),
InvitedRooms: make([]*database.InvitedRoom, 0),
LeftRooms: make([]id.RoomID, 0),
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
}
for _, data := range ad {
payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data

View file

@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
if err != nil {
return fmt.Errorf("failed to save events: %w", err)
}
sdc := &spaceDataCollector{}
for i := range currentStateEntries {
currentStateEntries[i].EventRowID = dbEvts[i].RowID
if mediaReferenceEntries[i] != nil {
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
}
if evts[i].Type != event.StateMember {
processImportantEvent(ctx, evts[i], room, updatedRoom)
processImportantEvent(ctx, evts[i], room, updatedRoom, dbEvts[i].RowID, sdc)
}
}
err = h.DB.Media.AddMany(ctx, mediaCacheEntries)
@ -146,6 +147,11 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
return fmt.Errorf("failed to save current state entries: %w", err)
}
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
// TODO dispatch space edge changes if something changed? (fairly unlikely though)
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
if err != nil {
return err
}
if roomChanged {
err = h.DB.Room.Upsert(ctx, updatedRoom)
if err != nil {
@ -155,19 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
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),
Receipts: make(map[id.EventID][]*database.Receipt),
Meta: room,
},
},
InvitedRooms: make([]*database.InvitedRoom, 0),
AccountData: make(map[event.Type]*database.AccountData),
LeftRooms: make([]id.RoomID, 0),
})
}
}

View file

@ -667,6 +667,7 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.LazyLoadSummary = summary
heroesChanged = true
}
sdc := &spaceDataCollector{}
decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
addedEvents := make(map[database.EventRowID]struct{})
@ -750,7 +751,7 @@ func (h *HiClient) processStateAndTimeline(
if err != nil {
return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err)
}
processImportantEvent(ctx, evt, room, updatedRoom)
processImportantEvent(ctx, evt, room, updatedRoom, dbEvt.RowID, sdc)
}
allNewEvents = append(allNewEvents, dbEvt)
addedEvents[dbEvt.RowID] = struct{}{}
@ -932,6 +933,10 @@ func (h *HiClient) processStateAndTimeline(
return fmt.Errorf("failed to save room data: %w", err)
}
}
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
if err != nil {
return err
}
// TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero?
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
for _, receipt := range receipts {
@ -1033,13 +1038,101 @@ func intPtrEqual(a, b *int) bool {
return *a == *b
}
func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomData, updatedRoom *database.Room) (roomDataChanged bool) {
type spaceDataCollector struct {
Children []database.SpaceChildEntry
Parents []database.SpaceParentEntry
RemovedChildren []id.RoomID
RemovedParents []id.RoomID
PowerLevelChanged bool
IsFullState bool
}
func (sdc *spaceDataCollector) Collect(evt *event.Event, rowID database.EventRowID) {
switch evt.Type {
case event.StatePowerLevels:
sdc.PowerLevelChanged = true
case event.StateCreate:
sdc.IsFullState = true
case event.StateSpaceChild:
content := evt.Content.AsSpaceChild()
if len(content.Via) == 0 {
sdc.RemovedChildren = append(sdc.RemovedChildren, id.RoomID(*evt.StateKey))
} else {
sdc.Children = append(sdc.Children, database.SpaceChildEntry{
ChildID: id.RoomID(*evt.StateKey),
EventRowID: rowID,
Order: content.Order,
Suggested: content.Suggested,
})
}
case event.StateSpaceParent:
content := evt.Content.AsSpaceParent()
if len(content.Via) == 0 {
sdc.RemovedParents = append(sdc.RemovedParents, id.RoomID(*evt.StateKey))
} else {
sdc.Parents = append(sdc.Parents, database.SpaceParentEntry{
ParentID: id.RoomID(*evt.StateKey),
EventRowID: rowID,
Canonical: content.Canonical,
})
}
}
}
func (sdc *spaceDataCollector) Apply(ctx context.Context, room *database.Room, seq *database.SpaceEdgeQuery) error {
if room.CreationContent == nil || room.CreationContent.Type != event.RoomTypeSpace {
sdc.Children = nil
sdc.RemovedChildren = nil
sdc.PowerLevelChanged = false
}
if len(sdc.Children) == 0 && len(sdc.RemovedChildren) == 0 &&
len(sdc.Parents) == 0 && len(sdc.RemovedParents) == 0 &&
!sdc.PowerLevelChanged {
return nil
}
return seq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
if len(sdc.Children) > 0 || len(sdc.RemovedChildren) > 0 {
err := seq.SetChildren(ctx, room.ID, sdc.Children, sdc.RemovedChildren, sdc.IsFullState)
if err != nil {
return fmt.Errorf("failed to set space children: %w", err)
}
}
if len(sdc.Parents) > 0 || len(sdc.RemovedParents) > 0 {
err := seq.SetParents(ctx, room.ID, sdc.Parents, sdc.RemovedParents, sdc.IsFullState)
if err != nil {
return fmt.Errorf("failed to set space parents: %w", err)
}
if len(sdc.Parents) > 0 {
err = seq.RevalidateAllParentsOfRoomValidity(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to revalidate own parent references: %w", err)
}
}
}
if sdc.PowerLevelChanged {
err := seq.RevalidateAllChildrenOfParentValidity(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to revalidate child parent references to self: %w", err)
}
}
return nil
})
}
func processImportantEvent(
ctx context.Context,
evt *event.Event,
existingRoomData, updatedRoom *database.Room,
rowID database.EventRowID,
sdc *spaceDataCollector,
) (roomDataChanged bool) {
if evt.StateKey == nil {
return
}
switch evt.Type {
case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias,
event.StateRoomAvatar, event.StateTopic, event.StateEncryption:
event.StateRoomAvatar, event.StateTopic, event.StateEncryption,
event.StateSpaceChild, event.StateSpaceParent, event.StatePowerLevels:
if *evt.StateKey != "" {
return
}
@ -1047,6 +1140,7 @@ func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomDa
return
}
err := evt.Content.ParseRaw(evt.Type)
sdc.Collect(evt, rowID)
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("event_type", &evt.Type).

View file

@ -12,5 +12,16 @@
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
<audio id="default-notification-sound" preload="auto" src="sounds/bright.flac"></audio>
<svg style="position: absolute; width: 0; height: 0;" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
<path d="M 0,0.5
C 0,0 0,0 0.5,0
1,0 1,0 1,0.5
1,1 1,1 0.5,1
0,1 0,1 0,0.5"></path>
</clipPath>
</defs>
</svg>
</body>
</html>

View file

@ -54,7 +54,7 @@ export const getUserColor = (userID: UserID) => {
// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go
function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string {
return "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="${backgroundColor}"/>
<rect x="0" y="0" width="1000" height="1000" fill="${backgroundColor}"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>${escapeHTMLChar(fallbackCharacter)}</text>
@ -103,8 +103,8 @@ export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: Conten
if ("dm_user_id" in room) {
dmUserID = room.dm_user_id
} else if ("lazy_load_summary" in room) {
dmUserID = room.lazy_load_summary?.heroes?.length === 1
? room.lazy_load_summary.heroes[0] : undefined
dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1
? room.lazy_load_summary["m.heroes"][0] : undefined
}
return getAvatarURL(dmUserID ?? room.room_id, {
displayname: room.name,

View file

@ -39,6 +39,7 @@ import {
} from "../types"
import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.ts"
import { RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace } from "./space.ts"
export interface RoomListEntry {
room_id: RoomID
@ -72,7 +73,11 @@ export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
currentRoomListFilter: string = ""
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this)
currentRoomListQuery: string = ""
currentRoomListFilter: RoomListFilter | null = null
readonly accountData: Map<string, UnknownEventContent> = new Map()
readonly accountDataSubs = new MultiSubscribable()
readonly emojiRoomsSub = new Subscribable()
@ -89,11 +94,25 @@ export class StateStore {
activeRoomIsPreview: boolean = false
imageAuthToken?: string
getFilteredRoomList(): RoomListEntry[] {
if (!this.currentRoomListFilter) {
return this.roomList.current
#roomListFilterFunc = (entry: RoomListEntry) => {
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
return false
} else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) {
return false
}
return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter))
return true
}
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null
}
return this.#roomListFilterFunc
}
getFilteredRoomList(): RoomListEntry[] {
const fn = this.roomListFilterFunc
return fn ? this.roomList.current.filter(fn) : this.roomList.current
}
#shouldHideRoom(entry: SyncRoom): boolean {
@ -122,7 +141,7 @@ export class StateStore {
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
(entry.events ?? []).findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
}
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
@ -143,8 +162,8 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room"
return {
room_id: entry.meta.room_id,
dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1
? entry.meta.lazy_load_summary.heroes[0] : undefined,
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
sorting_timestamp: entry.meta.sorting_timestamp,
preview_event,
preview_sender,
@ -165,7 +184,7 @@ export class StateStore {
}
const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
for (const data of sync.invited_rooms) {
for (const data of sync.invited_rooms ?? []) {
const room = new InvitedRoomStore(data, this)
this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) {
@ -176,7 +195,7 @@ export class StateStore {
}
}
const hasInvites = this.inviteRooms.size > 0
for (const [roomID, data] of Object.entries(sync.rooms)) {
for (const [roomID, data] of Object.entries(sync.rooms ?? {})) {
let isNewRoom = false
let room = this.rooms.get(roomID)
if (!room) {
@ -203,7 +222,7 @@ export class StateStore {
}
}
if (window.Notification?.permission === "granted" && !focused.current) {
if (window.Notification?.permission === "granted" && !focused.current && data.notifications) {
for (const notification of data.notifications) {
this.showNotification(room, notification.event_rowid, notification.sound)
}
@ -212,7 +231,7 @@ export class StateStore {
this.switchRoom?.(roomID)
}
}
for (const ad of Object.values(sync.account_data)) {
for (const ad of Object.values(sync.account_data ?? {})) {
if (ad.type === "io.element.recent_emoji") {
this.#frequentlyUsedEmoji = null
} else if (ad.type === "fi.mau.gomuks.preferences") {
@ -222,7 +241,7 @@ export class StateStore {
this.accountData.set(ad.type, ad.content)
this.accountDataSubs.notify(ad.type)
}
for (const roomID of sync.left_rooms) {
for (const roomID of sync.left_rooms ?? []) {
if (this.activeRoomID === roomID) {
this.switchRoom?.(null)
}
@ -233,7 +252,7 @@ export class StateStore {
let updatedRoomList: RoomListEntry[] | undefined
if (resyncRoomList) {
updatedRoomList = this.inviteRooms.values().toArray()
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms)
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {})
.map(entry => this.#makeRoomListEntry(entry))
.filter(entry => entry !== null))
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
@ -259,6 +278,19 @@ export class StateStore {
if (updatedRoomList) {
this.roomList.emit(updatedRoomList)
}
if (sync.space_edges) {
// Ensure all space stores exist first
for (const spaceID of Object.keys(sync.space_edges)) {
this.getSpaceStore(spaceID, true)
}
for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) {
this.getSpaceStore(spaceID, true).children = children
}
}
if (sync.top_level_spaces) {
this.topLevelSpaces.emit(sync.top_level_spaces)
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
}
}
invalidateEmojiPackKeyCache() {
@ -324,6 +356,20 @@ export class StateStore {
return this.#watchedRoomEmojiPacks ?? {}
}
getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore
getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null
getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null {
let store = this.spaceEdges.get(spaceID)
if (!store) {
if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") {
return null
}
store = new SpaceEdgeStore(spaceID, this)
this.spaceEdges.set(spaceID, store)
}
return store
}
get frequentlyUsedEmoji(): Map<string, number> {
if (this.#frequentlyUsedEmoji === null) {
const emojiData = this.accountData.get("io.element.recent_emoji")
@ -433,9 +479,12 @@ export class StateStore {
clear() {
this.rooms.clear()
this.inviteRooms.clear()
this.spaceEdges.clear()
this.roomList.emit([])
this.topLevelSpaces.emit([])
this.accountData.clear()
this.currentRoomListFilter = ""
this.currentRoomListQuery = ""
this.currentRoomListFilter = null
this.#frequentlyUsedEmoji = null
this.#emojiPackKeys = null
this.#watchedRoomEmojiPacks = null

View file

@ -62,7 +62,7 @@ function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
arraysAreEqual(ll1?.heroes, ll2?.heroes)
arraysAreEqual(ll1?.["m.heroes"], ll2?.["m.heroes"])
}
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
@ -390,7 +390,7 @@ export class RoomStateStore {
} else {
this.meta.emit(sync.meta)
}
for (const ad of Object.values(sync.account_data)) {
for (const ad of Object.values(sync.account_data ?? {})) {
if (ad.type === "fi.mau.gomuks.preferences") {
this.serverPreferenceCache = ad.content
this.preferenceSub.notify()
@ -398,10 +398,10 @@ export class RoomStateStore {
this.accountData.set(ad.type, ad.content)
this.accountDataSubs.notify(ad.type)
}
for (const evt of sync.events) {
for (const evt of sync.events ?? []) {
this.applyEvent(evt)
}
for (const [evtType, changedEvts] of Object.entries(sync.state)) {
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
let stateMap = this.state.get(evtType)
if (!stateMap) {
stateMap = new Map()
@ -414,9 +414,9 @@ export class RoomStateStore {
this.stateSubs.notify(evtType)
}
if (sync.reset) {
this.timeline = sync.timeline
this.timeline = sync.timeline ?? []
this.pendingEvents.splice(0, this.pendingEvents.length)
} else {
} else if (sync.timeline) {
this.timeline.push(...sync.timeline)
}
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
@ -426,7 +426,7 @@ export class RoomStateStore {
this.openNotifications.clear()
}
this.notifyTimelineSubscribers()
for (const [evtID, receipts] of Object.entries(sync.receipts)) {
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
this.applyReceipts(receipts, evtID, false)
}
}

View file

@ -0,0 +1,136 @@
// 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 { RoomListEntry, StateStore } from "@/api/statestore/main.ts"
import { DBSpaceEdge, RoomID } from "@/api/types"
export interface RoomListFilter {
id: string
include(room: RoomListEntry): boolean
}
export const DirectChatSpace: RoomListFilter = {
id: "fi.mau.gomuks.direct_chats",
include: room => !!room.dm_user_id,
}
export const UnreadsSpace: RoomListFilter = {
id: "fi.mau.gomuks.unreads",
include: room => Boolean(room.unread_messages
|| room.unread_notifications
|| room.unread_highlights
|| room.marked_unread),
}
export class SpaceEdgeStore implements RoomListFilter {
#children: DBSpaceEdge[] = []
#childRooms: Set<RoomID> = new Set()
#flattenedRooms: Set<RoomID> = new Set()
#childSpaces: Set<SpaceEdgeStore> = new Set()
readonly #parentSpaces: Set<SpaceEdgeStore> = new Set()
constructor(public id: RoomID, private parent: StateStore) {
}
#addParent(parent: SpaceEdgeStore) {
this.#parentSpaces.add(parent)
}
#removeParent(parent: SpaceEdgeStore) {
this.#parentSpaces.delete(parent)
}
include(room: RoomListEntry) {
return this.#flattenedRooms.has(room.room_id)
}
get children() {
return this.#children
}
#updateFlattened(recalculate: boolean, added: Set<RoomID>) {
if (recalculate) {
let flattened = new Set(this.#childRooms)
for (const space of this.#childSpaces) {
flattened = flattened.union(space.#flattenedRooms)
}
this.#flattenedRooms = flattened
} else if (added.size > 50) {
this.#flattenedRooms = this.#flattenedRooms.union(added)
} else if (added.size > 0) {
for (const room of added) {
this.#flattenedRooms.add(room)
}
}
}
#notifyParentsOfChange(recalculate: boolean, added: Set<RoomID>, stack: WeakSet<SpaceEdgeStore>) {
if (stack.has(this)) {
return
}
stack.add(this)
for (const parent of this.#parentSpaces) {
parent.#updateFlattened(recalculate, added)
parent.#notifyParentsOfChange(recalculate, added, stack)
}
stack.delete(this)
}
set children(newChildren: DBSpaceEdge[]) {
const newChildRooms = new Set<RoomID>()
const newChildSpaces = new Set<SpaceEdgeStore>()
for (const child of newChildren) {
const spaceStore = this.parent.getSpaceStore(child.child_id)
if (spaceStore) {
newChildSpaces.add(spaceStore)
spaceStore.#addParent(this)
} else {
newChildRooms.add(child.child_id)
}
}
for (const space of this.#childSpaces) {
if (!newChildSpaces.has(space)) {
space.#removeParent(this)
}
}
const addedRooms = newChildRooms.difference(this.#childRooms)
const removedRooms = this.#childRooms.difference(newChildRooms)
const didAddChildren = newChildSpaces.difference(this.#childSpaces).size > 0
const recalculateFlattened = removedRooms.size > 0 || didAddChildren
this.#children = newChildren
this.#childRooms = newChildRooms
this.#childSpaces = newChildSpaces
if (this.#childSpaces.size > 0) {
this.#updateFlattened(recalculateFlattened, addedRooms)
} else {
this.#flattenedRooms = newChildRooms
}
if (this.#parentSpaces.size > 0) {
this.#notifyParentsOfChange(recalculateFlattened, addedRooms, new WeakSet())
}
}
}
export class SpaceOrphansSpace extends SpaceEdgeStore {
static id = "fi.mau.gomuks.space_orphans"
constructor(parent: StateStore) {
super(SpaceOrphansSpace.id, parent)
}
include(room: RoomListEntry): boolean {
return !super.include(room) && !room.dm_user_id
}
}

View file

@ -19,6 +19,7 @@ import {
DBReceipt,
DBRoom,
DBRoomAccountData,
DBSpaceEdge,
EventRowID,
RawDBEvent,
TimelineRowTuple,
@ -71,13 +72,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand<string> {
export interface SyncRoom {
meta: DBRoom
timeline: TimelineRowTuple[]
events: RawDBEvent[]
state: Record<EventType, Record<string, EventRowID>>
timeline: TimelineRowTuple[] | null
events: RawDBEvent[] | null
state: Record<EventType, Record<string, EventRowID>> | null
reset: boolean
notifications: SyncNotification[]
account_data: Record<EventType, DBRoomAccountData>
receipts: Record<EventID, DBReceipt[]>
notifications: SyncNotification[] | null
account_data: Record<EventType, DBRoomAccountData> | null
receipts: Record<EventID, DBReceipt[]> | null
}
export interface SyncNotification {
@ -86,10 +87,12 @@ export interface SyncNotification {
}
export interface SyncCompleteData {
rooms: Record<RoomID, SyncRoom>
invited_rooms: DBInvitedRoom[]
left_rooms: RoomID[]
account_data: Record<EventType, DBAccountData>
rooms: Record<RoomID, SyncRoom> | null
invited_rooms: DBInvitedRoom[] | null
left_rooms: RoomID[] | null
account_data: Record<EventType, DBAccountData> | null
space_edges: Record<RoomID, DBSpaceEdge[]> | null
top_level_spaces: RoomID[] | null
since?: string
clear_state?: boolean
}

View file

@ -71,6 +71,18 @@ export interface DBRoom {
prev_batch: string
}
export interface DBSpaceEdge {
// space_id: RoomID
child_id: RoomID
child_event_rowid?: EventRowID
order?: string
suggested?: true
parent_event_rowid?: EventRowID
canonical?: true
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
export type UnknownEventContent = Record<string, any>

View file

@ -43,7 +43,7 @@ export interface TombstoneEventContent {
}
export interface LazyLoadSummary {
heroes?: UserID[]
"m.heroes"?: UserID[]
"m.joined_member_count"?: number
"m.invited_member_count"?: number
}

1
web/src/icons/home.svg Normal file
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="M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z"/></svg>

After

Width:  |  Height:  |  Size: 244 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-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80Zm0-420ZM160-200v-80h80v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v13q-11 22-16 45t-4 47q-10-2-19.5-3.5T480-720q-66 0-113 47t-47 113v280h320v-257q18 8 38.5 12.5T720-520v240h80v80H160Zm560-400q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Z"/></svg>

After

Width:  |  Height:  |  Size: 480 B

1
web/src/icons/person.svg Normal file
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-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>

After

Width:  |  Height:  |  Size: 549 B

1
web/src/icons/tag.svg Normal file
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="m240-160 40-160H120l20-80h160l40-160H180l20-80h160l40-160h80l-40 160h160l40-160h80l-40 160h160l-20 80H660l-40 160h160l-20 80H600l-40 160h-80l40-160H360l-40 160h-80Zm140-240h160l40-160H420l-40 160Z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -1,5 +1,5 @@
main.matrix-main {
--room-list-width: 300px;
--room-list-width: 350px;
--right-panel-width: 300px;
position: fixed;

View file

@ -294,8 +294,8 @@ const MainScreen = () => {
const roomID = evt.state?.room_id ?? null
if (roomID !== client.store.activeRoomID) {
context.setActiveRoom(roomID, {
alias: ensureString(evt?.state.source_alias) || undefined,
via: ensureStringArray(evt?.state.source_via),
alias: ensureString(evt.state?.source_alias) || undefined,
via: ensureStringArray(evt.state?.source_via),
}, false)
}
context.setRightPanel(evt.state?.right_panel ?? null, false)
@ -318,7 +318,7 @@ const MainScreen = () => {
}, [context, client])
useEffect(() => context.keybindings.listen(), [context])
const [roomListWidth, resizeHandle1] = useResizeHandle(
300, 48, Math.min(900, window.innerWidth * 0.4),
350, 96, Math.min(900, window.innerWidth * 0.4),
"roomListWidth", { className: "room-list-resizer" },
)
const [rightPanelWidth, resizeHandle2] = useResizeHandle(

View file

@ -35,6 +35,7 @@ div.autocompletions {
> img {
width: 1.5rem;
height: 1.5rem;
object-fit: contain;
}
}
}

View file

@ -13,7 +13,7 @@
//
// 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 React, { JSX, useCallback, useLayoutEffect, useReducer, useRef } from "react"
import React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
@ -40,7 +40,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
evt.stopPropagation()
}
const openModal = useCallback((newState: ModalState) => {
if (!history.state?.modal) {
if (!history.state?.modal && newState.captureInput !== false) {
history.pushState({ ...(history.state ?? {}), modal: true }, "")
}
setState(newState)
@ -50,6 +50,9 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
wrapperRef.current.focus()
}
}, [state])
useEffect(() => {
window.closeModal = onClickWrapper
const listener = (evt: PopStateEvent) => {
if (!evt.state?.modal) {
setState(null)
@ -57,7 +60,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
}
window.addEventListener("popstate", listener)
return () => window.removeEventListener("popstate", listener)
}, [state])
}, [])
let modal: JSX.Element | null = null
if (state) {
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
@ -68,15 +71,19 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
</div>
</div>
}
modal = <div
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
onClick={onClickWrapper}
onKeyDown={onKeyWrapper}
tabIndex={-1}
ref={wrapperRef}
>
{content}
</div>
if (state.captureInput !== false) {
modal = <div
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
onClick={onClickWrapper}
onKeyDown={onKeyWrapper}
tabIndex={-1}
ref={wrapperRef}
>
{content}
</div>
} else {
modal = content
}
}
return <ModalContext value={openModal}>
{children}

View file

@ -32,6 +32,7 @@ export interface ModalState {
boxClass?: string
innerBoxClass?: string
onClose?: () => void
captureInput?: boolean
}
type openModal = (state: ModalState) => void

View file

@ -40,8 +40,8 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
}
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,
dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1
? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined,
name: roomData.meta.current.name ?? "Unnamed room",
avatar: roomData.meta.current.avatar,
search_name: "",

View file

@ -0,0 +1,52 @@
// 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 { JSX } from "react"
import type { RoomListFilter } from "@/api/statestore/space.ts"
import HomeIcon from "@/icons/home.svg?react"
import NotificationsIcon from "@/icons/notifications.svg?react"
import PersonIcon from "@/icons/person.svg?react"
import TagIcon from "@/icons/tag.svg?react"
import "./RoomList.css"
export interface FakeSpaceProps {
space: RoomListFilter | null
setSpace: (space: RoomListFilter | null) => void
isActive: boolean
}
const getFakeSpaceIcon = (space: RoomListFilter | null): JSX.Element | null => {
switch (space?.id) {
case undefined:
return <HomeIcon />
case "fi.mau.gomuks.direct_chats":
return <PersonIcon />
case "fi.mau.gomuks.unreads":
return <NotificationsIcon />
case "fi.mau.gomuks.space_orphans":
return <TagIcon />
default:
return null
}
}
const FakeSpace = ({ space, setSpace, isActive }: FakeSpaceProps) => {
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={() => setSpace(space)}>
{getFakeSpaceIcon(space)}
</div>
}
export default FakeSpace

View file

@ -5,14 +5,53 @@ div.room-list-wrapper {
box-sizing: border-box;
overflow: hidden;
scrollbar-color: var(--room-list-scrollbar-color);
display: flex;
flex-direction: column;
display: grid;
grid-template:
"spacebar search" 3.5rem
"spacebar roomlist" 1fr
/ 3rem 1fr;
}
div.room-list {
background-color: var(--room-list-background-overlay);
overflow-y: auto;
flex: 1;
grid-area: roomlist;
}
div.space-bar {
background-color: var(--space-list-background-overlay);
grid-area: spacebar;
overflow: auto;
scrollbar-width: none;
> div.space-entry {
width: 2rem;
height: 2rem;
padding: .25rem;
margin: .25rem;
border-radius: .25rem;
cursor: var(--clickable-cursor);
&:hover, &:focus {
background-color: var(--room-list-entry-hover-color);
}
&.active {
background-color: var(--room-list-entry-selected-color);
}
> svg {
width: 100%;
height: 100%;
}
> img.avatar {
border-radius: 0;
clip-path: url(#squircle);
width: 100%;
height: 100%;
}
}
}
div.room-search-wrapper {
@ -21,6 +60,7 @@ div.room-search-wrapper {
align-items: center;
height: 3.5rem;
background-color: var(--room-list-search-background-overlay);
grid-area: search;
> input {
padding: 0 0 0 1rem;

View file

@ -13,7 +13,8 @@
//
// 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 React, { use, useRef, useState } from "react"
import React, { use, useCallback, useRef, useState } from "react"
import { DirectChatSpace, RoomListFilter, UnreadsSpace } from "@/api/statestore/space.ts"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import reverseMap from "@/util/reversemap.ts"
@ -22,6 +23,8 @@ import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { keyToString } from "../keybindings.ts"
import Entry from "./Entry.tsx"
import FakeSpace from "./FakeSpace.tsx"
import Space from "./Space.tsx"
import CloseIcon from "@/icons/close.svg?react"
import SearchIcon from "@/icons/search.svg?react"
import "./RoomList.css"
@ -34,20 +37,27 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
const client = use(ClientContext)!
const mainScreen = use(MainScreenContext)
const roomList = useEventAsState(client.store.roomList)
const roomFilterRef = useRef<HTMLInputElement>(null)
const [roomFilter, setRoomFilter] = useState("")
const [realRoomFilter, setRealRoomFilter] = useState("")
const spaces = useEventAsState(client.store.topLevelSpaces)
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, directSetQuery] = useState("")
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const updateRoomFilter = (evt: React.ChangeEvent<HTMLInputElement>) => {
setRoomFilter(evt.target.value)
client.store.currentRoomListFilter = toSearchableString(evt.target.value)
setRealRoomFilter(client.store.currentRoomListFilter)
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
client.store.currentRoomListQuery = toSearchableString(evt.target.value)
directSetQuery(evt.target.value)
}
const setSpace = useCallback((space: RoomListFilter | null) => {
directSetSpace(space)
client.store.currentRoomListFilter = space
}, [client])
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
setSpace(store)
}, [setSpace, client])
const clearQuery = () => {
setRoomFilter("")
client.store.currentRoomListFilter = ""
setRealRoomFilter("")
roomFilterRef.current?.focus()
client.store.currentRoomListQuery = ""
directSetQuery("")
searchInputRef.current?.focus()
}
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
const key = keyToString(evt)
@ -64,28 +74,49 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
}
}
const roomListFilter = client.store.roomListFilterFunc
const pseudoSpaces = [
null,
DirectChatSpace,
UnreadsSpace,
client.store.spaceOrphans,
]
return <div className="room-list-wrapper">
<div className="room-search-wrapper">
<input
value={roomFilter}
onChange={updateRoomFilter}
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
className="room-search"
type="text"
placeholder="Search rooms"
ref={roomFilterRef}
ref={searchInputRef}
id="room-search"
/>
<button onClick={clearQuery} disabled={roomFilter === ""}>
{roomFilter !== "" ? <CloseIcon/> : <SearchIcon/>}
<button onClick={clearQuery} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>
<div className="space-bar">
{pseudoSpaces.map(pseudoSpace => <FakeSpace
key={pseudoSpace?.id ?? "null"}
space={pseudoSpace}
setSpace={setSpace}
isActive={space?.id === pseudoSpace?.id}
/>)}
{spaces.map(roomID => <Space
roomID={roomID}
client={client}
onClick={onClickSpace}
isActive={space?.id === roomID}
/>)}
</div>
<div className="room-list">
{reverseMap(roomList, room =>
<Entry
key={room.room_id}
isActive={room.room_id === activeRoomID}
hidden={roomFilter ? !room.search_name.includes(realRoomFilter) : false}
hidden={roomListFilter ? !roomListFilter(room) : false}
room={room}
/>,
)}

View file

@ -0,0 +1,40 @@
// 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 React from "react"
import Client from "@/api/client.ts"
import { getRoomAvatarURL } from "@/api/media.ts"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import "./RoomList.css"
export interface SpaceProps {
roomID: RoomID
client: Client
onClick: (evt: React.MouseEvent<HTMLDivElement>) => void
isActive: boolean
}
const Space = ({ roomID, client, onClick, isActive }: SpaceProps) => {
const room = useEventAsState(client.store.rooms.get(roomID)?.meta)
if (!room) {
return
}
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}>
<img src={getRoomAvatarURL(room)} alt={room.name} title={room.name} className="avatar" />
</div>
}
export default Space

View file

@ -27,7 +27,7 @@ import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx"
import URLPreviews from "./URLPreviews.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "@/icons/error.svg?react"
import PendingIcon from "@/icons/pending.svg?react"
import SentIcon from "@/icons/sent.svg?react"
@ -98,6 +98,31 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
/>,
})
}
const onClick = (mouseEvt: React.MouseEvent) => {
const targetElem = mouseEvt.target as HTMLElement
if (
targetElem.tagName === "A"
|| targetElem.tagName === "IMG"
) {
return
}
mouseEvt.preventDefault()
if (window.hackyOpenEventContextMenu === evt.event_id) {
window.closeModal()
window.hackyOpenEventContextMenu = undefined
} else {
openModal({
content: <EventFixedMenu evt={evt} roomCtx={roomCtx} />,
captureInput: false,
onClose: () => {
if (window.hackyOpenEventContextMenu === evt.event_id) {
window.hackyOpenEventContextMenu = undefined
}
},
})
window.hackyOpenEventContextMenu = evt.event_id
}
}
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(evt)
@ -175,11 +200,12 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
data-event-id={evt.event_id}
className={wrapperClassNames.join(" ")}
onContextMenu={onContextMenu}
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
>
{!disableMenu && !isMobileDevice && <div
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
>
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
</div>}
{replyAboveMessage}
{renderAvatar && <div

View file

@ -16,17 +16,18 @@
import { CSSProperties, use } from "react"
import { MemDBEvent } from "@/api/types"
import ClientContext from "../../ClientContext.ts"
import { RoomContextData, useRoomContext } from "../../roomview/roomcontext.ts"
import { RoomContextData } from "../../roomview/roomcontext.ts"
import { usePrimaryItems } from "./usePrimaryItems.tsx"
import { useSecondaryItems } from "./useSecondaryItems.tsx"
interface EventHoverMenuProps {
evt: MemDBEvent
roomCtx: RoomContextData
setForceOpen: (forceOpen: boolean) => void
}
export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen)
export const EventHoverMenu = ({ evt, roomCtx, setForceOpen }: EventHoverMenuProps) => {
const elements = usePrimaryItems(use(ClientContext)!, roomCtx, evt, true, false, undefined, setForceOpen)
return <div className="event-hover-menu">{elements}</div>
}
@ -43,7 +44,7 @@ export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, style, undefined)
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt)
return <div style={style} className="event-context-menu full">
{primary}
@ -51,3 +52,13 @@ export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =>
{secondary}
</div>
}
export const EventFixedMenu = ({ evt, roomCtx }: Omit<EventContextMenuProps, "style">) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, true, undefined, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt, false)
return <div className="event-fixed-menu">
{primary}
{secondary}
</div>
}

View file

@ -2,13 +2,9 @@ div.event-hover-menu {
position: absolute;
right: .5rem;
top: -1.5rem;
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: .5rem;
display: flex;
gap: .25rem;
padding: .125rem;
z-index: 1;
> button {
width: 2rem;
@ -16,6 +12,36 @@ div.event-hover-menu {
}
}
div.event-hover-menu, div.event-fixed-menu {
display: flex;
gap: .25rem;
background-color: var(--background-color);
z-index: 1;
}
div.event-fixed-menu {
position: fixed;
inset: 0 0 auto;
height: 3.5rem;
padding: .25rem;
border-bottom: 1px solid var(--border-color);
box-sizing: border-box;
justify-content: right;
flex-direction: row-reverse;
overflow-x: auto;
overflow-y: hidden;
> button {
width: 3rem;
height: 3rem;
flex-shrink: 0;
&.redact-button {
color: var(--error-color);
}
}
}
div.event-context-menu {
position: fixed;
background-color: var(--background-color);

View file

@ -13,5 +13,5 @@
//
// 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/>.
export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { getModalStyleFromMouse } from "./util.ts"

View file

@ -37,9 +37,11 @@ export const usePrimaryItems = (
roomCtx: RoomContextData,
evt: MemDBEvent,
isHover: boolean,
isFixed: boolean,
style?: CSSProperties,
setForceOpen?: (forceOpen: boolean) => void,
) => {
const names = !isHover && !isFixed
const closeModal = !isHover ? use(ModalCloseContext) : noop
const openModal = use(ModalContext)
@ -108,11 +110,11 @@ export const usePrimaryItems = (
return <>
{didFail && <button onClick={onClickResend} title="Resend message">
<RefreshIcon/>
{!isHover && "Resend"}
{names && "Resend"}
</button>}
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}>
<ReactIcon/>
{!isHover && "React"}
{names && "React"}
</button>}
{canSend && <button
disabled={isEditing || isPending}
@ -120,11 +122,11 @@ export const usePrimaryItems = (
onClick={onClickReply}
>
<ReplyIcon/>
{!isHover && "Reply"}
{names && "Reply"}
</button>}
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}>
<EditIcon/>
{!isHover && "Edit"}
{names && "Edit"}
</button>}
{isHover && <button onClick={onClickMore}><MoreIcon/></button>}
</>

View file

@ -32,6 +32,7 @@ export const useSecondaryItems = (
client: Client,
roomCtx: RoomContextData,
evt: MemDBEvent,
names = true,
) => {
const closeModal = use(ModalCloseContext)
const openModal = use(ModalContext)
@ -102,20 +103,22 @@ export const useSecondaryItems = (
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
return <>
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
{ownPL >= pinPL && (pins.includes(evt.event_id)
? <button onClick={onClickPin(false)}>
<UnpinIcon/>Unpin message
<UnpinIcon/>{names && "Unpin message"}
</button>
: <button onClick={onClickPin(true)} title={pendingTitle} disabled={isPending}>
<PinIcon/>Pin message
<PinIcon/>{names && "Pin message"}
</button>)}
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}><ReportIcon/>Report</button>
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}>
<ReportIcon/>{names && "Report"}
</button>
{canRedact && <button
onClick={onClickRedact}
disabled={isPending}
title={pendingTitle}
className="redact-button"
><DeleteIcon/>Remove</button>}
><DeleteIcon/>{names && "Remove"}</button>}
</>
}

View file

@ -15,12 +15,15 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useSyncExternalStore } from "react"
const noop = () => {}
const noopListen = () => noop
export function useEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T
export function useEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null
export function useEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null {
export function useEventAsState<T>(dispatcher?: CachedEventDispatcher<T>): T | null
export function useEventAsState<T>(dispatcher?: CachedEventDispatcher<T>): T | null {
return useSyncExternalStore(
dispatcher.listenChange,
() => dispatcher.current,
dispatcher ? dispatcher.listenChange : noopListen,
() => dispatcher ? dispatcher.current : null,
)
}

View file

@ -14,5 +14,7 @@ declare global {
mainScreenContext: MainScreenContextFields
openLightbox: (params: { src: string, alt: string }) => void
gcSettings: GCSettings
hackyOpenEventContextMenu?: string
closeModal: () => void
}
}