mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13: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
|
||||
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
|
||||
<circle cx="500" cy="500" r="500" fill="%s"/>
|
||||
<rect x="0" y="0" width="1000" height="1000" fill="%s"/>
|
||||
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
>%s</text>
|
||||
|
|
|
@ -17,16 +17,17 @@ import (
|
|||
type Database struct {
|
||||
*dbutil.Database
|
||||
|
||||
Account AccountQuery
|
||||
AccountData AccountDataQuery
|
||||
Room RoomQuery
|
||||
InvitedRoom InvitedRoomQuery
|
||||
Event EventQuery
|
||||
CurrentState CurrentStateQuery
|
||||
Timeline TimelineQuery
|
||||
SessionRequest SessionRequestQuery
|
||||
Receipt ReceiptQuery
|
||||
Media MediaQuery
|
||||
Account *AccountQuery
|
||||
AccountData *AccountDataQuery
|
||||
Room *RoomQuery
|
||||
InvitedRoom *InvitedRoomQuery
|
||||
Event *EventQuery
|
||||
CurrentState *CurrentStateQuery
|
||||
Timeline *TimelineQuery
|
||||
SessionRequest *SessionRequestQuery
|
||||
Receipt *ReceiptQuery
|
||||
Media *MediaQuery
|
||||
SpaceEdge *SpaceEdgeQuery
|
||||
}
|
||||
|
||||
func New(rawDB *dbutil.Database) *Database {
|
||||
|
@ -35,16 +36,17 @@ func New(rawDB *dbutil.Database) *Database {
|
|||
return &Database{
|
||||
Database: rawDB,
|
||||
|
||||
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||
InvitedRoom: InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
||||
Event: EventQuery{QueryHelper: eventQH},
|
||||
CurrentState: CurrentStateQuery{QueryHelper: eventQH},
|
||||
Timeline: TimelineQuery{QueryHelper: eventQH},
|
||||
SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||
Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||
Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
||||
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
||||
Event: &EventQuery{QueryHelper: eventQH},
|
||||
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
|
||||
Timeline: &TimelineQuery{QueryHelper: eventQH},
|
||||
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
||||
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,3 +81,7 @@ func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData {
|
|||
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
|
||||
return &Account{}
|
||||
}
|
||||
|
||||
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
|
||||
return &SpaceEdge{}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ const (
|
|||
FROM room
|
||||
`
|
||||
getRoomsBySortingTimestampQuery = getRoomBaseQuery + `WHERE sorting_timestamp < $1 AND sorting_timestamp > 0 ORDER BY sorting_timestamp DESC LIMIT $2`
|
||||
getRoomsByTypeQuery = getRoomBaseQuery + `WHERE room_type = $1`
|
||||
getRoomByIDQuery = getRoomBaseQuery + `WHERE room_id = $1`
|
||||
ensureRoomExistsQuery = `
|
||||
INSERT INTO room (room_id) VALUES ($1)
|
||||
|
@ -34,7 +35,8 @@ const (
|
|||
`
|
||||
upsertRoomFromSyncQuery = `
|
||||
UPDATE room
|
||||
SET creation_content = COALESCE(room.creation_content, $2),
|
||||
SET room_type = COALESCE(room.room_type, json($2)->>'$.type'),
|
||||
creation_content = COALESCE(room.creation_content, $2),
|
||||
tombstone_content = COALESCE(room.tombstone_content, $3),
|
||||
name = COALESCE($4, room.name),
|
||||
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
|
||||
|
@ -95,6 +97,10 @@ func (rq *RoomQuery) GetBySortTS(ctx context.Context, maxTS time.Time, limit int
|
|||
return rq.QueryMany(ctx, getRoomsBySortingTimestampQuery, maxTS.UnixMilli(), limit)
|
||||
}
|
||||
|
||||
func (rq *RoomQuery) GetAllSpaces(ctx context.Context) ([]*Room, error) {
|
||||
return rq.QueryMany(ctx, getRoomsByTypeQuery, event.RoomTypeSpace)
|
||||
}
|
||||
|
||||
func (rq *RoomQuery) Upsert(ctx context.Context, room *Room) error {
|
||||
return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...)
|
||||
}
|
||||
|
|
236
pkg/hicli/database/space.go
Normal file
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 (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
|
@ -10,6 +10,7 @@ CREATE TABLE account (
|
|||
|
||||
CREATE TABLE room (
|
||||
room_id TEXT NOT NULL PRIMARY KEY,
|
||||
room_type TEXT,
|
||||
creation_content TEXT,
|
||||
tombstone_content TEXT,
|
||||
|
||||
|
@ -35,7 +36,7 @@ CREATE TABLE room (
|
|||
|
||||
CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
CREATE INDEX room_type_idx ON room (creation_content ->> 'type');
|
||||
CREATE INDEX room_type_idx ON room (room_type);
|
||||
CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC);
|
||||
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
|
||||
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0);
|
||||
|
@ -278,3 +279,24 @@ CREATE TABLE receipt (
|
|||
CONSTRAINT receipt_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE
|
||||
-- note: there's no foreign key on event ID because receipts could point at events that are too far in history.
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE space_edge (
|
||||
space_id TEXT NOT NULL,
|
||||
child_id TEXT NOT NULL,
|
||||
|
||||
-- m.space.child fields
|
||||
child_event_rowid INTEGER,
|
||||
"order" TEXT NOT NULL DEFAULT '',
|
||||
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
|
||||
-- m.space.parent fields
|
||||
parent_event_rowid INTEGER,
|
||||
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
|
||||
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
|
||||
|
||||
PRIMARY KEY (space_id, child_id),
|
||||
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
|
||||
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
|
||||
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
|
||||
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
|
||||
) STRICT;
|
||||
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
|
||||
|
|
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 {
|
||||
Since *string `json:"since,omitempty"`
|
||||
ClearState bool `json:"clear_state,omitempty"`
|
||||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
|
||||
LeftRooms []id.RoomID `json:"left_rooms"`
|
||||
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
||||
Since *string `json:"since,omitempty"`
|
||||
ClearState bool `json:"clear_state,omitempty"`
|
||||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
|
||||
LeftRooms []id.RoomID `json:"left_rooms"`
|
||||
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
||||
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
||||
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
||||
}
|
||||
|
||||
func (c *SyncComplete) IsEmpty() bool {
|
||||
|
|
|
@ -14,12 +14,9 @@ import (
|
|||
|
||||
func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom {
|
||||
syncRoom := &SyncRoom{
|
||||
Meta: room,
|
||||
Events: make([]*database.Event, 0, 2),
|
||||
Timeline: make([]database.TimelineRowTuple, 0),
|
||||
State: map[event.Type]map[string]database.EventRowID{},
|
||||
Notifications: make([]SyncNotification, 0),
|
||||
Receipts: make(map[id.EventID][]*database.Receipt),
|
||||
Meta: room,
|
||||
Events: make([]*database.Event, 0, 2),
|
||||
State: map[event.Type]map[string]database.EventRowID{},
|
||||
}
|
||||
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
|
||||
if err != nil {
|
||||
|
@ -27,7 +24,6 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
|
|||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
syncRoom.AccountData = make(map[event.Type]*database.AccountData)
|
||||
} else {
|
||||
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
|
||||
for _, data := range ad {
|
||||
|
@ -70,6 +66,49 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
|
|||
func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*SyncComplete] {
|
||||
return func(yield func(*SyncComplete) bool) {
|
||||
maxTS := time.Now().Add(1 * time.Hour)
|
||||
{
|
||||
spaces, err := h.DB.Room.GetAllSpaces(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get initial spaces to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(spaces)),
|
||||
}
|
||||
for _, room := range spaces {
|
||||
payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
payload.TopLevelSpaces, err = h.DB.SpaceEdge.GetTopLevelIDs(ctx, h.Account.UserID)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get top-level space IDs to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "")
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.ClearState = true
|
||||
if !yield(&payload) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize)
|
||||
if err != nil {
|
||||
|
@ -79,22 +118,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
return
|
||||
}
|
||||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)-1),
|
||||
LeftRooms: make([]id.RoomID, 0),
|
||||
AccountData: make(map[event.Type]*database.AccountData),
|
||||
}
|
||||
if i == 0 {
|
||||
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.ClearState = true
|
||||
}
|
||||
if payload.InvitedRooms == nil {
|
||||
payload.InvitedRooms = make([]*database.InvitedRoom, 0)
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
|
||||
}
|
||||
for _, room := range rooms {
|
||||
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
||||
|
@ -106,7 +130,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
return
|
||||
}
|
||||
}
|
||||
if !yield(&payload) || len(rooms) < batchSize {
|
||||
if !yield(&payload) {
|
||||
return
|
||||
} else if len(rooms) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -117,10 +143,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
return
|
||||
}
|
||||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom),
|
||||
InvitedRooms: make([]*database.InvitedRoom, 0),
|
||||
LeftRooms: make([]id.RoomID, 0),
|
||||
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
||||
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
||||
}
|
||||
for _, data := range ad {
|
||||
payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data
|
||||
|
|
|
@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
|||
if err != nil {
|
||||
return fmt.Errorf("failed to save events: %w", err)
|
||||
}
|
||||
sdc := &spaceDataCollector{}
|
||||
for i := range currentStateEntries {
|
||||
currentStateEntries[i].EventRowID = dbEvts[i].RowID
|
||||
if mediaReferenceEntries[i] != nil {
|
||||
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
|
||||
}
|
||||
if evts[i].Type != event.StateMember {
|
||||
processImportantEvent(ctx, evts[i], room, updatedRoom)
|
||||
processImportantEvent(ctx, evts[i], room, updatedRoom, dbEvts[i].RowID, sdc)
|
||||
}
|
||||
}
|
||||
err = h.DB.Media.AddMany(ctx, mediaCacheEntries)
|
||||
|
@ -146,6 +147,11 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
|||
return fmt.Errorf("failed to save current state entries: %w", err)
|
||||
}
|
||||
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
|
||||
// TODO dispatch space edge changes if something changed? (fairly unlikely though)
|
||||
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if roomChanged {
|
||||
err = h.DB.Room.Upsert(ctx, updatedRoom)
|
||||
if err != nil {
|
||||
|
@ -155,19 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
|||
h.EventHandler(&SyncComplete{
|
||||
Rooms: map[id.RoomID]*SyncRoom{
|
||||
roomID: {
|
||||
Meta: room,
|
||||
Timeline: make([]database.TimelineRowTuple, 0),
|
||||
State: make(map[event.Type]map[string]database.EventRowID),
|
||||
AccountData: make(map[event.Type]*database.AccountData),
|
||||
Events: make([]*database.Event, 0),
|
||||
Reset: false,
|
||||
Notifications: make([]SyncNotification, 0),
|
||||
Receipts: make(map[id.EventID][]*database.Receipt),
|
||||
Meta: room,
|
||||
},
|
||||
},
|
||||
InvitedRooms: make([]*database.InvitedRoom, 0),
|
||||
AccountData: make(map[event.Type]*database.AccountData),
|
||||
LeftRooms: make([]id.RoomID, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -667,6 +667,7 @@ func (h *HiClient) processStateAndTimeline(
|
|||
updatedRoom.LazyLoadSummary = summary
|
||||
heroesChanged = true
|
||||
}
|
||||
sdc := &spaceDataCollector{}
|
||||
decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
|
||||
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
|
||||
addedEvents := make(map[database.EventRowID]struct{})
|
||||
|
@ -750,7 +751,7 @@ func (h *HiClient) processStateAndTimeline(
|
|||
if err != nil {
|
||||
return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err)
|
||||
}
|
||||
processImportantEvent(ctx, evt, room, updatedRoom)
|
||||
processImportantEvent(ctx, evt, room, updatedRoom, dbEvt.RowID, sdc)
|
||||
}
|
||||
allNewEvents = append(allNewEvents, dbEvt)
|
||||
addedEvents[dbEvt.RowID] = struct{}{}
|
||||
|
@ -932,6 +933,10 @@ func (h *HiClient) processStateAndTimeline(
|
|||
return fmt.Errorf("failed to save room data: %w", err)
|
||||
}
|
||||
}
|
||||
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero?
|
||||
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
|
||||
for _, receipt := range receipts {
|
||||
|
@ -1033,13 +1038,101 @@ func intPtrEqual(a, b *int) bool {
|
|||
return *a == *b
|
||||
}
|
||||
|
||||
func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomData, updatedRoom *database.Room) (roomDataChanged bool) {
|
||||
type spaceDataCollector struct {
|
||||
Children []database.SpaceChildEntry
|
||||
Parents []database.SpaceParentEntry
|
||||
RemovedChildren []id.RoomID
|
||||
RemovedParents []id.RoomID
|
||||
PowerLevelChanged bool
|
||||
IsFullState bool
|
||||
}
|
||||
|
||||
func (sdc *spaceDataCollector) Collect(evt *event.Event, rowID database.EventRowID) {
|
||||
switch evt.Type {
|
||||
case event.StatePowerLevels:
|
||||
sdc.PowerLevelChanged = true
|
||||
case event.StateCreate:
|
||||
sdc.IsFullState = true
|
||||
case event.StateSpaceChild:
|
||||
content := evt.Content.AsSpaceChild()
|
||||
if len(content.Via) == 0 {
|
||||
sdc.RemovedChildren = append(sdc.RemovedChildren, id.RoomID(*evt.StateKey))
|
||||
} else {
|
||||
sdc.Children = append(sdc.Children, database.SpaceChildEntry{
|
||||
ChildID: id.RoomID(*evt.StateKey),
|
||||
EventRowID: rowID,
|
||||
Order: content.Order,
|
||||
Suggested: content.Suggested,
|
||||
})
|
||||
}
|
||||
case event.StateSpaceParent:
|
||||
content := evt.Content.AsSpaceParent()
|
||||
if len(content.Via) == 0 {
|
||||
sdc.RemovedParents = append(sdc.RemovedParents, id.RoomID(*evt.StateKey))
|
||||
} else {
|
||||
sdc.Parents = append(sdc.Parents, database.SpaceParentEntry{
|
||||
ParentID: id.RoomID(*evt.StateKey),
|
||||
EventRowID: rowID,
|
||||
Canonical: content.Canonical,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sdc *spaceDataCollector) Apply(ctx context.Context, room *database.Room, seq *database.SpaceEdgeQuery) error {
|
||||
if room.CreationContent == nil || room.CreationContent.Type != event.RoomTypeSpace {
|
||||
sdc.Children = nil
|
||||
sdc.RemovedChildren = nil
|
||||
sdc.PowerLevelChanged = false
|
||||
}
|
||||
if len(sdc.Children) == 0 && len(sdc.RemovedChildren) == 0 &&
|
||||
len(sdc.Parents) == 0 && len(sdc.RemovedParents) == 0 &&
|
||||
!sdc.PowerLevelChanged {
|
||||
return nil
|
||||
}
|
||||
return seq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
|
||||
if len(sdc.Children) > 0 || len(sdc.RemovedChildren) > 0 {
|
||||
err := seq.SetChildren(ctx, room.ID, sdc.Children, sdc.RemovedChildren, sdc.IsFullState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set space children: %w", err)
|
||||
}
|
||||
}
|
||||
if len(sdc.Parents) > 0 || len(sdc.RemovedParents) > 0 {
|
||||
err := seq.SetParents(ctx, room.ID, sdc.Parents, sdc.RemovedParents, sdc.IsFullState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set space parents: %w", err)
|
||||
}
|
||||
if len(sdc.Parents) > 0 {
|
||||
err = seq.RevalidateAllParentsOfRoomValidity(ctx, room.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revalidate own parent references: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sdc.PowerLevelChanged {
|
||||
err := seq.RevalidateAllChildrenOfParentValidity(ctx, room.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revalidate child parent references to self: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func processImportantEvent(
|
||||
ctx context.Context,
|
||||
evt *event.Event,
|
||||
existingRoomData, updatedRoom *database.Room,
|
||||
rowID database.EventRowID,
|
||||
sdc *spaceDataCollector,
|
||||
) (roomDataChanged bool) {
|
||||
if evt.StateKey == nil {
|
||||
return
|
||||
}
|
||||
switch evt.Type {
|
||||
case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias,
|
||||
event.StateRoomAvatar, event.StateTopic, event.StateEncryption:
|
||||
event.StateRoomAvatar, event.StateTopic, event.StateEncryption,
|
||||
event.StateSpaceChild, event.StateSpaceParent, event.StatePowerLevels:
|
||||
if *evt.StateKey != "" {
|
||||
return
|
||||
}
|
||||
|
@ -1047,6 +1140,7 @@ func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomDa
|
|||
return
|
||||
}
|
||||
err := evt.Content.ParseRaw(evt.Type)
|
||||
sdc.Collect(evt, rowID)
|
||||
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
|
||||
zerolog.Ctx(ctx).Warn().Err(err).
|
||||
Stringer("event_type", &evt.Type).
|
||||
|
|
|
@ -12,5 +12,16 @@
|
|||
<div id="root"></div>
|
||||
<script type="module" src="src/main.tsx"></script>
|
||||
<audio id="default-notification-sound" preload="auto" src="sounds/bright.flac"></audio>
|
||||
<svg style="position: absolute; width: 0; height: 0;" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
|
||||
<path d="M 0,0.5
|
||||
C 0,0 0,0 0.5,0
|
||||
1,0 1,0 1,0.5
|
||||
1,1 1,1 0.5,1
|
||||
0,1 0,1 0,0.5"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -54,7 +54,7 @@ export const getUserColor = (userID: UserID) => {
|
|||
// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go
|
||||
function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string {
|
||||
return "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
|
||||
<circle cx="500" cy="500" r="500" fill="${backgroundColor}"/>
|
||||
<rect x="0" y="0" width="1000" height="1000" fill="${backgroundColor}"/>
|
||||
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
>${escapeHTMLChar(fallbackCharacter)}</text>
|
||||
|
@ -103,8 +103,8 @@ export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: Conten
|
|||
if ("dm_user_id" in room) {
|
||||
dmUserID = room.dm_user_id
|
||||
} else if ("lazy_load_summary" in room) {
|
||||
dmUserID = room.lazy_load_summary?.heroes?.length === 1
|
||||
? room.lazy_load_summary.heroes[0] : undefined
|
||||
dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||
? room.lazy_load_summary["m.heroes"][0] : undefined
|
||||
}
|
||||
return getAvatarURL(dmUserID ?? room.room_id, {
|
||||
displayname: room.name,
|
||||
|
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from "../types"
|
||||
import { InvitedRoomStore } from "./invitedroom.ts"
|
||||
import { RoomStateStore } from "./room.ts"
|
||||
import { RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace } from "./space.ts"
|
||||
|
||||
export interface RoomListEntry {
|
||||
room_id: RoomID
|
||||
|
@ -72,7 +73,11 @@ export class StateStore {
|
|||
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
|
||||
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
||||
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
|
||||
currentRoomListFilter: string = ""
|
||||
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
|
||||
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
|
||||
readonly spaceOrphans = new SpaceOrphansSpace(this)
|
||||
currentRoomListQuery: string = ""
|
||||
currentRoomListFilter: RoomListFilter | null = null
|
||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||
readonly accountDataSubs = new MultiSubscribable()
|
||||
readonly emojiRoomsSub = new Subscribable()
|
||||
|
@ -89,11 +94,25 @@ export class StateStore {
|
|||
activeRoomIsPreview: boolean = false
|
||||
imageAuthToken?: string
|
||||
|
||||
getFilteredRoomList(): RoomListEntry[] {
|
||||
if (!this.currentRoomListFilter) {
|
||||
return this.roomList.current
|
||||
#roomListFilterFunc = (entry: RoomListEntry) => {
|
||||
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||
return false
|
||||
} else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) {
|
||||
return false
|
||||
}
|
||||
return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter))
|
||||
return true
|
||||
}
|
||||
|
||||
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
|
||||
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
|
||||
return null
|
||||
}
|
||||
return this.#roomListFilterFunc
|
||||
}
|
||||
|
||||
getFilteredRoomList(): RoomListEntry[] {
|
||||
const fn = this.roomListFilterFunc
|
||||
return fn ? this.roomList.current.filter(fn) : this.roomList.current
|
||||
}
|
||||
|
||||
#shouldHideRoom(entry: SyncRoom): boolean {
|
||||
|
@ -122,7 +141,7 @@ export class StateStore {
|
|||
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
|
||||
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
|
||||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
|
||||
entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
|
||||
(entry.events ?? []).findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
|
||||
}
|
||||
|
||||
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
|
||||
|
@ -143,8 +162,8 @@ export class StateStore {
|
|||
const name = entry.meta.name ?? "Unnamed room"
|
||||
return {
|
||||
room_id: entry.meta.room_id,
|
||||
dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1
|
||||
? entry.meta.lazy_load_summary.heroes[0] : undefined,
|
||||
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
|
||||
sorting_timestamp: entry.meta.sorting_timestamp,
|
||||
preview_event,
|
||||
preview_sender,
|
||||
|
@ -165,7 +184,7 @@ export class StateStore {
|
|||
}
|
||||
const resyncRoomList = this.roomList.current.length === 0
|
||||
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
|
||||
for (const data of sync.invited_rooms) {
|
||||
for (const data of sync.invited_rooms ?? []) {
|
||||
const room = new InvitedRoomStore(data, this)
|
||||
this.inviteRooms.set(room.room_id, room)
|
||||
if (!resyncRoomList) {
|
||||
|
@ -176,7 +195,7 @@ export class StateStore {
|
|||
}
|
||||
}
|
||||
const hasInvites = this.inviteRooms.size > 0
|
||||
for (const [roomID, data] of Object.entries(sync.rooms)) {
|
||||
for (const [roomID, data] of Object.entries(sync.rooms ?? {})) {
|
||||
let isNewRoom = false
|
||||
let room = this.rooms.get(roomID)
|
||||
if (!room) {
|
||||
|
@ -203,7 +222,7 @@ export class StateStore {
|
|||
}
|
||||
}
|
||||
|
||||
if (window.Notification?.permission === "granted" && !focused.current) {
|
||||
if (window.Notification?.permission === "granted" && !focused.current && data.notifications) {
|
||||
for (const notification of data.notifications) {
|
||||
this.showNotification(room, notification.event_rowid, notification.sound)
|
||||
}
|
||||
|
@ -212,7 +231,7 @@ export class StateStore {
|
|||
this.switchRoom?.(roomID)
|
||||
}
|
||||
}
|
||||
for (const ad of Object.values(sync.account_data)) {
|
||||
for (const ad of Object.values(sync.account_data ?? {})) {
|
||||
if (ad.type === "io.element.recent_emoji") {
|
||||
this.#frequentlyUsedEmoji = null
|
||||
} else if (ad.type === "fi.mau.gomuks.preferences") {
|
||||
|
@ -222,7 +241,7 @@ export class StateStore {
|
|||
this.accountData.set(ad.type, ad.content)
|
||||
this.accountDataSubs.notify(ad.type)
|
||||
}
|
||||
for (const roomID of sync.left_rooms) {
|
||||
for (const roomID of sync.left_rooms ?? []) {
|
||||
if (this.activeRoomID === roomID) {
|
||||
this.switchRoom?.(null)
|
||||
}
|
||||
|
@ -233,7 +252,7 @@ export class StateStore {
|
|||
let updatedRoomList: RoomListEntry[] | undefined
|
||||
if (resyncRoomList) {
|
||||
updatedRoomList = this.inviteRooms.values().toArray()
|
||||
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms)
|
||||
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {})
|
||||
.map(entry => this.#makeRoomListEntry(entry))
|
||||
.filter(entry => entry !== null))
|
||||
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
|
||||
|
@ -259,6 +278,19 @@ export class StateStore {
|
|||
if (updatedRoomList) {
|
||||
this.roomList.emit(updatedRoomList)
|
||||
}
|
||||
if (sync.space_edges) {
|
||||
// Ensure all space stores exist first
|
||||
for (const spaceID of Object.keys(sync.space_edges)) {
|
||||
this.getSpaceStore(spaceID, true)
|
||||
}
|
||||
for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) {
|
||||
this.getSpaceStore(spaceID, true).children = children
|
||||
}
|
||||
}
|
||||
if (sync.top_level_spaces) {
|
||||
this.topLevelSpaces.emit(sync.top_level_spaces)
|
||||
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
|
||||
}
|
||||
}
|
||||
|
||||
invalidateEmojiPackKeyCache() {
|
||||
|
@ -324,6 +356,20 @@ export class StateStore {
|
|||
return this.#watchedRoomEmojiPacks ?? {}
|
||||
}
|
||||
|
||||
getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore
|
||||
getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null
|
||||
getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null {
|
||||
let store = this.spaceEdges.get(spaceID)
|
||||
if (!store) {
|
||||
if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") {
|
||||
return null
|
||||
}
|
||||
store = new SpaceEdgeStore(spaceID, this)
|
||||
this.spaceEdges.set(spaceID, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
get frequentlyUsedEmoji(): Map<string, number> {
|
||||
if (this.#frequentlyUsedEmoji === null) {
|
||||
const emojiData = this.accountData.get("io.element.recent_emoji")
|
||||
|
@ -433,9 +479,12 @@ export class StateStore {
|
|||
clear() {
|
||||
this.rooms.clear()
|
||||
this.inviteRooms.clear()
|
||||
this.spaceEdges.clear()
|
||||
this.roomList.emit([])
|
||||
this.topLevelSpaces.emit([])
|
||||
this.accountData.clear()
|
||||
this.currentRoomListFilter = ""
|
||||
this.currentRoomListQuery = ""
|
||||
this.currentRoomListFilter = null
|
||||
this.#frequentlyUsedEmoji = null
|
||||
this.#emojiPackKeys = null
|
||||
this.#watchedRoomEmojiPacks = null
|
||||
|
|
|
@ -62,7 +62,7 @@ function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
|
|||
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
|
||||
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
|
||||
ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
|
||||
arraysAreEqual(ll1?.heroes, ll2?.heroes)
|
||||
arraysAreEqual(ll1?.["m.heroes"], ll2?.["m.heroes"])
|
||||
}
|
||||
|
||||
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
||||
|
@ -390,7 +390,7 @@ export class RoomStateStore {
|
|||
} else {
|
||||
this.meta.emit(sync.meta)
|
||||
}
|
||||
for (const ad of Object.values(sync.account_data)) {
|
||||
for (const ad of Object.values(sync.account_data ?? {})) {
|
||||
if (ad.type === "fi.mau.gomuks.preferences") {
|
||||
this.serverPreferenceCache = ad.content
|
||||
this.preferenceSub.notify()
|
||||
|
@ -398,10 +398,10 @@ export class RoomStateStore {
|
|||
this.accountData.set(ad.type, ad.content)
|
||||
this.accountDataSubs.notify(ad.type)
|
||||
}
|
||||
for (const evt of sync.events) {
|
||||
for (const evt of sync.events ?? []) {
|
||||
this.applyEvent(evt)
|
||||
}
|
||||
for (const [evtType, changedEvts] of Object.entries(sync.state)) {
|
||||
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
||||
let stateMap = this.state.get(evtType)
|
||||
if (!stateMap) {
|
||||
stateMap = new Map()
|
||||
|
@ -414,9 +414,9 @@ export class RoomStateStore {
|
|||
this.stateSubs.notify(evtType)
|
||||
}
|
||||
if (sync.reset) {
|
||||
this.timeline = sync.timeline
|
||||
this.timeline = sync.timeline ?? []
|
||||
this.pendingEvents.splice(0, this.pendingEvents.length)
|
||||
} else {
|
||||
} else if (sync.timeline) {
|
||||
this.timeline.push(...sync.timeline)
|
||||
}
|
||||
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
|
||||
|
@ -426,7 +426,7 @@ export class RoomStateStore {
|
|||
this.openNotifications.clear()
|
||||
}
|
||||
this.notifyTimelineSubscribers()
|
||||
for (const [evtID, receipts] of Object.entries(sync.receipts)) {
|
||||
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
||||
this.applyReceipts(receipts, evtID, false)
|
||||
}
|
||||
}
|
||||
|
|
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,
|
||||
DBRoom,
|
||||
DBRoomAccountData,
|
||||
DBSpaceEdge,
|
||||
EventRowID,
|
||||
RawDBEvent,
|
||||
TimelineRowTuple,
|
||||
|
@ -71,13 +72,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand<string> {
|
|||
|
||||
export interface SyncRoom {
|
||||
meta: DBRoom
|
||||
timeline: TimelineRowTuple[]
|
||||
events: RawDBEvent[]
|
||||
state: Record<EventType, Record<string, EventRowID>>
|
||||
timeline: TimelineRowTuple[] | null
|
||||
events: RawDBEvent[] | null
|
||||
state: Record<EventType, Record<string, EventRowID>> | null
|
||||
reset: boolean
|
||||
notifications: SyncNotification[]
|
||||
account_data: Record<EventType, DBRoomAccountData>
|
||||
receipts: Record<EventID, DBReceipt[]>
|
||||
notifications: SyncNotification[] | null
|
||||
account_data: Record<EventType, DBRoomAccountData> | null
|
||||
receipts: Record<EventID, DBReceipt[]> | null
|
||||
}
|
||||
|
||||
export interface SyncNotification {
|
||||
|
@ -86,10 +87,12 @@ export interface SyncNotification {
|
|||
}
|
||||
|
||||
export interface SyncCompleteData {
|
||||
rooms: Record<RoomID, SyncRoom>
|
||||
invited_rooms: DBInvitedRoom[]
|
||||
left_rooms: RoomID[]
|
||||
account_data: Record<EventType, DBAccountData>
|
||||
rooms: Record<RoomID, SyncRoom> | null
|
||||
invited_rooms: DBInvitedRoom[] | null
|
||||
left_rooms: RoomID[] | null
|
||||
account_data: Record<EventType, DBAccountData> | null
|
||||
space_edges: Record<RoomID, DBSpaceEdge[]> | null
|
||||
top_level_spaces: RoomID[] | null
|
||||
since?: string
|
||||
clear_state?: boolean
|
||||
}
|
||||
|
|
|
@ -71,6 +71,18 @@ export interface DBRoom {
|
|||
prev_batch: string
|
||||
}
|
||||
|
||||
export interface DBSpaceEdge {
|
||||
// space_id: RoomID
|
||||
child_id: RoomID
|
||||
|
||||
child_event_rowid?: EventRowID
|
||||
order?: string
|
||||
suggested?: true
|
||||
|
||||
parent_event_rowid?: EventRowID
|
||||
canonical?: true
|
||||
}
|
||||
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type UnknownEventContent = Record<string, any>
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ export interface TombstoneEventContent {
|
|||
}
|
||||
|
||||
export interface LazyLoadSummary {
|
||||
heroes?: UserID[]
|
||||
"m.heroes"?: UserID[]
|
||||
"m.joined_member_count"?: number
|
||||
"m.invited_member_count"?: number
|
||||
}
|
||||
|
|
1
web/src/icons/home.svg
Normal file
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 {
|
||||
--room-list-width: 300px;
|
||||
--room-list-width: 350px;
|
||||
--right-panel-width: 300px;
|
||||
|
||||
position: fixed;
|
||||
|
|
|
@ -294,8 +294,8 @@ const MainScreen = () => {
|
|||
const roomID = evt.state?.room_id ?? null
|
||||
if (roomID !== client.store.activeRoomID) {
|
||||
context.setActiveRoom(roomID, {
|
||||
alias: ensureString(evt?.state.source_alias) || undefined,
|
||||
via: ensureStringArray(evt?.state.source_via),
|
||||
alias: ensureString(evt.state?.source_alias) || undefined,
|
||||
via: ensureStringArray(evt.state?.source_via),
|
||||
}, false)
|
||||
}
|
||||
context.setRightPanel(evt.state?.right_panel ?? null, false)
|
||||
|
@ -318,7 +318,7 @@ const MainScreen = () => {
|
|||
}, [context, client])
|
||||
useEffect(() => context.keybindings.listen(), [context])
|
||||
const [roomListWidth, resizeHandle1] = useResizeHandle(
|
||||
300, 48, Math.min(900, window.innerWidth * 0.4),
|
||||
350, 96, Math.min(900, window.innerWidth * 0.4),
|
||||
"roomListWidth", { className: "room-list-resizer" },
|
||||
)
|
||||
const [rightPanelWidth, resizeHandle2] = useResizeHandle(
|
||||
|
|
|
@ -35,6 +35,7 @@ div.autocompletions {
|
|||
> img {
|
||||
width: 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
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { JSX, useCallback, useLayoutEffect, useReducer, useRef } from "react"
|
||||
import React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
|
||||
import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
|
||||
|
||||
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
|
@ -40,7 +40,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
evt.stopPropagation()
|
||||
}
|
||||
const openModal = useCallback((newState: ModalState) => {
|
||||
if (!history.state?.modal) {
|
||||
if (!history.state?.modal && newState.captureInput !== false) {
|
||||
history.pushState({ ...(history.state ?? {}), modal: true }, "")
|
||||
}
|
||||
setState(newState)
|
||||
|
@ -50,6 +50,9 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
|
||||
wrapperRef.current.focus()
|
||||
}
|
||||
}, [state])
|
||||
useEffect(() => {
|
||||
window.closeModal = onClickWrapper
|
||||
const listener = (evt: PopStateEvent) => {
|
||||
if (!evt.state?.modal) {
|
||||
setState(null)
|
||||
|
@ -57,7 +60,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
}
|
||||
window.addEventListener("popstate", listener)
|
||||
return () => window.removeEventListener("popstate", listener)
|
||||
}, [state])
|
||||
}, [])
|
||||
let modal: JSX.Element | null = null
|
||||
if (state) {
|
||||
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
|
||||
|
@ -68,15 +71,19 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
</div>
|
||||
</div>
|
||||
}
|
||||
modal = <div
|
||||
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
|
||||
onClick={onClickWrapper}
|
||||
onKeyDown={onKeyWrapper}
|
||||
tabIndex={-1}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
if (state.captureInput !== false) {
|
||||
modal = <div
|
||||
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
|
||||
onClick={onClickWrapper}
|
||||
onKeyDown={onKeyWrapper}
|
||||
tabIndex={-1}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
} else {
|
||||
modal = content
|
||||
}
|
||||
}
|
||||
return <ModalContext value={openModal}>
|
||||
{children}
|
||||
|
|
|
@ -32,6 +32,7 @@ export interface ModalState {
|
|||
boxClass?: string
|
||||
innerBoxClass?: string
|
||||
onClose?: () => void
|
||||
captureInput?: boolean
|
||||
}
|
||||
|
||||
type openModal = (state: ModalState) => void
|
||||
|
|
|
@ -40,8 +40,8 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
|
|||
}
|
||||
return {
|
||||
room_id: roomID,
|
||||
dm_user_id: roomData.meta.current.lazy_load_summary?.heroes?.length === 1
|
||||
? roomData.meta.current.lazy_load_summary.heroes[0] : undefined,
|
||||
dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||
? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined,
|
||||
name: roomData.meta.current.name ?? "Unnamed room",
|
||||
avatar: roomData.meta.current.avatar,
|
||||
search_name: "",
|
||||
|
|
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;
|
||||
overflow: hidden;
|
||||
scrollbar-color: var(--room-list-scrollbar-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template:
|
||||
"spacebar search" 3.5rem
|
||||
"spacebar roomlist" 1fr
|
||||
/ 3rem 1fr;
|
||||
}
|
||||
|
||||
div.room-list {
|
||||
background-color: var(--room-list-background-overlay);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
grid-area: roomlist;
|
||||
}
|
||||
|
||||
div.space-bar {
|
||||
background-color: var(--space-list-background-overlay);
|
||||
grid-area: spacebar;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
> div.space-entry {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: .25rem;
|
||||
margin: .25rem;
|
||||
border-radius: .25rem;
|
||||
cursor: var(--clickable-cursor);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--room-list-entry-hover-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--room-list-entry-selected-color);
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> img.avatar {
|
||||
border-radius: 0;
|
||||
clip-path: url(#squircle);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.room-search-wrapper {
|
||||
|
@ -21,6 +60,7 @@ div.room-search-wrapper {
|
|||
align-items: center;
|
||||
height: 3.5rem;
|
||||
background-color: var(--room-list-search-background-overlay);
|
||||
grid-area: search;
|
||||
|
||||
> input {
|
||||
padding: 0 0 0 1rem;
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { use, useRef, useState } from "react"
|
||||
import React, { use, useCallback, useRef, useState } from "react"
|
||||
import { DirectChatSpace, RoomListFilter, UnreadsSpace } from "@/api/statestore/space.ts"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import reverseMap from "@/util/reversemap.ts"
|
||||
|
@ -22,6 +23,8 @@ import ClientContext from "../ClientContext.ts"
|
|||
import MainScreenContext from "../MainScreenContext.ts"
|
||||
import { keyToString } from "../keybindings.ts"
|
||||
import Entry from "./Entry.tsx"
|
||||
import FakeSpace from "./FakeSpace.tsx"
|
||||
import Space from "./Space.tsx"
|
||||
import CloseIcon from "@/icons/close.svg?react"
|
||||
import SearchIcon from "@/icons/search.svg?react"
|
||||
import "./RoomList.css"
|
||||
|
@ -34,20 +37,27 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
|||
const client = use(ClientContext)!
|
||||
const mainScreen = use(MainScreenContext)
|
||||
const roomList = useEventAsState(client.store.roomList)
|
||||
const roomFilterRef = useRef<HTMLInputElement>(null)
|
||||
const [roomFilter, setRoomFilter] = useState("")
|
||||
const [realRoomFilter, setRealRoomFilter] = useState("")
|
||||
const spaces = useEventAsState(client.store.topLevelSpaces)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [query, directSetQuery] = useState("")
|
||||
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
|
||||
|
||||
const updateRoomFilter = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRoomFilter(evt.target.value)
|
||||
client.store.currentRoomListFilter = toSearchableString(evt.target.value)
|
||||
setRealRoomFilter(client.store.currentRoomListFilter)
|
||||
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
client.store.currentRoomListQuery = toSearchableString(evt.target.value)
|
||||
directSetQuery(evt.target.value)
|
||||
}
|
||||
const setSpace = useCallback((space: RoomListFilter | null) => {
|
||||
directSetSpace(space)
|
||||
client.store.currentRoomListFilter = space
|
||||
}, [client])
|
||||
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
|
||||
setSpace(store)
|
||||
}, [setSpace, client])
|
||||
const clearQuery = () => {
|
||||
setRoomFilter("")
|
||||
client.store.currentRoomListFilter = ""
|
||||
setRealRoomFilter("")
|
||||
roomFilterRef.current?.focus()
|
||||
client.store.currentRoomListQuery = ""
|
||||
directSetQuery("")
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const key = keyToString(evt)
|
||||
|
@ -64,28 +74,49 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
const roomListFilter = client.store.roomListFilterFunc
|
||||
const pseudoSpaces = [
|
||||
null,
|
||||
DirectChatSpace,
|
||||
UnreadsSpace,
|
||||
client.store.spaceOrphans,
|
||||
]
|
||||
return <div className="room-list-wrapper">
|
||||
<div className="room-search-wrapper">
|
||||
<input
|
||||
value={roomFilter}
|
||||
onChange={updateRoomFilter}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onKeyDown}
|
||||
className="room-search"
|
||||
type="text"
|
||||
placeholder="Search rooms"
|
||||
ref={roomFilterRef}
|
||||
ref={searchInputRef}
|
||||
id="room-search"
|
||||
/>
|
||||
<button onClick={clearQuery} disabled={roomFilter === ""}>
|
||||
{roomFilter !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
<button onClick={clearQuery} disabled={query === ""}>
|
||||
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-bar">
|
||||
{pseudoSpaces.map(pseudoSpace => <FakeSpace
|
||||
key={pseudoSpace?.id ?? "null"}
|
||||
space={pseudoSpace}
|
||||
setSpace={setSpace}
|
||||
isActive={space?.id === pseudoSpace?.id}
|
||||
/>)}
|
||||
{spaces.map(roomID => <Space
|
||||
roomID={roomID}
|
||||
client={client}
|
||||
onClick={onClickSpace}
|
||||
isActive={space?.id === roomID}
|
||||
/>)}
|
||||
</div>
|
||||
<div className="room-list">
|
||||
{reverseMap(roomList, room =>
|
||||
<Entry
|
||||
key={room.room_id}
|
||||
isActive={room.room_id === activeRoomID}
|
||||
hidden={roomFilter ? !room.search_name.includes(realRoomFilter) : false}
|
||||
hidden={roomListFilter ? !roomListFilter(room) : false}
|
||||
room={room}
|
||||
/>,
|
||||
)}
|
||||
|
|
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 URLPreviews from "./URLPreviews.tsx"
|
||||
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
|
||||
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
|
||||
import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
|
||||
import ErrorIcon from "@/icons/error.svg?react"
|
||||
import PendingIcon from "@/icons/pending.svg?react"
|
||||
import SentIcon from "@/icons/sent.svg?react"
|
||||
|
@ -98,6 +98,31 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
|
|||
/>,
|
||||
})
|
||||
}
|
||||
const onClick = (mouseEvt: React.MouseEvent) => {
|
||||
const targetElem = mouseEvt.target as HTMLElement
|
||||
if (
|
||||
targetElem.tagName === "A"
|
||||
|| targetElem.tagName === "IMG"
|
||||
) {
|
||||
return
|
||||
}
|
||||
mouseEvt.preventDefault()
|
||||
if (window.hackyOpenEventContextMenu === evt.event_id) {
|
||||
window.closeModal()
|
||||
window.hackyOpenEventContextMenu = undefined
|
||||
} else {
|
||||
openModal({
|
||||
content: <EventFixedMenu evt={evt} roomCtx={roomCtx} />,
|
||||
captureInput: false,
|
||||
onClose: () => {
|
||||
if (window.hackyOpenEventContextMenu === evt.event_id) {
|
||||
window.hackyOpenEventContextMenu = undefined
|
||||
}
|
||||
},
|
||||
})
|
||||
window.hackyOpenEventContextMenu = evt.event_id
|
||||
}
|
||||
}
|
||||
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
|
||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||
const BodyType = getBodyType(evt)
|
||||
|
@ -175,11 +200,12 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
|
|||
data-event-id={evt.event_id}
|
||||
className={wrapperClassNames.join(" ")}
|
||||
onContextMenu={onContextMenu}
|
||||
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
|
||||
>
|
||||
{!disableMenu && !isMobileDevice && <div
|
||||
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
||||
>
|
||||
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
|
||||
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
|
||||
</div>}
|
||||
{replyAboveMessage}
|
||||
{renderAvatar && <div
|
||||
|
|
|
@ -16,17 +16,18 @@
|
|||
import { CSSProperties, use } from "react"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import ClientContext from "../../ClientContext.ts"
|
||||
import { RoomContextData, useRoomContext } from "../../roomview/roomcontext.ts"
|
||||
import { RoomContextData } from "../../roomview/roomcontext.ts"
|
||||
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
||||
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
||||
|
||||
interface EventHoverMenuProps {
|
||||
evt: MemDBEvent
|
||||
roomCtx: RoomContextData
|
||||
setForceOpen: (forceOpen: boolean) => void
|
||||
}
|
||||
|
||||
export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
|
||||
const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen)
|
||||
export const EventHoverMenu = ({ evt, roomCtx, setForceOpen }: EventHoverMenuProps) => {
|
||||
const elements = usePrimaryItems(use(ClientContext)!, roomCtx, evt, true, false, undefined, setForceOpen)
|
||||
return <div className="event-hover-menu">{elements}</div>
|
||||
}
|
||||
|
||||
|
@ -43,7 +44,7 @@ export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =
|
|||
|
||||
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||
const client = use(ClientContext)!
|
||||
const primary = usePrimaryItems(client, roomCtx, evt, false, style, undefined)
|
||||
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
|
||||
const secondary = useSecondaryItems(client, roomCtx, evt)
|
||||
return <div style={style} className="event-context-menu full">
|
||||
{primary}
|
||||
|
@ -51,3 +52,13 @@ export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =>
|
|||
{secondary}
|
||||
</div>
|
||||
}
|
||||
|
||||
export const EventFixedMenu = ({ evt, roomCtx }: Omit<EventContextMenuProps, "style">) => {
|
||||
const client = use(ClientContext)!
|
||||
const primary = usePrimaryItems(client, roomCtx, evt, false, true, undefined, undefined)
|
||||
const secondary = useSecondaryItems(client, roomCtx, evt, false)
|
||||
return <div className="event-fixed-menu">
|
||||
{primary}
|
||||
{secondary}
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -2,13 +2,9 @@ div.event-hover-menu {
|
|||
position: absolute;
|
||||
right: .5rem;
|
||||
top: -1.5rem;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: .5rem;
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .125rem;
|
||||
z-index: 1;
|
||||
|
||||
> button {
|
||||
width: 2rem;
|
||||
|
@ -16,6 +12,36 @@ div.event-hover-menu {
|
|||
}
|
||||
}
|
||||
|
||||
div.event-hover-menu, div.event-fixed-menu {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
background-color: var(--background-color);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
div.event-fixed-menu {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: 3.5rem;
|
||||
padding: .25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
justify-content: right;
|
||||
flex-direction: row-reverse;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
> button {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.redact-button {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.event-context-menu {
|
||||
position: fixed;
|
||||
background-color: var(--background-color);
|
||||
|
|
|
@ -13,5 +13,5 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
||||
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
||||
export { getModalStyleFromMouse } from "./util.ts"
|
||||
|
|
|
@ -37,9 +37,11 @@ export const usePrimaryItems = (
|
|||
roomCtx: RoomContextData,
|
||||
evt: MemDBEvent,
|
||||
isHover: boolean,
|
||||
isFixed: boolean,
|
||||
style?: CSSProperties,
|
||||
setForceOpen?: (forceOpen: boolean) => void,
|
||||
) => {
|
||||
const names = !isHover && !isFixed
|
||||
const closeModal = !isHover ? use(ModalCloseContext) : noop
|
||||
const openModal = use(ModalContext)
|
||||
|
||||
|
@ -108,11 +110,11 @@ export const usePrimaryItems = (
|
|||
return <>
|
||||
{didFail && <button onClick={onClickResend} title="Resend message">
|
||||
<RefreshIcon/>
|
||||
{!isHover && "Resend"}
|
||||
{names && "Resend"}
|
||||
</button>}
|
||||
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}>
|
||||
<ReactIcon/>
|
||||
{!isHover && "React"}
|
||||
{names && "React"}
|
||||
</button>}
|
||||
{canSend && <button
|
||||
disabled={isEditing || isPending}
|
||||
|
@ -120,11 +122,11 @@ export const usePrimaryItems = (
|
|||
onClick={onClickReply}
|
||||
>
|
||||
<ReplyIcon/>
|
||||
{!isHover && "Reply"}
|
||||
{names && "Reply"}
|
||||
</button>}
|
||||
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}>
|
||||
<EditIcon/>
|
||||
{!isHover && "Edit"}
|
||||
{names && "Edit"}
|
||||
</button>}
|
||||
{isHover && <button onClick={onClickMore}><MoreIcon/></button>}
|
||||
</>
|
||||
|
|
|
@ -32,6 +32,7 @@ export const useSecondaryItems = (
|
|||
client: Client,
|
||||
roomCtx: RoomContextData,
|
||||
evt: MemDBEvent,
|
||||
names = true,
|
||||
) => {
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const openModal = use(ModalContext)
|
||||
|
@ -102,20 +103,22 @@ export const useSecondaryItems = (
|
|||
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
||||
|
||||
return <>
|
||||
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
|
||||
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||
? <button onClick={onClickPin(false)}>
|
||||
<UnpinIcon/>Unpin message
|
||||
<UnpinIcon/>{names && "Unpin message"}
|
||||
</button>
|
||||
: <button onClick={onClickPin(true)} title={pendingTitle} disabled={isPending}>
|
||||
<PinIcon/>Pin message
|
||||
<PinIcon/>{names && "Pin message"}
|
||||
</button>)}
|
||||
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}><ReportIcon/>Report</button>
|
||||
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}>
|
||||
<ReportIcon/>{names && "Report"}
|
||||
</button>
|
||||
{canRedact && <button
|
||||
onClick={onClickRedact}
|
||||
disabled={isPending}
|
||||
title={pendingTitle}
|
||||
className="redact-button"
|
||||
><DeleteIcon/>Remove</button>}
|
||||
><DeleteIcon/>{names && "Remove"}</button>}
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -15,12 +15,15 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
const noop = () => {}
|
||||
const noopListen = () => noop
|
||||
|
||||
export function useEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T
|
||||
export function useEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null
|
||||
export function useEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null {
|
||||
export function useEventAsState<T>(dispatcher?: CachedEventDispatcher<T>): T | null
|
||||
export function useEventAsState<T>(dispatcher?: CachedEventDispatcher<T>): T | null {
|
||||
return useSyncExternalStore(
|
||||
dispatcher.listenChange,
|
||||
() => dispatcher.current,
|
||||
dispatcher ? dispatcher.listenChange : noopListen,
|
||||
() => dispatcher ? dispatcher.current : null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
|
@ -14,5 +14,7 @@ declare global {
|
|||
mainScreenContext: MainScreenContextFields
|
||||
openLightbox: (params: { src: string, alt: string }) => void
|
||||
gcSettings: GCSettings
|
||||
hackyOpenEventContextMenu?: string
|
||||
closeModal: () => void
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue