mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
Merge branch 'main' into nexy7574/extended-profiles
This commit is contained in:
commit
f5e2584754
40 changed files with 1100 additions and 172 deletions
|
@ -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
|
// 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">
|
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"
|
<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"
|
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
>%s</text>
|
>%s</text>
|
||||||
|
|
|
@ -17,16 +17,17 @@ import (
|
||||||
type Database struct {
|
type Database struct {
|
||||||
*dbutil.Database
|
*dbutil.Database
|
||||||
|
|
||||||
Account AccountQuery
|
Account *AccountQuery
|
||||||
AccountData AccountDataQuery
|
AccountData *AccountDataQuery
|
||||||
Room RoomQuery
|
Room *RoomQuery
|
||||||
InvitedRoom InvitedRoomQuery
|
InvitedRoom *InvitedRoomQuery
|
||||||
Event EventQuery
|
Event *EventQuery
|
||||||
CurrentState CurrentStateQuery
|
CurrentState *CurrentStateQuery
|
||||||
Timeline TimelineQuery
|
Timeline *TimelineQuery
|
||||||
SessionRequest SessionRequestQuery
|
SessionRequest *SessionRequestQuery
|
||||||
Receipt ReceiptQuery
|
Receipt *ReceiptQuery
|
||||||
Media MediaQuery
|
Media *MediaQuery
|
||||||
|
SpaceEdge *SpaceEdgeQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(rawDB *dbutil.Database) *Database {
|
func New(rawDB *dbutil.Database) *Database {
|
||||||
|
@ -35,16 +36,17 @@ func New(rawDB *dbutil.Database) *Database {
|
||||||
return &Database{
|
return &Database{
|
||||||
Database: rawDB,
|
Database: rawDB,
|
||||||
|
|
||||||
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||||
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||||
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||||
InvitedRoom: InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
||||||
Event: EventQuery{QueryHelper: eventQH},
|
Event: &EventQuery{QueryHelper: eventQH},
|
||||||
CurrentState: CurrentStateQuery{QueryHelper: eventQH},
|
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
|
||||||
Timeline: TimelineQuery{QueryHelper: eventQH},
|
Timeline: &TimelineQuery{QueryHelper: eventQH},
|
||||||
SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||||
Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||||
Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
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 {
|
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
|
||||||
return &Account{}
|
return &Account{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
|
||||||
|
return &SpaceEdge{}
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ const (
|
||||||
FROM room
|
FROM room
|
||||||
`
|
`
|
||||||
getRoomsBySortingTimestampQuery = getRoomBaseQuery + `WHERE sorting_timestamp < $1 AND sorting_timestamp > 0 ORDER BY sorting_timestamp DESC LIMIT $2`
|
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`
|
getRoomByIDQuery = getRoomBaseQuery + `WHERE room_id = $1`
|
||||||
ensureRoomExistsQuery = `
|
ensureRoomExistsQuery = `
|
||||||
INSERT INTO room (room_id) VALUES ($1)
|
INSERT INTO room (room_id) VALUES ($1)
|
||||||
|
@ -34,7 +35,8 @@ const (
|
||||||
`
|
`
|
||||||
upsertRoomFromSyncQuery = `
|
upsertRoomFromSyncQuery = `
|
||||||
UPDATE room
|
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),
|
tombstone_content = COALESCE(room.tombstone_content, $3),
|
||||||
name = COALESCE($4, room.name),
|
name = COALESCE($4, room.name),
|
||||||
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
|
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)
|
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 {
|
func (rq *RoomQuery) Upsert(ctx context.Context, room *Room) error {
|
||||||
return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...)
|
return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...)
|
||||||
}
|
}
|
||||||
|
|
236
pkg/hicli/database/space.go
Normal file
236
pkg/hicli/database/space.go
Normal 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
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
-- v0 -> v9 (compatible with v5+): Latest revision
|
-- v0 -> v10 (compatible with v10+): Latest revision
|
||||||
CREATE TABLE account (
|
CREATE TABLE account (
|
||||||
user_id TEXT NOT NULL PRIMARY KEY,
|
user_id TEXT NOT NULL PRIMARY KEY,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
|
@ -10,6 +10,7 @@ CREATE TABLE account (
|
||||||
|
|
||||||
CREATE TABLE room (
|
CREATE TABLE room (
|
||||||
room_id TEXT NOT NULL PRIMARY KEY,
|
room_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
room_type TEXT,
|
||||||
creation_content TEXT,
|
creation_content TEXT,
|
||||||
tombstone_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
|
CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL
|
||||||
) STRICT;
|
) 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_sorting_timestamp_idx ON room (sorting_timestamp DESC);
|
||||||
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
|
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
|
||||||
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0);
|
-- 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
|
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.
|
-- note: there's no foreign key on event ID because receipts could point at events that are too far in history.
|
||||||
) STRICT;
|
) 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);
|
||||||
|
|
83
pkg/hicli/database/upgrades/10-spaces.sql
Normal file
83
pkg/hicli/database/upgrades/10-spaces.sql
Normal 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;
|
|
@ -31,12 +31,14 @@ type SyncNotification struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncComplete struct {
|
type SyncComplete struct {
|
||||||
Since *string `json:"since,omitempty"`
|
Since *string `json:"since,omitempty"`
|
||||||
ClearState bool `json:"clear_state,omitempty"`
|
ClearState bool `json:"clear_state,omitempty"`
|
||||||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||||
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
|
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
|
||||||
LeftRooms []id.RoomID `json:"left_rooms"`
|
LeftRooms []id.RoomID `json:"left_rooms"`
|
||||||
InvitedRooms []*database.InvitedRoom `json:"invited_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 {
|
func (c *SyncComplete) IsEmpty() bool {
|
||||||
|
|
|
@ -14,12 +14,9 @@ import (
|
||||||
|
|
||||||
func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom {
|
func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom {
|
||||||
syncRoom := &SyncRoom{
|
syncRoom := &SyncRoom{
|
||||||
Meta: room,
|
Meta: room,
|
||||||
Events: make([]*database.Event, 0, 2),
|
Events: make([]*database.Event, 0, 2),
|
||||||
Timeline: make([]database.TimelineRowTuple, 0),
|
State: map[event.Type]map[string]database.EventRowID{},
|
||||||
State: map[event.Type]map[string]database.EventRowID{},
|
|
||||||
Notifications: make([]SyncNotification, 0),
|
|
||||||
Receipts: make(map[id.EventID][]*database.Receipt),
|
|
||||||
}
|
}
|
||||||
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
|
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -27,7 +24,6 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
syncRoom.AccountData = make(map[event.Type]*database.AccountData)
|
|
||||||
} else {
|
} else {
|
||||||
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
|
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
|
||||||
for _, data := range 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] {
|
func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*SyncComplete] {
|
||||||
return func(yield func(*SyncComplete) bool) {
|
return func(yield func(*SyncComplete) bool) {
|
||||||
maxTS := time.Now().Add(1 * time.Hour)
|
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++ {
|
for i := 0; ; i++ {
|
||||||
rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize)
|
rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -79,22 +118,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload := SyncComplete{
|
payload := SyncComplete{
|
||||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)-1),
|
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
for _, room := range rooms {
|
for _, room := range rooms {
|
||||||
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
||||||
|
@ -106,7 +130,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !yield(&payload) || len(rooms) < batchSize {
|
if !yield(&payload) {
|
||||||
|
return
|
||||||
|
} else if len(rooms) < batchSize {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,10 +143,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload := SyncComplete{
|
payload := SyncComplete{
|
||||||
Rooms: make(map[id.RoomID]*SyncRoom),
|
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
||||||
InvitedRooms: make([]*database.InvitedRoom, 0),
|
|
||||||
LeftRooms: make([]id.RoomID, 0),
|
|
||||||
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
|
||||||
}
|
}
|
||||||
for _, data := range ad {
|
for _, data := range ad {
|
||||||
payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data
|
payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data
|
||||||
|
|
|
@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to save events: %w", err)
|
return fmt.Errorf("failed to save events: %w", err)
|
||||||
}
|
}
|
||||||
|
sdc := &spaceDataCollector{}
|
||||||
for i := range currentStateEntries {
|
for i := range currentStateEntries {
|
||||||
currentStateEntries[i].EventRowID = dbEvts[i].RowID
|
currentStateEntries[i].EventRowID = dbEvts[i].RowID
|
||||||
if mediaReferenceEntries[i] != nil {
|
if mediaReferenceEntries[i] != nil {
|
||||||
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
|
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
|
||||||
}
|
}
|
||||||
if evts[i].Type != event.StateMember {
|
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)
|
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)
|
return fmt.Errorf("failed to save current state entries: %w", err)
|
||||||
}
|
}
|
||||||
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
|
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 {
|
if roomChanged {
|
||||||
err = h.DB.Room.Upsert(ctx, updatedRoom)
|
err = h.DB.Room.Upsert(ctx, updatedRoom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -155,19 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
||||||
h.EventHandler(&SyncComplete{
|
h.EventHandler(&SyncComplete{
|
||||||
Rooms: map[id.RoomID]*SyncRoom{
|
Rooms: map[id.RoomID]*SyncRoom{
|
||||||
roomID: {
|
roomID: {
|
||||||
Meta: room,
|
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),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InvitedRooms: make([]*database.InvitedRoom, 0),
|
|
||||||
AccountData: make(map[event.Type]*database.AccountData),
|
|
||||||
LeftRooms: make([]id.RoomID, 0),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -667,6 +667,7 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
updatedRoom.LazyLoadSummary = summary
|
updatedRoom.LazyLoadSummary = summary
|
||||||
heroesChanged = true
|
heroesChanged = true
|
||||||
}
|
}
|
||||||
|
sdc := &spaceDataCollector{}
|
||||||
decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
|
decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
|
||||||
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
|
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
|
||||||
addedEvents := make(map[database.EventRowID]struct{})
|
addedEvents := make(map[database.EventRowID]struct{})
|
||||||
|
@ -750,7 +751,7 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
if err != nil {
|
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)
|
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)
|
allNewEvents = append(allNewEvents, dbEvt)
|
||||||
addedEvents[dbEvt.RowID] = struct{}{}
|
addedEvents[dbEvt.RowID] = struct{}{}
|
||||||
|
@ -932,6 +933,10 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
return fmt.Errorf("failed to save room data: %w", err)
|
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?
|
// 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 {
|
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
|
||||||
for _, receipt := range receipts {
|
for _, receipt := range receipts {
|
||||||
|
@ -1033,13 +1038,101 @@ func intPtrEqual(a, b *int) bool {
|
||||||
return *a == *b
|
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 {
|
if evt.StateKey == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch evt.Type {
|
switch evt.Type {
|
||||||
case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias,
|
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 != "" {
|
if *evt.StateKey != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1047,6 +1140,7 @@ func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomDa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := evt.Content.ParseRaw(evt.Type)
|
err := evt.Content.ParseRaw(evt.Type)
|
||||||
|
sdc.Collect(evt, rowID)
|
||||||
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
|
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
|
||||||
zerolog.Ctx(ctx).Warn().Err(err).
|
zerolog.Ctx(ctx).Warn().Err(err).
|
||||||
Stringer("event_type", &evt.Type).
|
Stringer("event_type", &evt.Type).
|
||||||
|
|
|
@ -12,5 +12,16 @@
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="src/main.tsx"></script>
|
<script type="module" src="src/main.tsx"></script>
|
||||||
<audio id="default-notification-sound" preload="auto" src="sounds/bright.flac"></audio>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -54,7 +54,7 @@ export const getUserColor = (userID: UserID) => {
|
||||||
// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go
|
// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go
|
||||||
function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string {
|
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">
|
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"
|
<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"
|
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
>${escapeHTMLChar(fallbackCharacter)}</text>
|
>${escapeHTMLChar(fallbackCharacter)}</text>
|
||||||
|
@ -103,8 +103,8 @@ export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: Conten
|
||||||
if ("dm_user_id" in room) {
|
if ("dm_user_id" in room) {
|
||||||
dmUserID = room.dm_user_id
|
dmUserID = room.dm_user_id
|
||||||
} else if ("lazy_load_summary" in room) {
|
} else if ("lazy_load_summary" in room) {
|
||||||
dmUserID = room.lazy_load_summary?.heroes?.length === 1
|
dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||||
? room.lazy_load_summary.heroes[0] : undefined
|
? room.lazy_load_summary["m.heroes"][0] : undefined
|
||||||
}
|
}
|
||||||
return getAvatarURL(dmUserID ?? room.room_id, {
|
return getAvatarURL(dmUserID ?? room.room_id, {
|
||||||
displayname: room.name,
|
displayname: room.name,
|
||||||
|
|
|
@ -39,6 +39,7 @@ import {
|
||||||
} from "../types"
|
} from "../types"
|
||||||
import { InvitedRoomStore } from "./invitedroom.ts"
|
import { InvitedRoomStore } from "./invitedroom.ts"
|
||||||
import { RoomStateStore } from "./room.ts"
|
import { RoomStateStore } from "./room.ts"
|
||||||
|
import { RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace } from "./space.ts"
|
||||||
|
|
||||||
export interface RoomListEntry {
|
export interface RoomListEntry {
|
||||||
room_id: RoomID
|
room_id: RoomID
|
||||||
|
@ -72,7 +73,11 @@ export class StateStore {
|
||||||
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
|
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
|
||||||
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
||||||
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
|
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 accountData: Map<string, UnknownEventContent> = new Map()
|
||||||
readonly accountDataSubs = new MultiSubscribable()
|
readonly accountDataSubs = new MultiSubscribable()
|
||||||
readonly emojiRoomsSub = new Subscribable()
|
readonly emojiRoomsSub = new Subscribable()
|
||||||
|
@ -89,11 +94,25 @@ export class StateStore {
|
||||||
activeRoomIsPreview: boolean = false
|
activeRoomIsPreview: boolean = false
|
||||||
imageAuthToken?: string
|
imageAuthToken?: string
|
||||||
|
|
||||||
getFilteredRoomList(): RoomListEntry[] {
|
#roomListFilterFunc = (entry: RoomListEntry) => {
|
||||||
if (!this.currentRoomListFilter) {
|
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||||
return this.roomList.current
|
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 {
|
#shouldHideRoom(entry: SyncRoom): boolean {
|
||||||
|
@ -122,7 +141,7 @@ export class StateStore {
|
||||||
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
|
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
|
||||||
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
|
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
|
||||||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
|
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 {
|
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
|
||||||
|
@ -143,8 +162,8 @@ export class StateStore {
|
||||||
const name = entry.meta.name ?? "Unnamed room"
|
const name = entry.meta.name ?? "Unnamed room"
|
||||||
return {
|
return {
|
||||||
room_id: entry.meta.room_id,
|
room_id: entry.meta.room_id,
|
||||||
dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1
|
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||||
? entry.meta.lazy_load_summary.heroes[0] : undefined,
|
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
|
||||||
sorting_timestamp: entry.meta.sorting_timestamp,
|
sorting_timestamp: entry.meta.sorting_timestamp,
|
||||||
preview_event,
|
preview_event,
|
||||||
preview_sender,
|
preview_sender,
|
||||||
|
@ -165,7 +184,7 @@ export class StateStore {
|
||||||
}
|
}
|
||||||
const resyncRoomList = this.roomList.current.length === 0
|
const resyncRoomList = this.roomList.current.length === 0
|
||||||
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
|
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)
|
const room = new InvitedRoomStore(data, this)
|
||||||
this.inviteRooms.set(room.room_id, room)
|
this.inviteRooms.set(room.room_id, room)
|
||||||
if (!resyncRoomList) {
|
if (!resyncRoomList) {
|
||||||
|
@ -176,7 +195,7 @@ export class StateStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasInvites = this.inviteRooms.size > 0
|
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 isNewRoom = false
|
||||||
let room = this.rooms.get(roomID)
|
let room = this.rooms.get(roomID)
|
||||||
if (!room) {
|
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) {
|
for (const notification of data.notifications) {
|
||||||
this.showNotification(room, notification.event_rowid, notification.sound)
|
this.showNotification(room, notification.event_rowid, notification.sound)
|
||||||
}
|
}
|
||||||
|
@ -212,7 +231,7 @@ export class StateStore {
|
||||||
this.switchRoom?.(roomID)
|
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") {
|
if (ad.type === "io.element.recent_emoji") {
|
||||||
this.#frequentlyUsedEmoji = null
|
this.#frequentlyUsedEmoji = null
|
||||||
} else if (ad.type === "fi.mau.gomuks.preferences") {
|
} else if (ad.type === "fi.mau.gomuks.preferences") {
|
||||||
|
@ -222,7 +241,7 @@ export class StateStore {
|
||||||
this.accountData.set(ad.type, ad.content)
|
this.accountData.set(ad.type, ad.content)
|
||||||
this.accountDataSubs.notify(ad.type)
|
this.accountDataSubs.notify(ad.type)
|
||||||
}
|
}
|
||||||
for (const roomID of sync.left_rooms) {
|
for (const roomID of sync.left_rooms ?? []) {
|
||||||
if (this.activeRoomID === roomID) {
|
if (this.activeRoomID === roomID) {
|
||||||
this.switchRoom?.(null)
|
this.switchRoom?.(null)
|
||||||
}
|
}
|
||||||
|
@ -233,7 +252,7 @@ export class StateStore {
|
||||||
let updatedRoomList: RoomListEntry[] | undefined
|
let updatedRoomList: RoomListEntry[] | undefined
|
||||||
if (resyncRoomList) {
|
if (resyncRoomList) {
|
||||||
updatedRoomList = this.inviteRooms.values().toArray()
|
updatedRoomList = this.inviteRooms.values().toArray()
|
||||||
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms)
|
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {})
|
||||||
.map(entry => this.#makeRoomListEntry(entry))
|
.map(entry => this.#makeRoomListEntry(entry))
|
||||||
.filter(entry => entry !== null))
|
.filter(entry => entry !== null))
|
||||||
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
|
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
|
||||||
|
@ -259,6 +278,19 @@ export class StateStore {
|
||||||
if (updatedRoomList) {
|
if (updatedRoomList) {
|
||||||
this.roomList.emit(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() {
|
invalidateEmojiPackKeyCache() {
|
||||||
|
@ -324,6 +356,20 @@ export class StateStore {
|
||||||
return this.#watchedRoomEmojiPacks ?? {}
|
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> {
|
get frequentlyUsedEmoji(): Map<string, number> {
|
||||||
if (this.#frequentlyUsedEmoji === null) {
|
if (this.#frequentlyUsedEmoji === null) {
|
||||||
const emojiData = this.accountData.get("io.element.recent_emoji")
|
const emojiData = this.accountData.get("io.element.recent_emoji")
|
||||||
|
@ -433,9 +479,12 @@ export class StateStore {
|
||||||
clear() {
|
clear() {
|
||||||
this.rooms.clear()
|
this.rooms.clear()
|
||||||
this.inviteRooms.clear()
|
this.inviteRooms.clear()
|
||||||
|
this.spaceEdges.clear()
|
||||||
this.roomList.emit([])
|
this.roomList.emit([])
|
||||||
|
this.topLevelSpaces.emit([])
|
||||||
this.accountData.clear()
|
this.accountData.clear()
|
||||||
this.currentRoomListFilter = ""
|
this.currentRoomListQuery = ""
|
||||||
|
this.currentRoomListFilter = null
|
||||||
this.#frequentlyUsedEmoji = null
|
this.#frequentlyUsedEmoji = null
|
||||||
this.#emojiPackKeys = null
|
this.#emojiPackKeys = null
|
||||||
this.#watchedRoomEmojiPacks = null
|
this.#watchedRoomEmojiPacks = null
|
||||||
|
|
|
@ -62,7 +62,7 @@ function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
|
||||||
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
|
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
|
||||||
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
|
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
|
||||||
ll1?.["m.invited_member_count"] === ll2?.["m.invited_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 {
|
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
||||||
|
@ -390,7 +390,7 @@ export class RoomStateStore {
|
||||||
} else {
|
} else {
|
||||||
this.meta.emit(sync.meta)
|
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") {
|
if (ad.type === "fi.mau.gomuks.preferences") {
|
||||||
this.serverPreferenceCache = ad.content
|
this.serverPreferenceCache = ad.content
|
||||||
this.preferenceSub.notify()
|
this.preferenceSub.notify()
|
||||||
|
@ -398,10 +398,10 @@ export class RoomStateStore {
|
||||||
this.accountData.set(ad.type, ad.content)
|
this.accountData.set(ad.type, ad.content)
|
||||||
this.accountDataSubs.notify(ad.type)
|
this.accountDataSubs.notify(ad.type)
|
||||||
}
|
}
|
||||||
for (const evt of sync.events) {
|
for (const evt of sync.events ?? []) {
|
||||||
this.applyEvent(evt)
|
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)
|
let stateMap = this.state.get(evtType)
|
||||||
if (!stateMap) {
|
if (!stateMap) {
|
||||||
stateMap = new Map()
|
stateMap = new Map()
|
||||||
|
@ -414,9 +414,9 @@ export class RoomStateStore {
|
||||||
this.stateSubs.notify(evtType)
|
this.stateSubs.notify(evtType)
|
||||||
}
|
}
|
||||||
if (sync.reset) {
|
if (sync.reset) {
|
||||||
this.timeline = sync.timeline
|
this.timeline = sync.timeline ?? []
|
||||||
this.pendingEvents.splice(0, this.pendingEvents.length)
|
this.pendingEvents.splice(0, this.pendingEvents.length)
|
||||||
} else {
|
} else if (sync.timeline) {
|
||||||
this.timeline.push(...sync.timeline)
|
this.timeline.push(...sync.timeline)
|
||||||
}
|
}
|
||||||
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
|
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
|
||||||
|
@ -426,7 +426,7 @@ export class RoomStateStore {
|
||||||
this.openNotifications.clear()
|
this.openNotifications.clear()
|
||||||
}
|
}
|
||||||
this.notifyTimelineSubscribers()
|
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)
|
this.applyReceipts(receipts, evtID, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
136
web/src/api/statestore/space.ts
Normal file
136
web/src/api/statestore/space.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ import {
|
||||||
DBReceipt,
|
DBReceipt,
|
||||||
DBRoom,
|
DBRoom,
|
||||||
DBRoomAccountData,
|
DBRoomAccountData,
|
||||||
|
DBSpaceEdge,
|
||||||
EventRowID,
|
EventRowID,
|
||||||
RawDBEvent,
|
RawDBEvent,
|
||||||
TimelineRowTuple,
|
TimelineRowTuple,
|
||||||
|
@ -71,13 +72,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand<string> {
|
||||||
|
|
||||||
export interface SyncRoom {
|
export interface SyncRoom {
|
||||||
meta: DBRoom
|
meta: DBRoom
|
||||||
timeline: TimelineRowTuple[]
|
timeline: TimelineRowTuple[] | null
|
||||||
events: RawDBEvent[]
|
events: RawDBEvent[] | null
|
||||||
state: Record<EventType, Record<string, EventRowID>>
|
state: Record<EventType, Record<string, EventRowID>> | null
|
||||||
reset: boolean
|
reset: boolean
|
||||||
notifications: SyncNotification[]
|
notifications: SyncNotification[] | null
|
||||||
account_data: Record<EventType, DBRoomAccountData>
|
account_data: Record<EventType, DBRoomAccountData> | null
|
||||||
receipts: Record<EventID, DBReceipt[]>
|
receipts: Record<EventID, DBReceipt[]> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncNotification {
|
export interface SyncNotification {
|
||||||
|
@ -86,10 +87,12 @@ export interface SyncNotification {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncCompleteData {
|
export interface SyncCompleteData {
|
||||||
rooms: Record<RoomID, SyncRoom>
|
rooms: Record<RoomID, SyncRoom> | null
|
||||||
invited_rooms: DBInvitedRoom[]
|
invited_rooms: DBInvitedRoom[] | null
|
||||||
left_rooms: RoomID[]
|
left_rooms: RoomID[] | null
|
||||||
account_data: Record<EventType, DBAccountData>
|
account_data: Record<EventType, DBAccountData> | null
|
||||||
|
space_edges: Record<RoomID, DBSpaceEdge[]> | null
|
||||||
|
top_level_spaces: RoomID[] | null
|
||||||
since?: string
|
since?: string
|
||||||
clear_state?: boolean
|
clear_state?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,6 +71,18 @@ export interface DBRoom {
|
||||||
prev_batch: string
|
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
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type UnknownEventContent = Record<string, any>
|
export type UnknownEventContent = Record<string, any>
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,7 @@ export interface TombstoneEventContent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LazyLoadSummary {
|
export interface LazyLoadSummary {
|
||||||
heroes?: UserID[]
|
"m.heroes"?: UserID[]
|
||||||
"m.joined_member_count"?: number
|
"m.joined_member_count"?: number
|
||||||
"m.invited_member_count"?: number
|
"m.invited_member_count"?: number
|
||||||
}
|
}
|
||||||
|
|
1
web/src/icons/home.svg
Normal file
1
web/src/icons/home.svg
Normal 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 |
1
web/src/icons/notifications-unread.svg
Normal file
1
web/src/icons/notifications-unread.svg
Normal 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
1
web/src/icons/person.svg
Normal 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
1
web/src/icons/tag.svg
Normal 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 |
|
@ -1,5 +1,5 @@
|
||||||
main.matrix-main {
|
main.matrix-main {
|
||||||
--room-list-width: 300px;
|
--room-list-width: 350px;
|
||||||
--right-panel-width: 300px;
|
--right-panel-width: 300px;
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
@ -294,8 +294,8 @@ const MainScreen = () => {
|
||||||
const roomID = evt.state?.room_id ?? null
|
const roomID = evt.state?.room_id ?? null
|
||||||
if (roomID !== client.store.activeRoomID) {
|
if (roomID !== client.store.activeRoomID) {
|
||||||
context.setActiveRoom(roomID, {
|
context.setActiveRoom(roomID, {
|
||||||
alias: ensureString(evt?.state.source_alias) || undefined,
|
alias: ensureString(evt.state?.source_alias) || undefined,
|
||||||
via: ensureStringArray(evt?.state.source_via),
|
via: ensureStringArray(evt.state?.source_via),
|
||||||
}, false)
|
}, false)
|
||||||
}
|
}
|
||||||
context.setRightPanel(evt.state?.right_panel ?? null, false)
|
context.setRightPanel(evt.state?.right_panel ?? null, false)
|
||||||
|
@ -318,7 +318,7 @@ const MainScreen = () => {
|
||||||
}, [context, client])
|
}, [context, client])
|
||||||
useEffect(() => context.keybindings.listen(), [context])
|
useEffect(() => context.keybindings.listen(), [context])
|
||||||
const [roomListWidth, resizeHandle1] = useResizeHandle(
|
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" },
|
"roomListWidth", { className: "room-list-resizer" },
|
||||||
)
|
)
|
||||||
const [rightPanelWidth, resizeHandle2] = useResizeHandle(
|
const [rightPanelWidth, resizeHandle2] = useResizeHandle(
|
||||||
|
|
|
@ -35,6 +35,7 @@ div.autocompletions {
|
||||||
> img {
|
> img {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
//
|
//
|
||||||
// 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 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"
|
import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
|
||||||
|
|
||||||
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
@ -40,7 +40,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
}
|
}
|
||||||
const openModal = useCallback((newState: ModalState) => {
|
const openModal = useCallback((newState: ModalState) => {
|
||||||
if (!history.state?.modal) {
|
if (!history.state?.modal && newState.captureInput !== false) {
|
||||||
history.pushState({ ...(history.state ?? {}), modal: true }, "")
|
history.pushState({ ...(history.state ?? {}), modal: true }, "")
|
||||||
}
|
}
|
||||||
setState(newState)
|
setState(newState)
|
||||||
|
@ -50,6 +50,9 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
|
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
|
||||||
wrapperRef.current.focus()
|
wrapperRef.current.focus()
|
||||||
}
|
}
|
||||||
|
}, [state])
|
||||||
|
useEffect(() => {
|
||||||
|
window.closeModal = onClickWrapper
|
||||||
const listener = (evt: PopStateEvent) => {
|
const listener = (evt: PopStateEvent) => {
|
||||||
if (!evt.state?.modal) {
|
if (!evt.state?.modal) {
|
||||||
setState(null)
|
setState(null)
|
||||||
|
@ -57,7 +60,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", listener)
|
window.addEventListener("popstate", listener)
|
||||||
return () => window.removeEventListener("popstate", listener)
|
return () => window.removeEventListener("popstate", listener)
|
||||||
}, [state])
|
}, [])
|
||||||
let modal: JSX.Element | null = null
|
let modal: JSX.Element | null = null
|
||||||
if (state) {
|
if (state) {
|
||||||
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
|
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
|
||||||
|
@ -68,15 +71,19 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
modal = <div
|
if (state.captureInput !== false) {
|
||||||
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
|
modal = <div
|
||||||
onClick={onClickWrapper}
|
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
|
||||||
onKeyDown={onKeyWrapper}
|
onClick={onClickWrapper}
|
||||||
tabIndex={-1}
|
onKeyDown={onKeyWrapper}
|
||||||
ref={wrapperRef}
|
tabIndex={-1}
|
||||||
>
|
ref={wrapperRef}
|
||||||
{content}
|
>
|
||||||
</div>
|
{content}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
modal = content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return <ModalContext value={openModal}>
|
return <ModalContext value={openModal}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -32,6 +32,7 @@ export interface ModalState {
|
||||||
boxClass?: string
|
boxClass?: string
|
||||||
innerBoxClass?: string
|
innerBoxClass?: string
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
captureInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type openModal = (state: ModalState) => void
|
type openModal = (state: ModalState) => void
|
||||||
|
|
|
@ -40,8 +40,8 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
room_id: roomID,
|
room_id: roomID,
|
||||||
dm_user_id: roomData.meta.current.lazy_load_summary?.heroes?.length === 1
|
dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||||
? roomData.meta.current.lazy_load_summary.heroes[0] : undefined,
|
? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined,
|
||||||
name: roomData.meta.current.name ?? "Unnamed room",
|
name: roomData.meta.current.name ?? "Unnamed room",
|
||||||
avatar: roomData.meta.current.avatar,
|
avatar: roomData.meta.current.avatar,
|
||||||
search_name: "",
|
search_name: "",
|
||||||
|
|
52
web/src/ui/roomlist/FakeSpace.tsx
Normal file
52
web/src/ui/roomlist/FakeSpace.tsx
Normal 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
|
|
@ -5,14 +5,53 @@ div.room-list-wrapper {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
scrollbar-color: var(--room-list-scrollbar-color);
|
scrollbar-color: var(--room-list-scrollbar-color);
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template:
|
||||||
|
"spacebar search" 3.5rem
|
||||||
|
"spacebar roomlist" 1fr
|
||||||
|
/ 3rem 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.room-list {
|
div.room-list {
|
||||||
background-color: var(--room-list-background-overlay);
|
background-color: var(--room-list-background-overlay);
|
||||||
overflow-y: auto;
|
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 {
|
div.room-search-wrapper {
|
||||||
|
@ -21,6 +60,7 @@ div.room-search-wrapper {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
background-color: var(--room-list-search-background-overlay);
|
background-color: var(--room-list-search-background-overlay);
|
||||||
|
grid-area: search;
|
||||||
|
|
||||||
> input {
|
> input {
|
||||||
padding: 0 0 0 1rem;
|
padding: 0 0 0 1rem;
|
||||||
|
|
|
@ -13,7 +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 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 type { RoomID } from "@/api/types"
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import reverseMap from "@/util/reversemap.ts"
|
import reverseMap from "@/util/reversemap.ts"
|
||||||
|
@ -22,6 +23,8 @@ import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
import { keyToString } from "../keybindings.ts"
|
import { keyToString } from "../keybindings.ts"
|
||||||
import Entry from "./Entry.tsx"
|
import Entry from "./Entry.tsx"
|
||||||
|
import FakeSpace from "./FakeSpace.tsx"
|
||||||
|
import Space from "./Space.tsx"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
import SearchIcon from "@/icons/search.svg?react"
|
import SearchIcon from "@/icons/search.svg?react"
|
||||||
import "./RoomList.css"
|
import "./RoomList.css"
|
||||||
|
@ -34,20 +37,27 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const mainScreen = use(MainScreenContext)
|
const mainScreen = use(MainScreenContext)
|
||||||
const roomList = useEventAsState(client.store.roomList)
|
const roomList = useEventAsState(client.store.roomList)
|
||||||
const roomFilterRef = useRef<HTMLInputElement>(null)
|
const spaces = useEventAsState(client.store.topLevelSpaces)
|
||||||
const [roomFilter, setRoomFilter] = useState("")
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [realRoomFilter, setRealRoomFilter] = useState("")
|
const [query, directSetQuery] = useState("")
|
||||||
|
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
|
||||||
|
|
||||||
const updateRoomFilter = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRoomFilter(evt.target.value)
|
client.store.currentRoomListQuery = toSearchableString(evt.target.value)
|
||||||
client.store.currentRoomListFilter = toSearchableString(evt.target.value)
|
directSetQuery(evt.target.value)
|
||||||
setRealRoomFilter(client.store.currentRoomListFilter)
|
|
||||||
}
|
}
|
||||||
|
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 = () => {
|
const clearQuery = () => {
|
||||||
setRoomFilter("")
|
client.store.currentRoomListQuery = ""
|
||||||
client.store.currentRoomListFilter = ""
|
directSetQuery("")
|
||||||
setRealRoomFilter("")
|
searchInputRef.current?.focus()
|
||||||
roomFilterRef.current?.focus()
|
|
||||||
}
|
}
|
||||||
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
const key = keyToString(evt)
|
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">
|
return <div className="room-list-wrapper">
|
||||||
<div className="room-search-wrapper">
|
<div className="room-search-wrapper">
|
||||||
<input
|
<input
|
||||||
value={roomFilter}
|
value={query}
|
||||||
onChange={updateRoomFilter}
|
onChange={setQuery}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
className="room-search"
|
className="room-search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search rooms"
|
placeholder="Search rooms"
|
||||||
ref={roomFilterRef}
|
ref={searchInputRef}
|
||||||
id="room-search"
|
id="room-search"
|
||||||
/>
|
/>
|
||||||
<button onClick={clearQuery} disabled={roomFilter === ""}>
|
<button onClick={clearQuery} disabled={query === ""}>
|
||||||
{roomFilter !== "" ? <CloseIcon/> : <SearchIcon/>}
|
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="room-list">
|
||||||
{reverseMap(roomList, room =>
|
{reverseMap(roomList, room =>
|
||||||
<Entry
|
<Entry
|
||||||
key={room.room_id}
|
key={room.room_id}
|
||||||
isActive={room.room_id === activeRoomID}
|
isActive={room.room_id === activeRoomID}
|
||||||
hidden={roomFilter ? !room.search_name.includes(realRoomFilter) : false}
|
hidden={roomListFilter ? !roomListFilter(room) : false}
|
||||||
room={room}
|
room={room}
|
||||||
/>,
|
/>,
|
||||||
)}
|
)}
|
||||||
|
|
40
web/src/ui/roomlist/Space.tsx
Normal file
40
web/src/ui/roomlist/Space.tsx
Normal 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
|
|
@ -27,7 +27,7 @@ import ReadReceipts from "./ReadReceipts.tsx"
|
||||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
import { ReplyIDBody } from "./ReplyBody.tsx"
|
||||||
import URLPreviews from "./URLPreviews.tsx"
|
import URLPreviews from "./URLPreviews.tsx"
|
||||||
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
|
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 ErrorIcon from "@/icons/error.svg?react"
|
||||||
import PendingIcon from "@/icons/pending.svg?react"
|
import PendingIcon from "@/icons/pending.svg?react"
|
||||||
import SentIcon from "@/icons/sent.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 memberEvt = useRoomMember(client, 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)
|
||||||
|
@ -175,11 +200,12 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
|
||||||
data-event-id={evt.event_id}
|
data-event-id={evt.event_id}
|
||||||
className={wrapperClassNames.join(" ")}
|
className={wrapperClassNames.join(" ")}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
|
||||||
>
|
>
|
||||||
{!disableMenu && !isMobileDevice && <div
|
{!disableMenu && !isMobileDevice && <div
|
||||||
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
||||||
>
|
>
|
||||||
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
|
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
|
||||||
</div>}
|
</div>}
|
||||||
{replyAboveMessage}
|
{replyAboveMessage}
|
||||||
{renderAvatar && <div
|
{renderAvatar && <div
|
||||||
|
|
|
@ -16,17 +16,18 @@
|
||||||
import { CSSProperties, use } from "react"
|
import { CSSProperties, use } from "react"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import ClientContext from "../../ClientContext.ts"
|
import ClientContext from "../../ClientContext.ts"
|
||||||
import { RoomContextData, useRoomContext } from "../../roomview/roomcontext.ts"
|
import { RoomContextData } from "../../roomview/roomcontext.ts"
|
||||||
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
||||||
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
||||||
|
|
||||||
interface EventHoverMenuProps {
|
interface EventHoverMenuProps {
|
||||||
evt: MemDBEvent
|
evt: MemDBEvent
|
||||||
|
roomCtx: RoomContextData
|
||||||
setForceOpen: (forceOpen: boolean) => void
|
setForceOpen: (forceOpen: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
|
export const EventHoverMenu = ({ evt, roomCtx, setForceOpen }: EventHoverMenuProps) => {
|
||||||
const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen)
|
const elements = usePrimaryItems(use(ClientContext)!, roomCtx, evt, true, false, undefined, setForceOpen)
|
||||||
return <div className="event-hover-menu">{elements}</div>
|
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) => {
|
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||||
const client = use(ClientContext)!
|
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)
|
const secondary = useSecondaryItems(client, roomCtx, evt)
|
||||||
return <div style={style} className="event-context-menu full">
|
return <div style={style} className="event-context-menu full">
|
||||||
{primary}
|
{primary}
|
||||||
|
@ -51,3 +52,13 @@ export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =>
|
||||||
{secondary}
|
{secondary}
|
||||||
</div>
|
</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>
|
||||||
|
}
|
||||||
|
|
|
@ -2,13 +2,9 @@ div.event-hover-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: .5rem;
|
right: .5rem;
|
||||||
top: -1.5rem;
|
top: -1.5rem;
|
||||||
background-color: var(--background-color);
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
display: flex;
|
|
||||||
gap: .25rem;
|
|
||||||
padding: .125rem;
|
padding: .125rem;
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
width: 2rem;
|
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 {
|
div.event-context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
|
|
@ -13,5 +13,5 @@
|
||||||
//
|
//
|
||||||
// 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/>.
|
||||||
export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
||||||
export { getModalStyleFromMouse } from "./util.ts"
|
export { getModalStyleFromMouse } from "./util.ts"
|
||||||
|
|
|
@ -37,9 +37,11 @@ export const usePrimaryItems = (
|
||||||
roomCtx: RoomContextData,
|
roomCtx: RoomContextData,
|
||||||
evt: MemDBEvent,
|
evt: MemDBEvent,
|
||||||
isHover: boolean,
|
isHover: boolean,
|
||||||
|
isFixed: boolean,
|
||||||
style?: CSSProperties,
|
style?: CSSProperties,
|
||||||
setForceOpen?: (forceOpen: boolean) => void,
|
setForceOpen?: (forceOpen: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
|
const names = !isHover && !isFixed
|
||||||
const closeModal = !isHover ? use(ModalCloseContext) : noop
|
const closeModal = !isHover ? use(ModalCloseContext) : noop
|
||||||
const openModal = use(ModalContext)
|
const openModal = use(ModalContext)
|
||||||
|
|
||||||
|
@ -108,11 +110,11 @@ export const usePrimaryItems = (
|
||||||
return <>
|
return <>
|
||||||
{didFail && <button onClick={onClickResend} title="Resend message">
|
{didFail && <button onClick={onClickResend} title="Resend message">
|
||||||
<RefreshIcon/>
|
<RefreshIcon/>
|
||||||
{!isHover && "Resend"}
|
{names && "Resend"}
|
||||||
</button>}
|
</button>}
|
||||||
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}>
|
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}>
|
||||||
<ReactIcon/>
|
<ReactIcon/>
|
||||||
{!isHover && "React"}
|
{names && "React"}
|
||||||
</button>}
|
</button>}
|
||||||
{canSend && <button
|
{canSend && <button
|
||||||
disabled={isEditing || isPending}
|
disabled={isEditing || isPending}
|
||||||
|
@ -120,11 +122,11 @@ export const usePrimaryItems = (
|
||||||
onClick={onClickReply}
|
onClick={onClickReply}
|
||||||
>
|
>
|
||||||
<ReplyIcon/>
|
<ReplyIcon/>
|
||||||
{!isHover && "Reply"}
|
{names && "Reply"}
|
||||||
</button>}
|
</button>}
|
||||||
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}>
|
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}>
|
||||||
<EditIcon/>
|
<EditIcon/>
|
||||||
{!isHover && "Edit"}
|
{names && "Edit"}
|
||||||
</button>}
|
</button>}
|
||||||
{isHover && <button onClick={onClickMore}><MoreIcon/></button>}
|
{isHover && <button onClick={onClickMore}><MoreIcon/></button>}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -32,6 +32,7 @@ export const useSecondaryItems = (
|
||||||
client: Client,
|
client: Client,
|
||||||
roomCtx: RoomContextData,
|
roomCtx: RoomContextData,
|
||||||
evt: MemDBEvent,
|
evt: MemDBEvent,
|
||||||
|
names = true,
|
||||||
) => {
|
) => {
|
||||||
const closeModal = use(ModalCloseContext)
|
const closeModal = use(ModalCloseContext)
|
||||||
const openModal = use(ModalContext)
|
const openModal = use(ModalContext)
|
||||||
|
@ -102,20 +103,22 @@ export const useSecondaryItems = (
|
||||||
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
|
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
||||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||||
? <button onClick={onClickPin(false)}>
|
? <button onClick={onClickPin(false)}>
|
||||||
<UnpinIcon/>Unpin message
|
<UnpinIcon/>{names && "Unpin message"}
|
||||||
</button>
|
</button>
|
||||||
: <button onClick={onClickPin(true)} title={pendingTitle} disabled={isPending}>
|
: <button onClick={onClickPin(true)} title={pendingTitle} disabled={isPending}>
|
||||||
<PinIcon/>Pin message
|
<PinIcon/>{names && "Pin message"}
|
||||||
</button>)}
|
</button>)}
|
||||||
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}><ReportIcon/>Report</button>
|
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}>
|
||||||
|
<ReportIcon/>{names && "Report"}
|
||||||
|
</button>
|
||||||
{canRedact && <button
|
{canRedact && <button
|
||||||
onClick={onClickRedact}
|
onClick={onClickRedact}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
title={pendingTitle}
|
title={pendingTitle}
|
||||||
className="redact-button"
|
className="redact-button"
|
||||||
><DeleteIcon/>Remove</button>}
|
><DeleteIcon/>{names && "Remove"}</button>}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,12 +15,15 @@
|
||||||
// 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 { useSyncExternalStore } from "react"
|
import { useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
const noop = () => {}
|
||||||
|
const noopListen = () => noop
|
||||||
|
|
||||||
export function useEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T
|
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(
|
return useSyncExternalStore(
|
||||||
dispatcher.listenChange,
|
dispatcher ? dispatcher.listenChange : noopListen,
|
||||||
() => dispatcher.current,
|
() => dispatcher ? dispatcher.current : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
|
@ -14,5 +14,7 @@ declare global {
|
||||||
mainScreenContext: MainScreenContextFields
|
mainScreenContext: MainScreenContextFields
|
||||||
openLightbox: (params: { src: string, alt: string }) => void
|
openLightbox: (params: { src: string, alt: string }) => void
|
||||||
gcSettings: GCSettings
|
gcSettings: GCSettings
|
||||||
|
hackyOpenEventContextMenu?: string
|
||||||
|
closeModal: () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue