forked from Mirrors/gomuks
parent
158409db2f
commit
0fbf76af98
21 changed files with 317 additions and 27 deletions
|
@ -8,7 +8,7 @@ require github.com/wailsapp/wails/v3 v3.0.0-alpha.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.mau.fi/gomuks v0.3.1
|
go.mau.fi/gomuks v0.3.1
|
||||||
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc
|
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
|
@ -160,8 +160,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc h1:55919oheHZLQktzYU2z9KSJ5KHXq34dYedoGFfdUcsg=
|
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
|
||||||
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
|
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -17,7 +17,7 @@ require (
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
github.com/yuin/goldmark v1.7.8
|
github.com/yuin/goldmark v1.7.8
|
||||||
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc
|
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
|
||||||
go.mau.fi/zeroconfig v0.1.3
|
go.mau.fi/zeroconfig v0.1.3
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
golang.org/x/image v0.23.0
|
golang.org/x/image v0.23.0
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -63,8 +63,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc h1:55919oheHZLQktzYU2z9KSJ5KHXq34dYedoGFfdUcsg=
|
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
|
||||||
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
|
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
|
|
@ -8,7 +8,9 @@ package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
"go.mau.fi/util/dbutil"
|
||||||
|
@ -25,6 +27,7 @@ const (
|
||||||
SET event_id = excluded.event_id,
|
SET event_id = excluded.event_id,
|
||||||
timestamp = excluded.timestamp
|
timestamp = excluded.timestamp
|
||||||
`
|
`
|
||||||
|
getReadReceiptsQuery = `SELECT room_id, user_id, receipt_type, thread_id, event_id, timestamp FROM receipt WHERE room_id = $1 AND receipt_type='m.read' AND event_id IN ($2)`
|
||||||
)
|
)
|
||||||
|
|
||||||
var receiptMassInserter = dbutil.NewMassInsertBuilder[*Receipt, [1]any](upsertReceiptQuery, "($1, $%d, $%d, $%d, $%d, $%d)")
|
var receiptMassInserter = dbutil.NewMassInsertBuilder[*Receipt, [1]any](upsertReceiptQuery, "($1, $%d, $%d, $%d, $%d, $%d)")
|
||||||
|
@ -53,11 +56,29 @@ func (rq *ReceiptQuery) PutMany(ctx context.Context, roomID id.RoomID, receipts
|
||||||
return rq.Exec(ctx, query, params...)
|
return rq.Exec(ctx, query, params...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rq *ReceiptQuery) GetManyRead(ctx context.Context, roomID id.RoomID, eventIDs []id.EventID) (map[id.EventID][]*Receipt, error) {
|
||||||
|
args := make([]any, len(eventIDs)+1)
|
||||||
|
placeholders := make([]string, len(eventIDs)+1)
|
||||||
|
args[0] = roomID
|
||||||
|
placeholders[0] = "?1"
|
||||||
|
for i, evtID := range eventIDs {
|
||||||
|
args[i+1] = evtID
|
||||||
|
placeholders[i+1] = fmt.Sprintf("?%d", i+2)
|
||||||
|
}
|
||||||
|
query := strings.Replace(getReadReceiptsQuery, "$2", strings.Join(placeholders, ", "), 1)
|
||||||
|
output := make(map[id.EventID][]*Receipt)
|
||||||
|
err := rq.QueryManyIter(ctx, query, args...).Iter(func(receipt *Receipt) (bool, error) {
|
||||||
|
output[receipt.EventID] = append(output[receipt.EventID], receipt)
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
type Receipt struct {
|
type Receipt struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id,omitempty"`
|
||||||
UserID id.UserID `json:"user_id"`
|
UserID id.UserID `json:"user_id"`
|
||||||
ReceiptType event.ReceiptType `json:"receipt_type"`
|
ReceiptType event.ReceiptType `json:"receipt_type"`
|
||||||
ThreadID event.ThreadID `json:"thread_id"`
|
ThreadID event.ThreadID `json:"thread_id,omitempty"`
|
||||||
EventID id.EventID `json:"event_id"`
|
EventID id.EventID `json:"event_id"`
|
||||||
Timestamp jsontime.UnixMilli `json:"timestamp"`
|
Timestamp jsontime.UnixMilli `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ type SyncRoom struct {
|
||||||
Events []*database.Event `json:"events"`
|
Events []*database.Event `json:"events"`
|
||||||
Reset bool `json:"reset"`
|
Reset bool `json:"reset"`
|
||||||
Notifications []SyncNotification `json:"notifications"`
|
Notifications []SyncNotification `json:"notifications"`
|
||||||
|
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncNotification struct {
|
type SyncNotification struct {
|
||||||
|
|
|
@ -19,6 +19,7 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
|
||||||
Timeline: make([]database.TimelineRowTuple, 0),
|
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),
|
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 {
|
||||||
|
|
|
@ -118,6 +118,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
|
||||||
return h.DB.CurrentState.GetMany(ctx, params.Keys)
|
return h.DB.CurrentState.GetMany(ctx, params.Keys)
|
||||||
})
|
})
|
||||||
|
case "get_receipts":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *getReceiptsParams) (map[id.EventID][]*database.Receipt, error) {
|
||||||
|
return h.GetReceipts(ctx, params.RoomID, params.EventIDs)
|
||||||
|
})
|
||||||
case "paginate":
|
case "paginate":
|
||||||
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
|
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
|
||||||
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
|
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
|
||||||
|
@ -310,3 +314,8 @@ type paginateParams struct {
|
||||||
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
|
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getReceiptsParams struct {
|
||||||
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
EventIDs []id.EventID `json:"event_ids"`
|
||||||
|
}
|
||||||
|
|
|
@ -162,6 +162,7 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
||||||
Events: make([]*database.Event, 0),
|
Events: make([]*database.Event, 0),
|
||||||
Reset: false,
|
Reset: false,
|
||||||
Notifications: make([]SyncNotification, 0),
|
Notifications: make([]SyncNotification, 0),
|
||||||
|
Receipts: make(map[id.EventID][]*database.Receipt),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AccountData: make(map[event.Type]*database.AccountData),
|
AccountData: make(map[event.Type]*database.AccountData),
|
||||||
|
@ -197,6 +198,7 @@ func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, includeMe
|
||||||
|
|
||||||
type PaginationResponse struct {
|
type PaginationResponse struct {
|
||||||
Events []*database.Event `json:"events"`
|
Events []*database.Event `json:"events"`
|
||||||
|
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,15 +206,61 @@ func (h *HiClient) Paginate(ctx context.Context, roomID id.RoomID, maxTimelineID
|
||||||
evts, err := h.DB.Timeline.Get(ctx, roomID, limit, maxTimelineID)
|
evts, err := h.DB.Timeline.Get(ctx, roomID, limit, maxTimelineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if len(evts) > 0 {
|
}
|
||||||
|
var resp *PaginationResponse
|
||||||
|
if len(evts) > 0 {
|
||||||
for _, evt := range evts {
|
for _, evt := range evts {
|
||||||
h.ReprocessExistingEvent(ctx, evt)
|
h.ReprocessExistingEvent(ctx, evt)
|
||||||
}
|
}
|
||||||
return &PaginationResponse{Events: evts, HasMore: true}, nil
|
resp = &PaginationResponse{Events: evts, HasMore: true}
|
||||||
} else {
|
} else {
|
||||||
return h.PaginateServer(ctx, roomID, limit)
|
resp, err = h.PaginateServer(ctx, roomID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eventIDs := make([]id.EventID, len(resp.Events))
|
||||||
|
for i, evt := range resp.Events {
|
||||||
|
eventIDs[i] = evt.ID
|
||||||
|
}
|
||||||
|
resp.Receipts, err = h.GetReceipts(ctx, roomID, eventIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get receipts: %w", err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) GetReceipts(ctx context.Context, roomID id.RoomID, eventIDs []id.EventID) (map[id.EventID][]*database.Receipt, error) {
|
||||||
|
receipts, err := h.DB.Receipt.GetManyRead(ctx, roomID, eventIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
encounteredUsers := map[id.UserID]struct{}{
|
||||||
|
// Never include own receipts
|
||||||
|
h.Account.UserID: {},
|
||||||
|
}
|
||||||
|
// If there are multiple receipts (e.g. due to threads), only keep the one for the latest event (first in the array)
|
||||||
|
// The input event IDs are already sorted in reverse chronological order
|
||||||
|
for _, evtID := range eventIDs {
|
||||||
|
receiptArr := receipts[evtID]
|
||||||
|
i := 0
|
||||||
|
for _, receipt := range receiptArr {
|
||||||
|
_, alreadyEncountered := encounteredUsers[receipt.UserID]
|
||||||
|
if alreadyEncountered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Clear room ID for efficiency
|
||||||
|
receipt.RoomID = ""
|
||||||
|
encounteredUsers[receipt.UserID] = struct{}{}
|
||||||
|
receiptArr[i] = receipt
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if len(receiptArr) > 0 && i < len(receiptArr) {
|
||||||
|
receipts[evtID] = receiptArr[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return receipts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {
|
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {
|
||||||
ctx, cancel := context.WithCancelCause(ctx)
|
ctx, cancel := context.WithCancelCause(ctx)
|
||||||
|
|
|
@ -180,6 +180,9 @@ func (h *HiClient) receiptsToList(content *event.ReceiptEventContent) ([]*databa
|
||||||
if userID == h.Account.UserID {
|
if userID == h.Account.UserID {
|
||||||
newOwnReceipts = append(newOwnReceipts, eventID)
|
newOwnReceipts = append(newOwnReceipts, eventID)
|
||||||
}
|
}
|
||||||
|
if receiptInfo.ThreadID == event.ReadReceiptThreadMain {
|
||||||
|
receiptInfo.ThreadID = ""
|
||||||
|
}
|
||||||
receiptList = append(receiptList, &database.Receipt{
|
receiptList = append(receiptList, &database.Receipt{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ReceiptType: receiptType,
|
ReceiptType: receiptType,
|
||||||
|
@ -718,15 +721,38 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
setNewState(evt.Type, *evt.StateKey, rowID)
|
setNewState(evt.Type, *evt.StateKey, rowID)
|
||||||
}
|
}
|
||||||
var timelineRowTuples []database.TimelineRowTuple
|
var timelineRowTuples []database.TimelineRowTuple
|
||||||
|
receiptMap := make(map[id.EventID][]*database.Receipt)
|
||||||
|
for _, receipt := range receipts {
|
||||||
|
if receipt.UserID != h.Account.UserID {
|
||||||
|
receiptMap[receipt.EventID] = append(receiptMap[receipt.EventID], receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
if len(timeline.Events) > 0 {
|
if len(timeline.Events) > 0 {
|
||||||
timelineIDs := make([]database.EventRowID, len(timeline.Events))
|
timelineIDs := make([]database.EventRowID, len(timeline.Events))
|
||||||
|
encounteredReceiptUsers := make(map[id.UserID]struct{})
|
||||||
readUpToIndex := -1
|
readUpToIndex := -1
|
||||||
for i := len(timeline.Events) - 1; i >= 0; i-- {
|
for i := len(timeline.Events) - 1; i >= 0; i-- {
|
||||||
evt := timeline.Events[i]
|
evt := timeline.Events[i]
|
||||||
|
for _, receipt := range receiptMap[evt.ID] {
|
||||||
|
encounteredReceiptUsers[receipt.UserID] = struct{}{}
|
||||||
|
}
|
||||||
isRead := slices.Contains(newOwnReceipts, evt.ID)
|
isRead := slices.Contains(newOwnReceipts, evt.ID)
|
||||||
isOwnEvent := evt.Sender == h.Account.UserID
|
isOwnEvent := evt.Sender == h.Account.UserID
|
||||||
if isRead || isOwnEvent {
|
_, alreadyEncountered := encounteredReceiptUsers[evt.Sender]
|
||||||
|
if !isOwnEvent && !alreadyEncountered {
|
||||||
|
encounteredReceiptUsers[evt.Sender] = struct{}{}
|
||||||
|
injectedReceipt := &database.Receipt{
|
||||||
|
RoomID: room.ID,
|
||||||
|
UserID: evt.Sender,
|
||||||
|
ReceiptType: event.ReceiptTypeRead,
|
||||||
|
EventID: evt.ID,
|
||||||
|
Timestamp: jsontime.UM(time.UnixMilli(evt.Timestamp)),
|
||||||
|
}
|
||||||
|
receipts = append(receipts, injectedReceipt)
|
||||||
|
receiptMap[evt.ID] = append(receiptMap[evt.ID], injectedReceipt)
|
||||||
|
}
|
||||||
|
if readUpToIndex == -1 && (isRead || isOwnEvent) {
|
||||||
readUpToIndex = i
|
readUpToIndex = i
|
||||||
// Reset unread counts if we see our own read receipt in the timeline.
|
// Reset unread counts if we see our own read receipt in the timeline.
|
||||||
// It'll be updated with new unreads (if any) at the end.
|
// It'll be updated with new unreads (if any) at the end.
|
||||||
|
@ -741,7 +767,6 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
})
|
})
|
||||||
newOwnReceipts = append(newOwnReceipts, evt.ID)
|
newOwnReceipts = append(newOwnReceipts, evt.ID)
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, evt := range timeline.Events {
|
for i, evt := range timeline.Events {
|
||||||
|
@ -841,7 +866,10 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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(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 {
|
||||||
|
receipt.RoomID = ""
|
||||||
|
}
|
||||||
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
|
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
|
||||||
Meta: room,
|
Meta: room,
|
||||||
Timeline: timelineRowTuples,
|
Timeline: timelineRowTuples,
|
||||||
|
@ -850,6 +878,7 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
Reset: timeline.Limited,
|
Reset: timeline.Limited,
|
||||||
Events: allNewEvents,
|
Events: allNewEvents,
|
||||||
Notifications: newNotifications,
|
Notifications: newNotifications,
|
||||||
|
Receipts: receiptMap,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -316,7 +316,7 @@ export default class Client {
|
||||||
throw new Error("Timeline changed while loading history")
|
throw new Error("Timeline changed while loading history")
|
||||||
}
|
}
|
||||||
room.hasMoreHistory = resp.has_more
|
room.hasMoreHistory = resp.has_more
|
||||||
room.applyPagination(resp.events)
|
room.applyPagination(resp.events, resp.receipts)
|
||||||
} finally {
|
} finally {
|
||||||
room.paginating = false
|
room.paginating = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
|
||||||
import type { CustomEmojiPack } from "@/util/emoji"
|
import type { CustomEmojiPack } from "@/util/emoji"
|
||||||
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
|
import type { EventID, EventType, MemDBEvent, MemReceipt, UnknownEventContent } from "../types"
|
||||||
import { Preferences, preferences } from "../types/preferences"
|
import { Preferences, preferences } from "../types/preferences"
|
||||||
import type { StateStore } from "./main.ts"
|
import type { StateStore } from "./main.ts"
|
||||||
import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts"
|
import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts"
|
||||||
|
@ -31,6 +31,13 @@ export function useRoomTyping(room: RoomStateStore): string[] {
|
||||||
return useSyncExternalStore(room.typingSub.subscribe, () => room.typing)
|
return useSyncExternalStore(room.typingSub.subscribe, () => room.typing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useReadReceipts(room: RoomStateStore, evtID: EventID): MemReceipt[] {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
room.receiptSubs.getSubscriber(evtID),
|
||||||
|
() => room.receiptsByEventID.get(evtID) ?? emptyArray,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function useRoomState(
|
export function useRoomState(
|
||||||
room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "",
|
room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "",
|
||||||
): MemDBEvent | null {
|
): MemDBEvent | null {
|
||||||
|
|
|
@ -21,6 +21,7 @@ import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subs
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
import {
|
import {
|
||||||
ContentURI,
|
ContentURI,
|
||||||
|
DBReceipt,
|
||||||
DBRoom,
|
DBRoom,
|
||||||
EncryptedEventContent,
|
EncryptedEventContent,
|
||||||
EventID,
|
EventID,
|
||||||
|
@ -30,6 +31,7 @@ import {
|
||||||
ImagePack,
|
ImagePack,
|
||||||
LazyLoadSummary,
|
LazyLoadSummary,
|
||||||
MemDBEvent,
|
MemDBEvent,
|
||||||
|
MemReceipt,
|
||||||
MemberEventContent,
|
MemberEventContent,
|
||||||
PowerLevelEventContent,
|
PowerLevelEventContent,
|
||||||
RawDBEvent,
|
RawDBEvent,
|
||||||
|
@ -83,6 +85,8 @@ export interface AutocompleteMemberEntry {
|
||||||
|
|
||||||
const collator = new Intl.Collator()
|
const collator = new Intl.Collator()
|
||||||
|
|
||||||
|
const UNSENT_TIMELINE_ROWID_BASE = 1000000000000000
|
||||||
|
|
||||||
export class RoomStateStore {
|
export class RoomStateStore {
|
||||||
readonly roomID: RoomID
|
readonly roomID: RoomID
|
||||||
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
||||||
|
@ -98,6 +102,9 @@ export class RoomStateStore {
|
||||||
readonly typingSub = new Subscribable()
|
readonly typingSub = new Subscribable()
|
||||||
readonly stateSubs = new MultiSubscribable()
|
readonly stateSubs = new MultiSubscribable()
|
||||||
readonly eventSubs = new MultiSubscribable()
|
readonly eventSubs = new MultiSubscribable()
|
||||||
|
readonly receiptsByEventID: Map<EventID, MemReceipt[]> = new Map()
|
||||||
|
readonly receiptsByUserID: Map<UserID, MemReceipt> = new Map()
|
||||||
|
readonly receiptSubs = new MultiSubscribable()
|
||||||
readonly requestedEvents: Set<EventID> = new Set()
|
readonly requestedEvents: Set<EventID> = new Set()
|
||||||
readonly requestedMembers: Set<UserID> = new Set()
|
readonly requestedMembers: Set<UserID> = new Set()
|
||||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||||
|
@ -126,7 +133,7 @@ export class RoomStateStore {
|
||||||
this.preferences = getPreferenceProxy(parent, this)
|
this.preferences = getPreferenceProxy(parent, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyTimelineSubscribers() {
|
#updateTimelineCache() {
|
||||||
this.timelineCache = this.timeline.map(rt => {
|
this.timelineCache = this.timeline.map(rt => {
|
||||||
const evt = this.eventsByRowID.get(rt.event_rowid)
|
const evt = this.eventsByRowID.get(rt.event_rowid)
|
||||||
if (!evt) {
|
if (!evt) {
|
||||||
|
@ -137,6 +144,10 @@ export class RoomStateStore {
|
||||||
}).concat(this.pendingEvents
|
}).concat(this.pendingEvents
|
||||||
.map(rowID => this.eventsByRowID.get(rowID))
|
.map(rowID => this.eventsByRowID.get(rowID))
|
||||||
.filter(evt => !!evt))
|
.filter(evt => !!evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyTimelineSubscribers() {
|
||||||
|
this.#updateTimelineCache()
|
||||||
this.timelineSub.notify()
|
this.timelineSub.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -232,7 +243,7 @@ export class RoomStateStore {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPagination(history: RawDBEvent[]) {
|
applyPagination(history: RawDBEvent[], allReceipts: Record<EventID, DBReceipt[]>) {
|
||||||
// Pagination comes in newest to oldest, timeline is in the opposite order
|
// Pagination comes in newest to oldest, timeline is in the opposite order
|
||||||
history.reverse()
|
history.reverse()
|
||||||
const newTimeline = history.map(evt => {
|
const newTimeline = history.map(evt => {
|
||||||
|
@ -241,6 +252,50 @@ export class RoomStateStore {
|
||||||
})
|
})
|
||||||
this.timeline.splice(0, 0, ...newTimeline)
|
this.timeline.splice(0, 0, ...newTimeline)
|
||||||
this.notifyTimelineSubscribers()
|
this.notifyTimelineSubscribers()
|
||||||
|
for (const [evtID, receipts] of Object.entries(allReceipts)) {
|
||||||
|
this.applyReceipts(receipts, evtID, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReceipts(receipts: DBReceipt[], evtID: EventID, override: boolean) {
|
||||||
|
const evt = this.eventsByID.get(evtID)
|
||||||
|
if (!evt?.timeline_rowid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const filtered = receipts.filter(receipt => this.applyReceipt(receipt, evt))
|
||||||
|
filtered.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
if (override) {
|
||||||
|
this.receiptsByEventID.set(evtID, filtered)
|
||||||
|
} else {
|
||||||
|
const existing = this.receiptsByEventID.get(evtID) ?? []
|
||||||
|
this.receiptsByEventID.set(evtID, existing.concat(filtered))
|
||||||
|
}
|
||||||
|
this.receiptSubs.notify(evtID)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReceipt(receipt: DBReceipt, evt: MemDBEvent): receipt is MemReceipt {
|
||||||
|
const existingReceipt = this.receiptsByUserID.get(receipt.user_id)
|
||||||
|
if (existingReceipt) {
|
||||||
|
if (existingReceipt.timeline_rowid >= evt.timeline_rowid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const oldArr = this.receiptsByEventID.get(existingReceipt.event_id)
|
||||||
|
if (oldArr) {
|
||||||
|
const idx = oldArr.indexOf(existingReceipt)
|
||||||
|
if (idx >= 0) {
|
||||||
|
oldArr.splice(idx, 1)
|
||||||
|
if (oldArr.length === 0) {
|
||||||
|
this.receiptsByEventID.delete(existingReceipt.event_id)
|
||||||
|
}
|
||||||
|
this.receiptSubs.notify(existingReceipt.event_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const memReceipt = receipt as MemReceipt
|
||||||
|
memReceipt.timeline_rowid = evt.timeline_rowid > UNSENT_TIMELINE_ROWID_BASE ? 1 : evt.timeline_rowid
|
||||||
|
memReceipt.event_rowid = evt.rowid
|
||||||
|
this.receiptsByUserID.set(receipt.user_id, memReceipt)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
applyEvent(evt: RawDBEvent, pending: boolean = false) {
|
applyEvent(evt: RawDBEvent, pending: boolean = false) {
|
||||||
|
@ -248,7 +303,7 @@ export class RoomStateStore {
|
||||||
memEvt.mem = true
|
memEvt.mem = true
|
||||||
memEvt.pending = pending
|
memEvt.pending = pending
|
||||||
if (pending) {
|
if (pending) {
|
||||||
memEvt.timeline_rowid = 1000000000000000 + memEvt.timestamp
|
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
|
||||||
}
|
}
|
||||||
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
||||||
memEvt.type = evt.decrypted_type
|
memEvt.type = evt.decrypted_type
|
||||||
|
@ -287,6 +342,7 @@ export class RoomStateStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.eventSubs.notify(memEvt.event_id)
|
this.eventSubs.notify(memEvt.event_id)
|
||||||
|
return memEvt
|
||||||
}
|
}
|
||||||
|
|
||||||
applySendComplete(evt: RawDBEvent) {
|
applySendComplete(evt: RawDBEvent) {
|
||||||
|
@ -354,6 +410,9 @@ export class RoomStateStore {
|
||||||
this.openNotifications.clear()
|
this.openNotifications.clear()
|
||||||
}
|
}
|
||||||
this.notifyTimelineSubscribers()
|
this.notifyTimelineSubscribers()
|
||||||
|
for (const [evtID, receipts] of Object.entries(sync.receipts)) {
|
||||||
|
this.applyReceipts(receipts, evtID, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyState(evt: RawDBEvent) {
|
applyState(evt: RawDBEvent) {
|
||||||
|
@ -473,6 +532,8 @@ export class RoomStateStore {
|
||||||
const deletedEvents = this.eventsByRowID.size - eventsToKeep.size
|
const deletedEvents = this.eventsByRowID.size - eventsToKeep.size
|
||||||
this.eventsByRowID.clear()
|
this.eventsByRowID.clear()
|
||||||
this.eventsByID.clear()
|
this.eventsByID.clear()
|
||||||
|
this.receiptsByEventID.clear()
|
||||||
|
this.receiptsByUserID.clear()
|
||||||
for (const evt of eventsToKeepList) {
|
for (const evt of eventsToKeepList) {
|
||||||
this.eventsByRowID.set(evt.rowid, evt)
|
this.eventsByRowID.set(evt.rowid, evt)
|
||||||
this.eventsByID.set(evt.event_id, evt)
|
this.eventsByID.set(evt.event_id, evt)
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import {
|
import {
|
||||||
DBAccountData,
|
DBAccountData,
|
||||||
|
DBReceipt,
|
||||||
DBRoom,
|
DBRoom,
|
||||||
DBRoomAccountData,
|
DBRoomAccountData,
|
||||||
EventRowID,
|
EventRowID,
|
||||||
|
@ -23,6 +24,7 @@ import {
|
||||||
} from "./hitypes.ts"
|
} from "./hitypes.ts"
|
||||||
import {
|
import {
|
||||||
DeviceID,
|
DeviceID,
|
||||||
|
EventID,
|
||||||
EventType,
|
EventType,
|
||||||
RoomID,
|
RoomID,
|
||||||
UserID,
|
UserID,
|
||||||
|
@ -74,6 +76,7 @@ export interface SyncRoom {
|
||||||
reset: boolean
|
reset: boolean
|
||||||
notifications: SyncNotification[]
|
notifications: SyncNotification[]
|
||||||
account_data: Record<EventType, DBRoomAccountData>
|
account_data: Record<EventType, DBRoomAccountData>
|
||||||
|
receipts: Record<EventID, DBReceipt[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncNotification {
|
export interface SyncNotification {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
EventID,
|
EventID,
|
||||||
EventType,
|
EventType,
|
||||||
LazyLoadSummary,
|
LazyLoadSummary,
|
||||||
|
ReceiptType,
|
||||||
RelationType,
|
RelationType,
|
||||||
RoomAlias,
|
RoomAlias,
|
||||||
RoomID,
|
RoomID,
|
||||||
|
@ -145,8 +146,22 @@ export interface DBRoomAccountData {
|
||||||
content: UnknownEventContent
|
content: UnknownEventContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DBReceipt {
|
||||||
|
user_id: UserID
|
||||||
|
receipt_type: ReceiptType
|
||||||
|
thread_id?: EventID | "main"
|
||||||
|
event_id: EventID
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemReceipt extends DBReceipt {
|
||||||
|
event_rowid: EventRowID
|
||||||
|
timeline_rowid: TimelineRowID
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginationResponse {
|
export interface PaginationResponse {
|
||||||
events: RawDBEvent[]
|
events: RawDBEvent[]
|
||||||
|
receipts: Record<EventID, DBReceipt[]>
|
||||||
has_more: boolean
|
has_more: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -82,9 +82,11 @@
|
||||||
--timeline-sender-name-timestamp-gap: .25rem;
|
--timeline-sender-name-timestamp-gap: .25rem;
|
||||||
--timeline-sender-name-content-gap: 0;
|
--timeline-sender-name-content-gap: 0;
|
||||||
--timeline-horizontal-padding: 1.5rem;
|
--timeline-horizontal-padding: 1.5rem;
|
||||||
|
--timeline-status-size: 4rem;
|
||||||
|
|
||||||
@media screen and (max-width: 45rem) {
|
@media screen and (max-width: 45rem) {
|
||||||
--timeline-horizontal-padding: .5rem;
|
--timeline-horizontal-padding: .5rem;
|
||||||
|
--timeline-status-size: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
|
@ -42,6 +42,7 @@ const TypingNotifications = () => {
|
||||||
use(ClientContext)?.requestMemberEvent(room, sender)
|
use(ClientContext)?.requestMemberEvent(room, sender)
|
||||||
}
|
}
|
||||||
avatars.push(<img
|
avatars.push(<img
|
||||||
|
key={sender}
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(sender, member)}
|
src={getAvatarURL(sender, member)}
|
||||||
|
|
29
web/src/ui/timeline/ReadReceipts.css
Normal file
29
web/src/ui/timeline/ReadReceipts.css
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
div.timeline-event > div.read-receipts {
|
||||||
|
grid-area: status;
|
||||||
|
display: flex;
|
||||||
|
justify-content: right;
|
||||||
|
align-items: end;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: .125rem;
|
||||||
|
|
||||||
|
> div.overflow-count {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
margin-right: .125rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.avatars {
|
||||||
|
display: flex;
|
||||||
|
margin-left: .35rem;
|
||||||
|
|
||||||
|
> img {
|
||||||
|
margin-left: -.35rem;
|
||||||
|
border: 1px solid var(--background-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& + div.event-send-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
61
web/src/ui/timeline/ReadReceipts.tsx
Normal file
61
web/src/ui/timeline/ReadReceipts.tsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// 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 { use } from "react"
|
||||||
|
import { getAvatarURL } from "@/api/media.ts"
|
||||||
|
import { RoomStateStore, useReadReceipts } from "@/api/statestore"
|
||||||
|
import { EventID, MemberEventContent } from "@/api/types"
|
||||||
|
import { humanJoin } from "@/util/join.ts"
|
||||||
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
|
import ClientContext from "../ClientContext.ts"
|
||||||
|
import "./ReadReceipts.css"
|
||||||
|
|
||||||
|
const ReadReceipts = ({ room, eventID }: { room: RoomStateStore, eventID: EventID }) => {
|
||||||
|
const receipts = useReadReceipts(room, eventID)
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Hacky hack for mobile clients. Would be nicer to get the number based on the CSS variable defining the size
|
||||||
|
const maxAvatarCount = window.innerWidth > 720 ? 4 : 2
|
||||||
|
const memberEvts = receipts.map(receipt => {
|
||||||
|
const memberEvt = room.getStateEvent("m.room.member", receipt.user_id)
|
||||||
|
const member = (memberEvt?.content ?? null) as MemberEventContent | null
|
||||||
|
if (!memberEvt) {
|
||||||
|
use(ClientContext)?.requestMemberEvent(room, receipt.user_id)
|
||||||
|
}
|
||||||
|
return [receipt.user_id, member] as const
|
||||||
|
})
|
||||||
|
const avatarMembers = receipts.length > maxAvatarCount ? memberEvts.slice(-maxAvatarCount+1) : memberEvts
|
||||||
|
const avatars = avatarMembers.map(([userID, member]) => {
|
||||||
|
return <img
|
||||||
|
key={userID}
|
||||||
|
className="small avatar"
|
||||||
|
loading="lazy"
|
||||||
|
src={getAvatarURL(userID, member)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
})
|
||||||
|
const names = memberEvts.map(([userID, member]) => getDisplayname(userID, member))
|
||||||
|
return <div className="read-receipts" title={`Read by ${humanJoin(names)}`}>
|
||||||
|
{avatars.length < receipts.length && <div className="overflow-count">
|
||||||
|
+{receipts.length - avatars.length}
|
||||||
|
</div>}
|
||||||
|
<div className="avatars">
|
||||||
|
{avatars}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadReceipts
|
|
@ -5,10 +5,10 @@ div.timeline-event {
|
||||||
padding: 0 var(--timeline-horizontal-padding);
|
padding: 0 var(--timeline-horizontal-padding);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template:
|
grid-template:
|
||||||
"cmc cmc cmc cmc" 0
|
"cmc cmc cmc empty" 0
|
||||||
"avatar gap sender sender" auto
|
"avatar gap sender sender" auto
|
||||||
"avatar gap content status" auto
|
"avatar gap content status" auto
|
||||||
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr 2rem;
|
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
|
||||||
contain: layout;
|
contain: layout;
|
||||||
margin-top: var(--timeline-message-gap);
|
margin-top: var(--timeline-message-gap);
|
||||||
|
|
||||||
|
@ -119,9 +119,9 @@ div.timeline-event {
|
||||||
|
|
||||||
&.same-sender {
|
&.same-sender {
|
||||||
grid-template:
|
grid-template:
|
||||||
"cmc cmc cmc" 0
|
"cmc cmc empty" 0
|
||||||
"timestamp content status" auto
|
"timestamp content status" auto
|
||||||
/ var(--timeline-avatar-total-size) 1fr 2rem;
|
/ var(--timeline-avatar-total-size) 1fr var(--timeline-status-size);
|
||||||
margin-top: var(--timeline-message-gap-same-sender);
|
margin-top: var(--timeline-message-gap-same-sender);
|
||||||
|
|
||||||
> div.sender-avatar, > div.event-sender-and-time {
|
> div.sender-avatar, > div.event-sender-and-time {
|
||||||
|
@ -135,9 +135,9 @@ div.timeline-event {
|
||||||
|
|
||||||
&.small-event {
|
&.small-event {
|
||||||
grid-template:
|
grid-template:
|
||||||
"cmc cmc cmc cmc" 0
|
"cmc cmc cmc empty" 0
|
||||||
"timestamp avatar content status" auto
|
"timestamp avatar content status" auto
|
||||||
/ var(--timeline-avatar-total-size) 1.5rem 1fr 2rem;
|
/ var(--timeline-avatar-total-size) 1.5rem 1fr var(--timeline-status-size);
|
||||||
|
|
||||||
> div.sender-avatar {
|
> div.sender-avatar {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
|
|
|
@ -23,6 +23,7 @@ import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
import { ModalContext } from "../modal/Modal.tsx"
|
import { ModalContext } from "../modal/Modal.tsx"
|
||||||
import { useRoomContext } from "../roomview/roomcontext.ts"
|
import { useRoomContext } from "../roomview/roomcontext.ts"
|
||||||
|
import ReadReceipts from "./ReadReceipts.tsx"
|
||||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
import { ReplyIDBody } from "./ReplyBody.tsx"
|
||||||
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
|
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
|
||||||
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
|
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
|
||||||
|
@ -201,6 +202,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
|
||||||
</ContentErrorBoundary>
|
</ContentErrorBoundary>
|
||||||
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{!evt.event_id.startsWith("~") && <ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
|
||||||
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
||||||
</div>
|
</div>
|
||||||
return <>
|
return <>
|
||||||
|
|
Loading…
Add table
Reference in a new issue