package memory import ( "context" "sort" "telesrv/internal/domain" ) func (s *ChannelStore) SetChannelDialogUnreadMark(_ context.Context, userID, channelID int64, unread bool) (bool, error) { if userID == 0 || channelID == 0 { return false, nil } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(userID, channelID) if err != nil { return false, nil } dialog := s.dialogForUserLocked(userID, channel) changed := dialog.UnreadMark != unread dialog.UnreadMark = unread member := s.members[channelID][userID] member.UnreadMark = unread s.members[channelID][userID] = member if s.dialogs[userID] == nil { s.dialogs[userID] = make(map[int64]domain.ChannelDialog) } s.dialogs[userID][channelID] = dialog return changed, nil } func (s *ChannelStore) ListChannelUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { if userID == 0 { return nil, nil } s.mu.RLock() defer s.mu.RUnlock() out := make([]domain.Peer, 0, len(s.dialogs[userID])) for channelID, dialog := range s.dialogs[userID] { if !dialog.UnreadMark { continue } if _, err := s.channelForMemberLocked(userID, channelID); err != nil { continue } out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (s *ChannelStore) ReadChannelMessageContents(_ context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelMessageContentsResult{}, err } if len(req.IDs) == 0 { return domain.ReadChannelMessageContentsResult{Channel: channel}, nil } if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid } wanted := make(map[int]struct{}, len(req.IDs)) for _, id := range req.IDs { if id <= 0 || id > domain.MaxMessageBoxID { return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid } wanted[id] = struct{}{} } messages := make([]domain.ChannelMessage, 0, len(wanted)) for _, msg := range s.messages[req.ChannelID] { if _, ok := wanted[msg.ID]; !ok { continue } if msg.Deleted || msg.ID <= member.AvailableMinID { continue } messages = append(messages, cloneChannelMessage(msg)) } sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) clearedSet := make(map[int]struct{}) for _, msg := range messages { byUser := s.reactions[req.ChannelID][msg.ID] if len(byUser) == 0 { continue } for reactedUserID, rows := range byUser { changed := false for i := range rows { if rows[i].SenderUserID == req.UserID && rows[i].UserID != req.UserID && rows[i].Unread { rows[i].Unread = false changed = true clearedSet[msg.ID] = struct{}{} } } if changed { byUser[reactedUserID] = rows } } } cleared := make([]int, 0, len(clearedSet)) for id := range clearedSet { cleared = append(cleared, id) } sort.Sort(sort.Reverse(sort.IntSlice(cleared))) if len(cleared) > 0 { s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) } // 视口内容已读同步翻转 mention 未读:客户端本地已减计数,服务端 // 不落库角标会在下一次 getDialogs 复活。 clearedMentions := make([]int, 0, len(messages)) mentionFlipped := false for _, msg := range messages { mention, ok := s.mentions[req.UserID][req.ChannelID][msg.ID] if !ok || !mention.unread { continue } mention.unread = false s.mentions[req.UserID][req.ChannelID][msg.ID] = mention clearedMentions = append(clearedMentions, msg.ID) mentionFlipped = true } sort.Ints(clearedMentions) if mentionFlipped { if dialogs := s.dialogs[req.UserID]; dialogs != nil { dialog := dialogs[req.ChannelID] dialog.UserID = req.UserID dialog.ChannelID = req.ChannelID dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0) dialogs[req.ChannelID] = dialog } } s.populateChannelMessageRepliesLocked(req.UserID, req.ChannelID, messages) s.populateChannelMessageReactionsLocked(req.UserID, channel, messages) return domain.ReadChannelMessageContentsResult{ Channel: channel, Messages: messages, ClearedUnreadReactionMessageIDs: cleared, ClearedUnreadMentionMessageIDs: clearedMentions, }, nil } func (s *ChannelStore) ListChannelUnreadMentions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { s.mu.RLock() defer s.mu.RUnlock() channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } limit := filter.Limit if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { limit = domain.MaxChannelUnreadMentionsLimit } filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) base := make([]domain.ChannelMessage, 0, limit) for msgID, mention := range s.mentions[viewerUserID][filter.ChannelID] { if !mention.unread { continue } if filter.TopMsgID > 0 && mention.topID != filter.TopMsgID && !(filter.TopMsgID == 1 && mention.topID == 0) { continue } msg, ok := s.findMessageLocked(filter.ChannelID, msgID) if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { continue } if filter.MaxID > 0 && msg.ID >= filter.MaxID { continue } if filter.MinID > 0 && msg.ID <= filter.MinID { continue } base = append(base, msg) } sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ OffsetID: filter.OffsetID, OffsetDate: filter.OffsetDate, AddOffset: filter.AddOffset, Limit: limit, MaxID: filter.MaxID, MinID: filter.MinID, }, limit) out := make([]domain.ChannelMessage, 0, len(page)) for _, msg := range page { out = append(out, cloneChannelMessage(msg)) } s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil } func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelMentionsResult{}, err } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { limit = domain.MaxChannelReadMentionsBatch } msgIDs := make([]int, 0, limit) for msgID, mention := range s.mentions[req.UserID][req.ChannelID] { if !mention.unread { continue } if req.TopMsgID > 0 && mention.topID != req.TopMsgID && !(req.TopMsgID == 1 && mention.topID == 0) { continue } msgIDs = append(msgIDs, msgID) } sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) if len(msgIDs) > limit { msgIDs = msgIDs[:limit] } for _, msgID := range msgIDs { mention := s.mentions[req.UserID][req.ChannelID][msgID] mention.unread = false s.mentions[req.UserID][req.ChannelID][msgID] = mention } remaining := s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, req.TopMsgID) if dialogs := s.dialogs[req.UserID]; dialogs != nil { dialog := dialogs[req.ChannelID] dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0) dialog.UserID = req.UserID dialog.ChannelID = req.ChannelID dialogs[req.ChannelID] = dialog } offset := 0 if remaining > 0 { offset = 1 } return domain.ReadChannelMentionsResult{ Channel: channel, Cleared: len(msgIDs), Remaining: remaining, Offset: offset, ChannelPts: channel.Pts, }, nil } func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelHistoryResult{}, err } maxID := req.MaxID if maxID <= 0 || maxID > channel.TopMessageID { maxID = channel.TopMessageID } member := s.members[req.ChannelID][req.UserID] previous := member.ReadInboxMaxID changed := maxID > member.ReadInboxMaxID var outboxUpdates []domain.ChannelReadOutboxUpdate if changed { member.ReadInboxMaxID = maxID member.ReadInboxDate = req.Date member.UnreadMark = false s.members[req.ChannelID][req.UserID] = member s.readMarks[req.ChannelID] = s.readMarks[req.ChannelID].advance(req.UserID, maxID) outboxUpdates = s.advanceChannelReadOutboxLocked(req.ChannelID, req.UserID, previous, maxID) } dialog := s.dialogForUserLocked(req.UserID, channel) dialog.ReadInboxMaxID = member.ReadInboxMaxID dialog.UnreadCount = s.channelUnreadCountLocked(req.UserID, channel.ID, member.ReadInboxMaxID, dialog.TopMessageID) dialog.UnreadMark = false if s.dialogs[req.UserID] == nil { s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) } s.dialogs[req.UserID][req.ChannelID] = dialog return domain.ReadChannelHistoryResult{ ChannelID: req.ChannelID, MaxID: maxID, StillUnreadCount: dialog.UnreadCount, Changed: changed, Pts: channel.Pts, Forum: channel.Forum, Dialog: dialog, OutboxUpdates: outboxUpdates, }, nil } func (s *ChannelStore) advanceChannelReadOutboxLocked(channelID, readerUserID int64, previous, maxID int) []domain.ChannelReadOutboxUpdate { if maxID <= previous { return nil } lowerID := previous if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { lowerID = maxID - domain.MaxChannelReadOutboxScanMessages } bySender := make(map[int64]int, domain.MaxChannelReadOutboxFanout) messages := s.messages[channelID] for i := len(messages) - 1; i >= 0; i-- { msg := messages[i] if msg.ID <= lowerID { break } if msg.ID > maxID || msg.Deleted || msg.SenderUserID == 0 || msg.SenderUserID == readerUserID { continue } if _, ok := bySender[msg.SenderUserID]; ok { continue } bySender[msg.SenderUserID] = msg.ID if len(bySender) >= domain.MaxChannelReadOutboxFanout { break } } if len(bySender) == 0 { return nil } senderIDs := make([]int64, 0, len(bySender)) for userID := range bySender { senderIDs = append(senderIDs, userID) } sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] }) channel := s.channels[channelID] out := make([]domain.ChannelReadOutboxUpdate, 0, len(senderIDs)) for _, userID := range senderIDs { maxForSender := bySender[userID] member, ok := s.members[channelID][userID] if !ok || member.Status != domain.ChannelMemberActive || maxForSender <= member.ReadOutboxMaxID { continue } member.ReadOutboxMaxID = maxForSender s.members[channelID][userID] = member dialog := s.dialogForUserLocked(userID, channel) if dialog.ReadOutboxMaxID < maxForSender { dialog.ReadOutboxMaxID = maxForSender } if s.dialogs[userID] == nil { s.dialogs[userID] = make(map[int64]domain.ChannelDialog) } s.dialogs[userID][channelID] = dialog out = append(out, domain.ChannelReadOutboxUpdate{UserID: userID, MaxID: maxForSender}) } return out } func (s *ChannelStore) ListMessageReadParticipants(_ context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { s.mu.RLock() defer s.mu.RUnlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.ChannelReadParticipantsResult{}, err } member := s.members[req.ChannelID][req.UserID] msg, found := s.findMessageLocked(req.ChannelID, req.MessageID) if !found || msg.Deleted || msg.ID <= member.AvailableMinID { return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid } result := domain.ChannelReadParticipantsResult{ Channel: channel, Message: cloneChannelMessage(msg), } if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { return result, nil } now := req.Date if now > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= now { return result, nil } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelReadParticipants { limit = domain.MaxChannelReadParticipants } for _, reader := range s.members[req.ChannelID] { if reader.UserID == req.UserID || reader.Status != domain.ChannelMemberActive || reader.BannedRights.ViewMessages { continue } if reader.ReadInboxDate <= 0 { continue } if reader.AvailableMinID >= req.MessageID || reader.ReadInboxMaxID < req.MessageID { continue } result.Participants = append(result.Participants, domain.ChannelReadParticipant{ UserID: reader.UserID, Date: reader.ReadInboxDate, }) if len(result.Participants) >= limit { break } } sort.Slice(result.Participants, func(i, j int) bool { if result.Participants[i].Date == result.Participants[j].Date { return result.Participants[i].UserID < result.Participants[j].UserID } return result.Participants[i].Date < result.Participants[j].Date }) return result, nil } func (s *ChannelStore) channelThreadUnreadCountLocked(viewerUserID, channelID int64, rootID, readMaxID int) int { unread := 0 for _, msg := range s.messages[channelID] { if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID { continue } if channelReplyBelongsToRoot(msg, channelID, rootID) { unread++ // 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。 if unread >= domain.MaxDialogUnreadCount { break } } } return unread } func (s *ChannelStore) channelUnreadCountLocked(viewerUserID, channelID int64, readMaxID, topID int) int { if viewerUserID == 0 || channelID == 0 || topID <= readMaxID { return 0 } unread := 0 for _, msg := range s.messages[channelID] { if msg.Deleted || msg.ID <= readMaxID || msg.ID > topID || msg.SenderUserID == viewerUserID { continue } unread++ // 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。 if unread >= domain.MaxDialogUnreadCount { break } } return unread } func (s *ChannelStore) addChannelUnreadMentionsLocked(channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) { if len(userIDs) == 0 || msg.ID == 0 { return } seen := make(map[int64]struct{}, len(userIDs)) written := 0 topID := channelMentionTopID(msg) for _, userID := range userIDs { if userID == 0 || userID == senderUserID { continue } if _, ok := seen[userID]; ok { continue } seen[userID] = struct{}{} member, ok := s.members[channelID][userID] if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { continue } if msg.ID <= member.AvailableMinID || msg.ID <= member.ReadInboxMaxID { continue } if s.mentions[userID] == nil { s.mentions[userID] = make(map[int64]map[int]memoryMention) } if s.mentions[userID][channelID] == nil { s.mentions[userID][channelID] = make(map[int]memoryMention) } s.mentions[userID][channelID][msg.ID] = memoryMention{topID: topID, unread: true} written++ if written == domain.MaxChannelMentionRecipients { return } } } func (s *ChannelStore) countChannelUnreadMentionsLocked(userID, channelID int64, topMsgID int) int { count := 0 for _, mention := range s.mentions[userID][channelID] { if !mention.unread { continue } if topMsgID == 0 || mention.topID == topMsgID || (topMsgID == 1 && mention.topID == 0) { count++ } } return count } func (s *ChannelStore) deleteChannelUnreadMentionsLocked(channelID int64, ids []int) { if len(ids) == 0 { return } set := make(map[int]struct{}, len(ids)) for _, id := range ids { set[id] = struct{}{} } for userID, byChannel := range s.mentions { mentions := byChannel[channelID] if len(mentions) == 0 { continue } for id := range set { delete(mentions, id) } if len(mentions) == 0 { delete(byChannel, channelID) } if len(byChannel) == 0 { delete(s.mentions, userID) } } } func (s *ChannelStore) deleteChannelUnreadMentionsUpToLocked(userID, channelID int64, maxID int) { if maxID <= 0 || len(s.mentions[userID][channelID]) == 0 { return } for id := range s.mentions[userID][channelID] { if id <= maxID { delete(s.mentions[userID][channelID], id) } } if len(s.mentions[userID][channelID]) == 0 { delete(s.mentions[userID], channelID) } if len(s.mentions[userID]) == 0 { delete(s.mentions, userID) } } func channelMentionTopID(msg domain.ChannelMessage) int { if msg.ReplyTo == nil { return 0 } if msg.ReplyTo.TopMessageID > 0 { return msg.ReplyTo.TopMessageID } return msg.ReplyTo.MessageID } func (s *ChannelStore) populateChannelMessageUnreadFlagsLocked(viewerUserID int64, messages []domain.ChannelMessage) { if viewerUserID == 0 || len(messages) == 0 { return } for i := range messages { if messages[i].ChannelID == 0 || messages[i].ID <= 0 { continue } mention, ok := s.mentions[viewerUserID][messages[i].ChannelID][messages[i].ID] if !ok { continue } messages[i].Mentioned = true messages[i].MediaUnread = mention.unread } } // clearChannelMentionsForUserLocked 在离开/被踢时清空该用户的提及状态。 func (s *ChannelStore) clearChannelMentionsForUserLocked(channelID, userID int64) { if byChannel := s.mentions[userID]; byChannel != nil { delete(byChannel, channelID) if len(byChannel) == 0 { delete(s.mentions, userID) } } if dialogs := s.dialogs[userID]; dialogs != nil { if dialog, ok := dialogs[channelID]; ok { dialog.UnreadMentions = 0 dialogs[channelID] = dialog } } }