feat(messages): sync saved message tags

This commit is contained in:
iamxvbaba 2026-07-25 19:23:14 +08:00
parent a785ae7491
commit 6b3eba6c5d
42 changed files with 1581 additions and 551 deletions

View file

@ -632,54 +632,6 @@ func (s *ChannelStore) ClearRecentMessageReactions(_ context.Context, userID int
return nil
}
func (s *ChannelStore) ListSavedReactionTags(_ context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
s.mu.RLock()
defer s.mu.RUnlock()
rows := make([]domain.SavedReactionTag, 0, len(s.savedTags[userID]))
for _, row := range s.savedTags[userID] {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
if rows[i].Reaction.Type != rows[j].Reaction.Type {
return rows[i].Reaction.Type < rows[j].Reaction.Type
}
return rows[i].Reaction.Value() < rows[j].Reaction.Value()
})
if len(rows) > limit {
rows = rows[:limit]
}
return rows, nil
}
func (s *ChannelStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(tag.Reaction.Emoticon) == "" {
return domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if s.savedTags[tag.UserID] == nil {
s.savedTags[tag.UserID] = make(map[string]domain.SavedReactionTag)
}
tag.Reaction.Emoticon = strings.TrimSpace(tag.Reaction.Emoticon)
if tag.Count < 0 {
tag.Count = 0
}
s.savedTags[tag.UserID][messageReactionKey(tag.Reaction)] = tag
return nil
}
func (s *ChannelStore) ListChannelUnreadReactions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) {
s.mu.RLock()
defer s.mu.RUnlock()

View file

@ -76,7 +76,6 @@ type ChannelStore struct {
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
@ -124,7 +123,6 @@ func NewChannelStore() *ChannelStore {
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),

View file

@ -52,6 +52,12 @@ func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult,
idsByOwner := make(map[int64][]int)
peersByOwner := make(map[int64]map[domain.Peer]struct{})
for _, row := range deleted {
if byMessage := s.savedMessageTags[row.userID]; byMessage != nil {
delete(byMessage, row.id)
if len(byMessage) == 0 {
delete(s.savedMessageTags, row.userID)
}
}
idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id)
if peersByOwner[row.userID] == nil {
peersByOwner[row.userID] = make(map[domain.Peer]struct{})

View file

@ -4,8 +4,9 @@ import (
"context"
"sort"
"strings"
"telesrv/internal/domain"
"time"
"telesrv/internal/domain"
)
func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) {
@ -282,6 +283,12 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
continue
}
if filter.MinDate > 0 && msg.Date <= filter.MinDate {
continue
}
if filter.MaxDate > 0 && msg.Date >= filter.MaxDate {
continue
}
if filter.MaxID > 0 && msg.ID >= filter.MaxID {
continue
}
@ -297,6 +304,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if filter.SavedPeer.ID != 0 && msg.SavedPeer != filter.SavedPeer {
continue
}
if len(filter.SavedReactions) > 0 && !messageHasAnySavedTag(msg, filter.SavedReactions) {
continue
}
base = append(base, msg)
}
@ -316,6 +326,22 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
}
}
func messageHasAnySavedTag(msg domain.Message, wanted []domain.MessageReaction) bool {
if msg.Reactions == nil || !msg.Reactions.AsTags {
return false
}
have := make(map[string]struct{}, len(msg.Reactions.Results))
for _, result := range msg.Reactions.Results {
have[result.Reaction.Key()] = struct{}{}
}
for _, reaction := range wanted {
if _, ok := have[reaction.Key()]; ok {
return true
}
}
return false
}
func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
if limit <= 0 || len(base) == 0 {
return nil

View file

@ -22,6 +22,9 @@ func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPriv
}
s.mu.Lock()
defer s.mu.Unlock()
if req.Peer.ID == req.UserID {
return s.setSavedMessageTagsLocked(req)
}
var target domain.Message
for _, msg := range s.m[req.UserID] {
if msg.ID == req.MessageID && msg.Peer == req.Peer {
@ -119,6 +122,10 @@ func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMess
}
func (s *MessageStore) privateMessageReactionsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
if msg.OwnerUserID != 0 &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
return s.savedMessageTagsForMessageLocked(msg)
}
reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID)
if len(reactions.Recent) == 0 || msg.From.ID == 0 {
return reactions
@ -232,6 +239,11 @@ func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageRe
return
}
var buf [16]byte
if reactions.AsTags {
_, _ = h.Write([]byte{1})
} else {
_, _ = h.Write([]byte{0})
}
for _, item := range reactions.Results {
_, _ = h.Write([]byte(item.Reaction.Type))
_, _ = h.Write([]byte{0})

View file

@ -0,0 +1,159 @@
package memory
import (
"context"
"sort"
"telesrv/internal/domain"
)
func (s *MessageStore) setSavedMessageTagsLocked(req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
var target domain.Message
for _, msg := range s.m[req.UserID] {
if msg.ID == req.MessageID &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
target = msg
break
}
}
if target.ID == 0 {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
for _, reaction := range req.Reactions {
if !reaction.Valid() {
return domain.PrivateMessageReactionsResult{}, domain.ErrReactionInvalid
}
}
if len(req.Reactions) == 0 {
if byMessage := s.savedMessageTags[req.UserID]; byMessage != nil {
delete(byMessage, target.ID)
if len(byMessage) == 0 {
delete(s.savedMessageTags, req.UserID)
}
}
} else {
if s.savedMessageTags[req.UserID] == nil {
s.savedMessageTags[req.UserID] = make(map[int][]domain.MessageReaction)
}
s.savedMessageTags[req.UserID][target.ID] = append([]domain.MessageReaction(nil), req.Reactions...)
}
item := cloneMessage(target)
reactions := s.savedMessageTagsForMessageLocked(item)
item.Reactions = cloneChannelMessageReactionsPtr(&reactions)
return domain.PrivateMessageReactionsResult{
Messages: []domain.Message{item},
Reactions: reactions,
}, nil
}
func (s *MessageStore) savedMessageTagsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
out := domain.ChannelMessageReactions{
AsTags: true,
Results: []domain.ChannelMessageReactionCount{},
Recent: []domain.ChannelMessagePeerReaction{},
}
for i, reaction := range s.savedMessageTags[msg.OwnerUserID][msg.ID] {
out.Results = append(out.Results, domain.ChannelMessageReactionCount{
Reaction: reaction,
Count: 1,
ChosenOrder: i + 1,
})
}
return out
}
func (s *MessageStore) ListSavedReactionTags(_ context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
if req.UserID == 0 {
return nil, domain.ErrReactionInvalid
}
if req.Limit <= 0 || req.Limit > domain.MaxSavedReactionTags {
req.Limit = domain.MaxSavedReactionTags
}
s.mu.RLock()
defer s.mu.RUnlock()
visible := make(map[int]domain.Message, len(s.m[req.UserID]))
for _, msg := range s.m[req.UserID] {
if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
visible[msg.ID] = msg
}
}
byKey := make(map[string]domain.SavedReactionTag)
for messageID, reactions := range s.savedMessageTags[req.UserID] {
msg, ok := visible[messageID]
if !ok || (req.SavedPeer.ID != 0 && msg.SavedPeer != req.SavedPeer) {
continue
}
for _, reaction := range reactions {
key := reaction.Key()
tag := byKey[key]
tag.UserID = req.UserID
tag.Reaction = reaction
tag.Count++
if req.SavedPeer.ID == 0 {
tag.Title = s.savedTagTitles[req.UserID][key]
}
byKey[key] = tag
}
}
out := make([]domain.SavedReactionTag, 0, len(byKey))
for _, tag := range byKey {
out = append(out, tag)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Reaction.Key() > out[j].Reaction.Key()
})
if len(out) > req.Limit {
out = out[:req.Limit]
}
return out, nil
}
func (s *MessageStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || !tag.Reaction.Valid() {
return domain.ErrReactionInvalid
}
key := tag.Reaction.Key()
s.mu.Lock()
defer s.mu.Unlock()
found := false
for messageID, reactions := range s.savedMessageTags[tag.UserID] {
alive := false
for _, msg := range s.m[tag.UserID] {
if msg.ID == messageID &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: tag.UserID}) {
alive = true
break
}
}
if !alive {
continue
}
for _, reaction := range reactions {
if reaction.Key() == key {
found = true
break
}
}
if found {
break
}
}
if !found {
return domain.ErrReactionInvalid
}
if tag.Title == "" {
if titles := s.savedTagTitles[tag.UserID]; titles != nil {
delete(titles, key)
}
return nil
}
if s.savedTagTitles[tag.UserID] == nil {
s.savedTagTitles[tag.UserID] = make(map[string]string)
}
s.savedTagTitles[tag.UserID][key] = tag.Title
return nil
}

View file

@ -0,0 +1,150 @@
package memory
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestSavedMessageTagsAssignmentCountsSearchAndDelete(t *testing.T) {
ctx := context.Background()
const userID int64 = 1001
self := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
peerA := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}
thumb := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
custom := domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 90001}
store := NewMessageStore()
create := func(body string, savedPeer domain.Peer) domain.Message {
msg, err := store.Create(ctx, domain.Message{
OwnerUserID: userID,
Peer: self,
From: self,
SavedPeer: savedPeer,
Date: 1_700_000_000,
Body: body,
})
if err != nil {
t.Fatalf("create saved message: %v", err)
}
return msg
}
first := create("first", peerA)
second := create("second", peerA)
third := create("third", peerB)
set := func(msg domain.Message, reactions ...domain.MessageReaction) {
t.Helper()
result, err := store.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: userID,
Peer: self,
MessageID: msg.ID,
Reactions: reactions,
ReactionsPerUserMax: 3,
})
if err != nil {
t.Fatalf("set saved tags for %d: %v", msg.ID, err)
}
if len(result.Messages) != 1 || result.Messages[0].Reactions == nil ||
!result.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag result = %+v, want one reactions_as_tags message", result)
}
}
set(first, thumb)
set(second, thumb, custom)
set(third, custom)
if got := store.nextPts[userID]; got != 0 {
t.Fatalf("tag mutations pts = %d, want 0", got)
}
if err := store.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: userID, Reaction: thumb, Title: "Fav",
}); err != nil {
t.Fatalf("rename saved tag: %v", err)
}
global, err := store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list global saved tags: %v", err)
}
assertMemorySavedTag(t, global, thumb, 2, "Fav")
assertMemorySavedTag(t, global, custom, 2, "")
perPeer, err := store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: userID, SavedPeer: peerA, Limit: 100,
})
if err != nil {
t.Fatalf("list per-peer saved tags: %v", err)
}
assertMemorySavedTag(t, perPeer, thumb, 2, "")
assertMemorySavedTag(t, perPeer, custom, 1, "")
found, err := store.ListByUser(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedPeer: peerA,
SavedReactions: []domain.MessageReaction{custom},
Limit: 10,
})
if err != nil {
t.Fatalf("search saved tag: %v", err)
}
if len(found.Messages) != 1 || found.Messages[0].ID != second.ID ||
found.Messages[0].Reactions == nil || !found.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag search = %+v, want second message", found.Messages)
}
foundAny, err := store.ListByUser(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedReactions: []domain.MessageReaction{thumb, custom},
Limit: 10,
})
if err != nil {
t.Fatalf("search any saved tag: %v", err)
}
if len(foundAny.Messages) != 3 {
t.Fatalf("saved tag OR search = %+v, want all three messages", foundAny.Messages)
}
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: userID,
IDs: []int{second.ID},
Date: 1_700_000_100,
}); err != nil {
t.Fatalf("delete tagged message: %v", err)
}
global, err = store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list tags after delete: %v", err)
}
assertMemorySavedTag(t, global, thumb, 1, "Fav")
assertMemorySavedTag(t, global, custom, 1, "")
set(first)
global, err = store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list tags after clear: %v", err)
}
if len(global) != 1 || global[0].Reaction.Key() != custom.Key() {
t.Fatalf("tags after clear = %+v, want only custom", global)
}
if err := store.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: userID, Reaction: thumb, Title: "ghost",
}); err != domain.ErrReactionInvalid {
t.Fatalf("rename unassigned tag err = %v, want ErrReactionInvalid", err)
}
}
func assertMemorySavedTag(t *testing.T, tags []domain.SavedReactionTag, reaction domain.MessageReaction, count int, title string) {
t.Helper()
for _, tag := range tags {
if tag.Reaction.Key() == reaction.Key() {
if tag.Count != count || tag.Title != title {
t.Fatalf("tag %s = %+v, want count=%d title=%q", reaction.Key(), tag, count, title)
}
return
}
}
t.Fatalf("tag %s not found in %+v", reaction.Key(), tags)
}

View file

@ -14,6 +14,8 @@ type MessageStore struct {
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
savedMessageTags map[int64]map[int][]domain.MessageReaction
savedTagTitles map[int64]map[string]string
privateSendDedup map[privateSendDedupKey]privateSendDedupRecord
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
albumGroups map[albumGroupKey]albumGroupRecord
@ -44,6 +46,8 @@ func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
savedMessageTags: make(map[int64]map[int][]domain.MessageReaction),
savedTagTitles: make(map[int64]map[string]string),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),