fix(channels): sync preserve locally cleared dialogs

This commit is contained in:
iamxvbaba 2026-07-29 16:19:04 +08:00
parent e22fac1c3f
commit 73cf6a8184
50 changed files with 1525 additions and 327 deletions

View file

@ -2948,7 +2948,7 @@ CREATE TABLE public.user_update_events (
story_payload jsonb DEFAULT '{}'::jsonb NOT NULL, story_payload jsonb DEFAULT '{}'::jsonb NOT NULL,
reaction_payload jsonb DEFAULT '{}'::jsonb NOT NULL, reaction_payload jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT user_update_events_peer_type_check CHECK (((peer_type IS NULL) OR ((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), CONSTRAINT user_update_events_peer_type_check CHECK (((peer_type IS NULL) OR ((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))),
CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_available_messages'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text]))) CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text])))
); );

View file

@ -7,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop' 'sent_story_reaction', 'new_story_reaction', 'noop'
]::text[]) ]::text[])

View file

@ -12,7 +12,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -7,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -10,7 +10,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -6,7 +6,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox', 'sent_story_reaction', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',
'read_channel_discussion_outbox' 'read_channel_discussion_outbox'

View file

@ -9,7 +9,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',

View file

@ -23,7 +23,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',

View file

@ -50,7 +50,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages', 'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -9,7 +9,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages', 'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -12,7 +12,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status',
'delete_messages', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -0,0 +1,4 @@
ALTER TABLE channel_members
DROP CONSTRAINT IF EXISTS channel_members_history_clear_anchor_check,
DROP COLUMN IF EXISTS history_clear_anchor_date,
DROP COLUMN IF EXISTS history_clear_anchor_id;

View file

@ -0,0 +1,14 @@
ALTER TABLE channel_members
ADD COLUMN history_clear_anchor_id integer DEFAULT 0 NOT NULL,
ADD COLUMN history_clear_anchor_date integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT channel_members_history_clear_anchor_check CHECK (
(
history_clear_anchor_id = 0
AND history_clear_anchor_date = 0
)
OR (
history_clear_anchor_id > 0
AND history_clear_anchor_date > 0
AND history_clear_anchor_id <= available_min_id
)
);

View file

@ -0,0 +1,7 @@
DROP INDEX IF EXISTS user_channel_member_index_history_clear_idx;
ALTER TABLE user_channel_member_index
DROP CONSTRAINT IF EXISTS user_channel_member_index_history_clear_check,
DROP COLUMN IF EXISTS history_clear_updated_at,
DROP COLUMN IF EXISTS history_clear_anchor_id,
DROP COLUMN IF EXISTS available_min_id;

View file

@ -0,0 +1,22 @@
ALTER TABLE user_channel_member_index
ADD COLUMN available_min_id integer DEFAULT 0 NOT NULL,
ADD COLUMN history_clear_anchor_id integer DEFAULT 0 NOT NULL,
ADD COLUMN history_clear_updated_at integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT user_channel_member_index_history_clear_check CHECK (
(
history_clear_anchor_id = 0
AND history_clear_updated_at = 0
)
OR (
history_clear_anchor_id > 0
AND history_clear_updated_at > 0
AND history_clear_anchor_id <= available_min_id
)
);
CREATE INDEX user_channel_member_index_history_clear_idx
ON user_channel_member_index (user_id, channel_id)
INCLUDE (available_min_id, history_clear_updated_at)
WHERE status = 'active'
AND NOT deleted
AND history_clear_anchor_id > 0;

View file

@ -2301,7 +2301,16 @@ func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.Ch
if err != nil { if err != nil {
return domain.ChannelDifference{}, err return domain.ChannelDifference{}, err
} }
return s.filterBotChannelDifference(ctx, userID, diff), nil diff = s.filterBotChannelDifference(ctx, userID, diff)
if diff.Self.HistoryClearAnchorID > 0 &&
diff.Self.HistoryClearAnchorID == diff.Self.AvailableMinID &&
!diff.TooLong {
// updateChannelAvailableMessages is an absolute no-PTS update. Keep it
// outside ChannelUpdateEvent so channel difference continuity remains
// defined solely by real channel events.
diff.AvailableMinID = diff.Self.AvailableMinID
}
return diff, nil
} }
// ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值(unpinAll 自愈)。 // ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值(unpinAll 自愈)。

View file

@ -413,6 +413,8 @@ func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
FolderID: dialog.FolderID, FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID, TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate, TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID, ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount, UnreadCount: dialog.UnreadCount,

View file

@ -731,16 +731,6 @@ func (s *Service) RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte,
}, true, excludeSessionID) }, true, excludeSessionID)
} }
// RecordChannelAvailableMessages records a local channel history clear for multi-device sync.
func (s *Service) RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelAvailable,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
PtsCount: 1,
}, true, excludeSessionID)
}
func (s *Service) recordEvent(ctx context.Context, stateAuthKeyID, excludeAuthKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { func (s *Service) recordEvent(ctx context.Context, stateAuthKeyID, excludeAuthKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEventCore(ctx, stateAuthKeyID, excludeAuthKeyID, userID, event, dispatch, excludeSessionID, true) return s.recordEventCore(ctx, stateAuthKeyID, excludeAuthKeyID, userID, event, dispatch, excludeSessionID, true)
} }

View file

@ -497,6 +497,12 @@ type ChannelMember struct {
Rank string Rank string
AvailableMinID int AvailableMinID int
AvailableMinPts int AvailableMinPts int
// HistoryClearAnchorID/Date identify an owner-local channels.deleteHistory
// boundary. They are deliberately separate from AvailableMinID: joining a
// hidden-prehistory channel also advances AvailableMinID but must not
// manufacture a "History cleared" service message.
HistoryClearAnchorID int
HistoryClearAnchorDate int
ReadInboxMaxID int ReadInboxMaxID int
ReadInboxDate int ReadInboxDate int
ReadOutboxMaxID int ReadOutboxMaxID int
@ -550,6 +556,8 @@ type ChannelDialog struct {
FolderID int FolderID int
TopMessageID int TopMessageID int
TopMessageDate int TopMessageDate int
HistoryClearAnchorID int
HistoryClearAnchorDate int
ReadInboxMaxID int ReadInboxMaxID int
ReadOutboxMaxID int ReadOutboxMaxID int
UnreadCount int UnreadCount int
@ -573,6 +581,10 @@ const (
ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user" ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user"
ChannelActionChatEditPhoto ChannelMessageActionType = "chat_edit_photo" ChannelActionChatEditPhoto ChannelMessageActionType = "chat_edit_photo"
ChannelActionChatDeletePhoto ChannelMessageActionType = "chat_delete_photo" ChannelActionChatDeletePhoto ChannelMessageActionType = "chat_delete_photo"
// ChannelActionHistoryClear is an owner-local projection at the member's
// channels.deleteHistory boundary. It is never persisted into the shared
// channel_messages row and never produces channel PTS.
ChannelActionHistoryClear ChannelMessageActionType = "history_clear"
ChannelActionChatJoined ChannelMessageActionType = "chat_joined" ChannelActionChatJoined ChannelMessageActionType = "chat_joined"
// ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息, // ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息,
// 渲染为 "X joined the group via invite link"。 // 渲染为 "X joined the group via invite link"。
@ -710,6 +722,39 @@ type ChannelMessage struct {
Deleted bool Deleted bool
} }
// ProjectChannelHistoryClearMessage returns the owner-local service-message
// projection for one channel history boundary. Identity fields from the shared
// source are retained when available, while all user payload, media, reply,
// reaction, pin, TTL and edit state is removed. A zero source is valid for a
// retained anchor whose shared row has already been physically pruned.
func ProjectChannelHistoryClearMessage(source ChannelMessage, channelID int64, messageID, messageDate int) ChannelMessage {
if source.ChannelID == 0 {
source.ChannelID = channelID
}
if source.ID == 0 {
source.ID = messageID
}
if source.Date == 0 {
source.Date = messageDate
}
return ChannelMessage{
ChannelID: source.ChannelID,
ID: source.ID,
SenderUserID: source.SenderUserID,
From: source.From,
Date: source.Date,
Post: source.Post,
Silent: source.Silent,
Action: &ChannelMessageAction{Type: ChannelActionHistoryClear},
}
}
// IsChannelHistoryClearMessage reports whether msg is an owner-local history
// clear projection rather than a shared channel service message.
func IsChannelHistoryClearMessage(msg ChannelMessage) bool {
return msg.Action != nil && msg.Action.Type == ChannelActionHistoryClear
}
// MessageReactionType identifies one stored reaction constructor without depending on TL types. // MessageReactionType identifies one stored reaction constructor without depending on TL types.
type MessageReactionType string type MessageReactionType string
@ -1290,6 +1335,10 @@ type ChannelDifference struct {
Events []ChannelUpdateEvent Events []ChannelUpdateEvent
NewMessages []ChannelMessage NewMessages []ChannelMessage
OtherUpdates []ChannelUpdateEvent OtherUpdates []ChannelUpdateEvent
// AvailableMinID is an owner-local, absolute no-PTS boundary appended to a
// normal channel difference. It must never be represented as a synthetic
// ChannelUpdateEvent because updateChannelAvailableMessages has no pts.
AvailableMinID int
Users []User Users []User
Channels []Channel Channels []Channel
Pts int Pts int
@ -1299,10 +1348,14 @@ type ChannelDifference struct {
Timeout int Timeout int
} }
// DirtyChannel identifies an active channel with channel-scoped updates after an account difference date. // DirtyChannel identifies an active channel with shared channel updates and/or
// an owner-local no-PTS history-clear boundary after an account difference date.
type DirtyChannel struct { type DirtyChannel struct {
ChannelID int64 ChannelID int64
Pts int Pts int
ChannelUpdatesDirty bool
AvailableMinID int
HistoryClearDate int
} }
// ChannelUpdateRetentionCheckpoint is the durable recovery boundary for one channel. // ChannelUpdateRetentionCheckpoint is the durable recovery boundary for one channel.
@ -1980,6 +2033,9 @@ type DeleteChannelHistoryResult struct {
Recipients []int64 Recipients []int64
Offset int Offset int
AvailableMinID int AvailableMinID int
// AvailableMinChanged is true only when this owner-local request advanced
// the member boundary and installed a new history-clear anchor.
AvailableMinChanged bool
} }
// UpdateChannelPinnedMessageRequest pins or unpins one channel/supergroup message. // UpdateChannelPinnedMessageRequest pins or unpins one channel/supergroup message.
@ -2192,6 +2248,10 @@ type ChannelHistoryFilter struct {
SenderUserID int64 SenderUserID int64
PinnedOnly bool PinnedOnly bool
MusicOnly bool MusicOnly bool
// IncludeHistoryClearAnchor is set only by messages.getHistory. Search,
// media and topic projections must not count the owner-local service marker
// as shared channel content.
IncludeHistoryClearAnchor bool
// NeedTotalCount requests the exact number of messages matching the static // NeedTotalCount requests the exact number of messages matching the static
// filters before offset/add_offset pagination. Ordinary history pages leave // filters before offset/add_offset pagination. Ordinary history pages leave
// this false and keep the bounded len(page)+has-more hint. // this false and keep the bounded len(page)+has-more hint.

View file

@ -74,6 +74,11 @@ type Dialog struct {
FolderID int FolderID int
TopMessage int TopMessage int
TopMessageDate int TopMessageDate int
// HistoryClearAnchorID/Date are internal owner-view projection metadata.
// They are not TL dialog fields; dialog response assembly uses them to
// materialize the matching messageActionHistoryClear top payload.
HistoryClearAnchorID int
HistoryClearAnchorDate int
ReadInboxMaxID int ReadInboxMaxID int
ReadOutboxMaxID int ReadOutboxMaxID int
UnreadCount int UnreadCount int

View file

@ -44,7 +44,6 @@ const (
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order" UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
UpdateEventDialogFilters UpdateEventType = "dialog_filters" UpdateEventDialogFilters UpdateEventType = "dialog_filters"
UpdateEventFolderPeers UpdateEventType = "folder_peers" UpdateEventFolderPeers UpdateEventType = "folder_peers"
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages" UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
UpdateEventStory UpdateEventType = "story" UpdateEventStory UpdateEventType = "story"
UpdateEventReadStories UpdateEventType = "read_stories" UpdateEventReadStories UpdateEventType = "read_stories"
@ -141,7 +140,6 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventDialogFilter, UpdateEventDialogFilter,
UpdateEventDialogFilterOrder, UpdateEventDialogFilterOrder,
UpdateEventDialogFilters, UpdateEventDialogFilters,
UpdateEventChannelAvailable,
UpdateEventChannelViewForum, UpdateEventChannelViewForum,
UpdateEventStory, UpdateEventStory,
UpdateEventReadStories, UpdateEventReadStories,
@ -180,5 +178,8 @@ type UpdateDifference struct {
type ChannelDifferenceNudge struct { type ChannelDifferenceNudge struct {
ChannelID int64 ChannelID int64
Pts int Pts int
ChannelUpdatesDirty bool
AvailableMinID int
HistoryClearDate int
Channel *ChannelView Channel *ChannelView
} }

View file

@ -394,10 +394,14 @@ func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDe
return nil, channelDeleteErr(err) return nil, channelDeleteErr(err)
} }
if res.Event.Pts == 0 { if res.Event.Pts == 0 {
event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) if res.AvailableMinChanged {
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event) // updateChannelAvailableMessages is an absolute owner-local boundary
// with no account/channel pts. Other online sessions consume it
// immediately; future cold/offline sessions discover the same
// boundary through account/channel difference recovery.
r.pushUserUpdates(ctx, userID, updates) r.pushUserUpdates(ctx, userID, updates)
}
return updates, nil return updates, nil
} }
pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates { pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates {

View file

@ -174,6 +174,7 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
r := New(Config{}, Deps{ r := New(Config{}, Deps{
Users: appusers.NewService(userStore), Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore), Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Updates: updateSvc, Updates: updateSvc,
Sessions: sessions, Sessions: sessions,
}, zaptest.NewLogger(t), clock.System) }, zaptest.NewLogger(t), clock.System)
@ -193,7 +194,14 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
if err != nil { if err != nil {
t.Fatalf("send channel message: %v", err) t.Fatalf("send channel message: %v", err)
} }
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) newChannelUpdate := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
msg := newChannelUpdate.Message.(*tg.Message)
clearSince := int(time.Now().Unix()) - 1
stateBefore, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
pushesBefore := len(sessions.pushedUserIDs())
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID, MaxID: msg.ID,
@ -202,42 +210,144 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
t.Fatalf("delete channel history local: %v", err) t.Fatalf("delete channel history local: %v", err)
} }
updates, ok := cleared.(*tg.Updates) updates, ok := cleared.(*tg.Updates)
if !ok || len(updates.Updates) != 2 { if !ok || len(updates.Updates) != 1 {
t.Fatalf("clear response = %T %+v, want available update plus pts bookkeeping", cleared, cleared) t.Fatalf("clear response = %T %+v, want one no-pts available update", cleared, cleared)
} }
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages) available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID { if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID {
t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID) t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID)
} }
// updateChannelAvailableMessages 不带账号 pts,事件占用的 pts 槽位 stateAfter, err := updateSvc.CurrentState(ctx, owner.ID)
// 必须用空 updateDeleteMessages 显式同步给客户端。 if err != nil {
bookkeeping, ok := updates.Updates[1].(*tg.UpdateDeleteMessages) t.Fatalf("account state after clear: %v", err)
if !ok || len(bookkeeping.Messages) != 0 || bookkeeping.Pts <= 0 || bookkeeping.PtsCount != 1 { }
t.Fatalf("clear bookkeeping = %#v, want empty updateDeleteMessages carrying the account pts step", updates.Updates[1]) if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("account pts advanced on local channel clear: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
} }
pushed := sessions.snapshot() pushed := sessions.snapshot()
if pushed.userID != owner.ID { if pushed.userID != owner.ID {
t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID) t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID)
} }
pushedUpdates, ok := pushed.message.(*tg.Updates) pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Updates) != 2 { if !ok || len(pushedUpdates.Updates) != 1 {
t.Fatalf("pushed clear update = %T %+v, want available update plus pts bookkeeping", pushed.message, pushed.message) t.Fatalf("pushed clear update = %T %+v, want one no-pts available update", pushed.message, pushed.message)
} }
if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok { if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok {
t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0]) t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0])
} }
diff, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) if got := len(sessions.pushedUserIDs()) - pushesBefore; got != 1 {
t.Fatalf("clear online pushes = %d, want exactly one owner-session fanout", got)
}
diff, err := updateSvc.GetDifference(ctx, [8]byte{}, owner.ID, stateBefore)
if err != nil { if err != nil {
t.Fatalf("get difference: %v", err) t.Fatalf("get difference: %v", err)
} }
full, ok := diff.(*tg.UpdatesDifference) if len(diff.Events) != 0 || diff.State.Pts != stateBefore.Pts {
if !ok || len(full.OtherUpdates) != 1 { t.Fatalf("difference after no-pts clear = %+v, want no durable account event", diff)
t.Fatalf("difference = %T %+v, want one other update", diff, diff)
} }
if diffUpdate, ok := full.OtherUpdates[0].(*tg.UpdateChannelAvailableMessages); !ok || diffUpdate.ChannelID != channel.ID || diffUpdate.AvailableMinID != msg.ID { offline, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{
t.Fatalf("difference update = %#v, want updateChannelAvailableMessages", full.OtherUpdates[0]) Pts: stateBefore.Pts,
Date: clearSince,
})
if err != nil {
t.Fatalf("account difference after offline local clear: %v", err)
}
offlineDiff, ok := offline.(*tg.UpdatesDifference)
if !ok {
t.Fatalf("offline account difference = %T %+v, want updates.difference", offline, offline)
}
var accountAvailable *tg.UpdateChannelAvailableMessages
for _, update := range offlineDiff.OtherUpdates {
if value, ok := update.(*tg.UpdateChannelAvailableMessages); ok {
accountAvailable = value
break
}
}
if accountAvailable == nil ||
accountAvailable.ChannelID != channel.ID ||
accountAvailable.AvailableMinID != msg.ID {
t.Fatalf("offline account updates = %+v, want updateChannelAvailableMessages channel=%d min=%d",
offlineDiff.OtherUpdates, channel.ID, msg.ID)
}
if offlineDiff.State.Pts != stateBefore.Pts || offlineDiff.State.Date < clearSince {
t.Fatalf("offline account state = %+v, want unchanged pts=%d and non-regressing date", offlineDiff.State, stateBefore.Pts)
} }
channelOffline, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: newChannelUpdate.Pts,
Limit: 100,
})
if err != nil {
t.Fatalf("channel difference after offline local clear: %v", err)
}
channelDiff, ok := channelOffline.(*tg.UpdatesChannelDifference)
if !ok {
t.Fatalf("offline channel difference = %T %+v, want non-empty difference with absolute update", channelOffline, channelOffline)
}
var channelAvailable *tg.UpdateChannelAvailableMessages
for _, update := range channelDiff.OtherUpdates {
if value, ok := update.(*tg.UpdateChannelAvailableMessages); ok {
channelAvailable = value
break
}
}
if channelAvailable == nil ||
channelAvailable.ChannelID != channel.ID ||
channelAvailable.AvailableMinID != msg.ID ||
channelDiff.Pts != newChannelUpdate.Pts {
t.Fatalf("offline channel updates = %+v pts=%d, want available boundary %d with unchanged channel pts=%d",
channelDiff.OtherUpdates, channelDiff.Pts, msg.ID, newChannelUpdate.Pts)
}
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs after clear: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs after clear: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogs)
if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Messages) != 1 {
t.Fatalf("dialogs after cold projection = %T %+v, want one anchored dialog/message", enc, enc)
}
dialog := dialogs.Dialogs[0].(*tg.Dialog)
service, ok := dialogs.Messages[0].(*tg.MessageService)
if !ok || dialog.TopMessage != msg.ID || service.ID != msg.ID {
t.Fatalf("cold dialog projection = dialog=%+v message=%T %+v, want history-clear top %d", dialog, dialogs.Messages[0], dialogs.Messages[0], msg.ID)
}
if _, ok := service.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold dialog service action = %T, want messageActionHistoryClear", service.Action)
}
historyReq := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 20,
}
b.Reset()
if err := historyReq.Encode(&b); err != nil {
t.Fatalf("encode get history after clear: %v", err)
}
historyEnc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get history after clear: %v", err)
}
history, ok := historyEnc.(*tg.MessagesChannelMessages)
if !ok || len(history.Messages) != 1 {
t.Fatalf("history after cold projection = %T %+v, want one history-clear marker", historyEnc, historyEnc)
}
historyService, ok := history.Messages[0].(*tg.MessageService)
if !ok || historyService.ID != msg.ID {
t.Fatalf("cold history projection = %T %+v, want service marker %d", history.Messages[0], history.Messages[0], msg.ID)
}
if _, ok := historyService.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold history service action = %T, want messageActionHistoryClear", historyService.Action)
}
stalePushesBefore := len(sessions.pushedUserIDs())
stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID - 1, MaxID: msg.ID - 1,
@ -246,13 +356,16 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
t.Fatalf("stale delete channel history local: %v", err) t.Fatalf("stale delete channel history local: %v", err)
} }
staleUpdates, ok := stale.(*tg.Updates) staleUpdates, ok := stale.(*tg.Updates)
if !ok || len(staleUpdates.Updates) != 2 { if !ok || len(staleUpdates.Updates) != 1 {
t.Fatalf("stale clear response = %T %+v, want monotonic update plus pts bookkeeping", stale, stale) t.Fatalf("stale clear response = %T %+v, want monotonic absolute update", stale, stale)
} }
staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages) staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID { if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID {
t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID) t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID)
} }
if got := len(sessions.pushedUserIDs()); got != stalePushesBefore {
t.Fatalf("stale clear pushed another online update: before=%d after=%d", stalePushesBefore, got)
}
} }
func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) { func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) {

View file

@ -290,27 +290,6 @@ func peerIDsExcept(ids []int64, skipIDs ...int64) []int64 {
type channelFanoutScope int type channelFanoutScope int
func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, channelID int64, availableMinID int) domain.UpdateEvent {
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventChannelAvailable,
Date: int(r.clock.Now().Unix()),
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
PtsCount: 1,
}
if r.deps.Updates == nil || userID == 0 || channelID == 0 || availableMinID <= 0 {
return event
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return event
}
return recorded
}
func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) { func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) {
if !read.Changed || read.ChannelID == 0 { if !read.Changed || read.ChannelID == 0 {
return domain.UpdateEvent{}, nil return domain.UpdateEvent{}, nil

View file

@ -222,6 +222,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
switch action.Type { switch action.Type {
case domain.ChannelActionCreate: case domain.ChannelActionCreate:
return &tg.MessageActionChannelCreate{Title: action.Title} return &tg.MessageActionChannelCreate{Title: action.Title}
case domain.ChannelActionHistoryClear:
return &tg.MessageActionHistoryClear{}
case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined: case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined:
return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)} return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)}
case domain.ChannelActionChatJoinedByLink: case domain.ChannelActionChatJoinedByLink:

View file

@ -55,11 +55,23 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
if nudge.ChannelID == 0 { if nudge.ChannelID == 0 {
continue continue
} }
if nudge.AvailableMinID > 0 {
out.OtherUpdates = append(out.OtherUpdates, &tg.UpdateChannelAvailableMessages{
ChannelID: nudge.ChannelID,
AvailableMinID: nudge.AvailableMinID,
})
}
// Preserve compatibility for older domain callers that only supplied
// Pts: a nudge without an owner-local boundary is a shared channel
// update nudge. New store results set ChannelUpdatesDirty explicitly so
// a channel can carry both absolute clear and too-long updates.
if nudge.ChannelUpdatesDirty || nudge.AvailableMinID == 0 {
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID} update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 { if nudge.Pts > 0 {
update.SetPts(nudge.Pts) update.SetPts(nudge.Pts)
} }
out.OtherUpdates = append(out.OtherUpdates, update) out.OtherUpdates = append(out.OtherUpdates, update)
}
if nudge.Channel != nil && nudge.Channel.Channel.ID != 0 { if nudge.Channel != nil && nudge.Channel.Channel.ID != 0 {
addChannelNudgeChat(out, seenChats, tgChannelChatForView(viewerUserID, *nudge.Channel)) addChannelNudgeChat(out, seenChats, tgChannelChatForView(viewerUserID, *nudge.Channel))
} }
@ -110,7 +122,7 @@ func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.U
Users: tgUsersForViewer(viewerUserID, diff.Users), Users: tgUsersForViewer(viewerUserID, diff.Users),
} }
} }
if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 { if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 && diff.AvailableMinID == 0 {
return &tg.UpdatesChannelDifferenceEmpty{ return &tg.UpdatesChannelDifferenceEmpty{
Final: diff.Final, Final: diff.Final,
Pts: diff.Pts, Pts: diff.Pts,
@ -136,6 +148,12 @@ func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.U
updates = append(updates, update) updates = append(updates, update)
} }
} }
if diff.AvailableMinID > 0 {
updates = append(updates, &tg.UpdateChannelAvailableMessages{
ChannelID: diff.Channel.ID,
AvailableMinID: diff.AvailableMinID,
})
}
chats := tgChannelDifferenceChats(viewerUserID, diff) chats := tgChannelDifferenceChats(viewerUserID, diff)
users := tgUsersForViewer(viewerUserID, diff.Users) users := tgUsersForViewer(viewerUserID, diff.Users)
return &tg.UpdatesChannelDifference{ return &tg.UpdatesChannelDifference{
@ -483,14 +501,6 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
Pts: event.Pts, Pts: event.Pts,
PtsCount: event.PtsCount, PtsCount: event.PtsCount,
} }
case domain.UpdateEventChannelAvailable:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 || event.MaxID <= 0 {
return nil
}
return &tg.UpdateChannelAvailableMessages{
ChannelID: event.Peer.ID,
AvailableMinID: event.MaxID,
}
default: default:
return nil return nil
} }

View file

@ -496,7 +496,6 @@ type UpdatesService interface {
RecordDialogFilterOrder(ctx context.Context, stateAuthKeyID [8]byte, userID int64, order []int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDialogFilterOrder(ctx context.Context, stateAuthKeyID [8]byte, userID int64, order []int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFiltersReload(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDialogFiltersReload(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelViewForumAsMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordChannelViewForumAsMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelDiscussionInbox(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordChannelDiscussionInbox(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)

View file

@ -74,16 +74,13 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
}) })
return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil
} }
if res.AvailableMinID > 0 { if res.AvailableMinChanged && res.AvailableMinID > 0 {
event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID)
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event)
r.pushUserUpdates(ctx, userID, updates) r.pushUserUpdates(ctx, userID, updates)
if event.Pts != 0 {
return &tg.MessagesAffectedHistory{Pts: event.Pts, PtsCount: event.PtsCount, Offset: res.Offset}, nil
} }
} // messages.affectedHistory.pts is the caller's account-state snapshot.
return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil // The local channel clear itself consumes no account/channel pts.
return r.affectedHistory(ctx, authKeyID, userID, res.Offset)
} }
if peer.Type != domain.PeerTypeUser { if peer.Type != domain.PeerTypeUser {
return nil, peerIDInvalidErr() return nil, peerIDInvalidErr()

View file

@ -7,11 +7,74 @@ import (
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels" appchannels "telesrv/internal/app/channels"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store/memory" "telesrv/internal/store/memory"
"testing" "testing"
) )
func TestMessagesDeleteHistoryChannelLocalClearReturnsAccountStateWithoutPTS(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 7,
Title: "local clear no pts",
Megagroup: true,
Date: 1_700_002_100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 7,
ChannelID: created.Channel.ID,
RandomID: 210_001,
Message: "clear locally",
Date: 1_700_002_101,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
stateBefore, err := updateSvc.CurrentState(ctx, 7)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
sessions := &captureSessions{}
r := New(Config{}, Deps{
Channels: appchannels.NewService(channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
affected, err := r.onMessagesDeleteHistory(WithUserID(ctx, 7), &tg.MessagesDeleteHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
MaxID: sent.Message.ID,
})
if err != nil {
t.Fatalf("messages.deleteHistory local channel: %v", err)
}
if affected.Pts != stateBefore.Pts || affected.PtsCount != 0 || affected.Offset != 0 {
t.Fatalf("affected history = %+v, want account pts=%d pts_count=0 offset=0", affected, stateBefore.Pts)
}
stateAfter, err := updateSvc.CurrentState(ctx, 7)
if err != nil {
t.Fatalf("account state after clear: %v", err)
}
if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("local channel clear advanced account pts: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
}
pushed := sessions.snapshot()
updates, ok := pushed.message.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("pushed local clear = %T %+v, want one available update", pushed.message, pushed.message)
}
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != created.Channel.ID || available.AvailableMinID != sent.Message.ID {
t.Fatalf("available update = %#v, want channel=%d min=%d", updates.Updates[0], created.Channel.ID, sent.Message.ID)
}
}
func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) { func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()

View file

@ -612,6 +612,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
MaxID: filter.MaxID, MaxID: filter.MaxID,
MinID: filter.MinID, MinID: filter.MinID,
Hash: filter.Hash, Hash: filter.Hash,
IncludeHistoryClearAnchor: true,
}) })
if err != nil { if err != nil {
return nil, channelInvalidErr(err) return nil, channelInvalidErr(err)

View file

@ -284,15 +284,6 @@ func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte,
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)}) return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)})
} }
func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelAvailable,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
})
}
func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID) s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{

View file

@ -102,6 +102,21 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
return nil, internalErr() return nil, internalErr()
} }
st.ChannelNudges = r.accountChannelDifferenceNudges(ctx, userID, req.Date) st.ChannelNudges = r.accountChannelDifferenceNudges(ctx, userID, req.Date)
if !st.Partial {
for _, nudge := range st.ChannelNudges {
if nudge.AvailableMinID <= 0 {
continue
}
// No-PTS owner-local clear recovery is date-indexed. Advance only
// on the final account page and never beyond the server clock; the
// indexed query deliberately overlaps equality, so a same-second
// reconnect can receive an idempotent duplicate rather than miss.
if now := int(r.clock.Now().Unix()); now > st.State.Date {
st.State.Date = now
}
break
}
}
// 密聊设备级 qts 消息(独立于账号级 pts 事件):按当前设备 req.Qts 补回。 // 密聊设备级 qts 消息(独立于账号级 pts 事件):按当前设备 req.Qts 补回。
encMsgs, newQts := r.encryptedDifference(ctx, req.Qts) encMsgs, newQts := r.encryptedDifference(ctx, req.Qts)
// 密聊握手/已读状态事件(无 qts):按未投递标记补回 OtherUpdates。 // 密聊握手/已读状态事件(无 qts):按未投递标记补回 OtherUpdates。
@ -154,10 +169,19 @@ func (r *Router) accountChannelDifferenceNudges(ctx context.Context, userID int6
} }
} }
for _, item := range dirty { for _, item := range dirty {
if len(out) >= maxNudges {
break
}
if item.ChannelID == 0 { if item.ChannelID == 0 {
continue continue
} }
nudge := domain.ChannelDifferenceNudge{ChannelID: item.ChannelID, Pts: item.Pts} nudge := domain.ChannelDifferenceNudge{
ChannelID: item.ChannelID,
Pts: item.Pts,
ChannelUpdatesDirty: item.ChannelUpdatesDirty,
AvailableMinID: item.AvailableMinID,
HistoryClearDate: item.HistoryClearDate,
}
if view, ok := viewsByID[item.ChannelID]; ok { if view, ok := viewsByID[item.ChannelID]; ok {
nudge.Channel = &view nudge.Channel = &view
} }

View file

@ -95,8 +95,8 @@ func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64,
out.Dialogs = append(out.Dialogs, dialog) out.Dialogs = append(out.Dialogs, dialog)
channel := s.channels[dialog.Peer.ID] channel := s.channels[dialog.Peer.ID]
out.Channels = append(out.Channels, channel) out.Channels = append(out.Channels, channel)
if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted { if msg, ok := s.channelMessageForMemberLocked(viewerUserID, dialog.Peer.ID, dialog.TopMessage); ok {
out.Messages = append(out.Messages, cloneChannelMessage(msg)) out.Messages = append(out.Messages, msg)
} }
} }
// 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。 // 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。
@ -151,8 +151,8 @@ func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64,
out.Channels = append(out.Channels, cloneChannel(parent)) out.Channels = append(out.Channels, cloneChannel(parent))
} }
} }
if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted { if msg, ok := s.channelMessageForMemberLocked(viewerUserID, channelID, dialog.TopMessage); ok {
out.Messages = append(out.Messages, cloneChannelMessage(msg)) out.Messages = append(out.Messages, msg)
} }
} }
out.Count = len(out.Dialogs) out.Count = len(out.Dialogs)
@ -474,6 +474,8 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberSt
FolderID: dialog.FolderID, FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID, TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate, TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID, ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount, UnreadCount: dialog.UnreadCount,
@ -491,13 +493,22 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberSt
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID { if topMessageID <= member.AvailableMinID {
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0 topMessageID = 0
} }
}
topMessageDate := channel.Date
if topMessageID > 0 && topMessageID == member.HistoryClearAnchorID {
topMessageDate = member.HistoryClearAnchorDate
}
return domain.ChannelDialog{ return domain.ChannelDialog{
UserID: userID, UserID: userID,
ChannelID: channel.ID, ChannelID: channel.ID,
TopMessageID: topMessageID, TopMessageID: topMessageID,
TopMessageDate: channel.Date, TopMessageDate: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
} }

View file

@ -337,9 +337,19 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
if !ok || member.Status != domain.ChannelMemberActive { if !ok || member.Status != domain.ChannelMemberActive {
continue continue
} }
item := domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel) checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if checkpoint.LatestEventDate > sinceDate { if checkpoint.LatestEventDate > sinceDate {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) item.ChannelUpdatesDirty = true
}
if clearDate := s.historyClearDates[channelID][userID]; clearDate >= sinceDate &&
member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
item.AvailableMinID = member.AvailableMinID
item.HistoryClearDate = clearDate
}
if item.ChannelUpdatesDirty || item.AvailableMinID > 0 {
out = append(out, item)
} }
} }
for channelID, channel := range s.channels { for channelID, channel := range s.channels {
@ -356,7 +366,11 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
} }
} }
if !found { if !found {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) out = append(out, domain.DirtyChannel{
ChannelID: channelID,
Pts: channel.Pts,
ChannelUpdatesDirty: true,
})
} }
} }
} }
@ -453,12 +467,16 @@ func (s *ChannelStore) dialogForMemberLocked(userID int64, channel domain.Channe
dialog.UserID = userID dialog.UserID = userID
dialog.ChannelID = channel.ID dialog.ChannelID = channel.ID
dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member) dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member)
dialog.HistoryClearAnchorID = member.HistoryClearAnchorID
dialog.HistoryClearAnchorDate = member.HistoryClearAnchorDate
// TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览 // TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览
// dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。 // dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。
dialog.TopMessageDate = 0 dialog.TopMessageDate = 0
if dialog.TopMessageID > 0 { if dialog.TopMessageID > 0 {
if top, ok := s.findMessageLocked(channel.ID, dialog.TopMessageID); ok { if top, ok := s.channelMessageForMemberLocked(userID, channel.ID, dialog.TopMessageID); ok {
dialog.TopMessageDate = top.Date dialog.TopMessageDate = top.Date
} else if dialog.TopMessageID == member.HistoryClearAnchorID {
dialog.TopMessageDate = member.HistoryClearAnchorDate
} }
} }
if member.ReadInboxMaxID > dialog.ReadInboxMaxID { if member.ReadInboxMaxID > dialog.ReadInboxMaxID {

View file

@ -1107,6 +1107,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
} }
member.AvailableMinID = 0 member.AvailableMinID = 0
member.AvailableMinPts = 0 member.AvailableMinPts = 0
member.HistoryClearAnchorID = 0
member.HistoryClearAnchorDate = 0
member.ReadInboxMaxID = mono.TopMessageID member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false member.UnreadMark = false

View file

@ -72,6 +72,12 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
if err != nil { if err != nil {
return domain.DeleteChannelHistoryResult{}, err return domain.DeleteChannelHistoryResult{}, err
} }
if req.Date <= 0 {
req.Date = channel.Date
if req.Date <= 0 {
req.Date = 1
}
}
maxID := req.MaxID maxID := req.MaxID
if maxID <= 0 || maxID > channel.TopMessageID { if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID maxID = channel.TopMessageID
@ -79,6 +85,22 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
member := s.members[req.ChannelID][req.UserID] member := s.members[req.ChannelID][req.UserID]
if !req.ForEveryone { if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID) appliedMinID := maxInt(member.AvailableMinID, maxID)
changed := appliedMinID > member.AvailableMinID
if changed {
anchorDate := req.Date
if msg, ok := s.findMessageLocked(req.ChannelID, appliedMinID); ok && msg.Date > 0 {
anchorDate = msg.Date
}
if anchorDate <= 0 {
anchorDate = channel.Date
}
member.HistoryClearAnchorID = appliedMinID
member.HistoryClearAnchorDate = anchorDate
if s.historyClearDates[req.ChannelID] == nil {
s.historyClearDates[req.ChannelID] = make(map[int64]int)
}
s.historyClearDates[req.ChannelID][req.UserID] = req.Date
}
member.AvailableMinID = appliedMinID member.AvailableMinID = appliedMinID
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID) member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID)
member.UnreadMark = false member.UnreadMark = false
@ -92,7 +114,11 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog)
} }
s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel) s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel)
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil return domain.DeleteChannelHistoryResult{
Channel: channel,
AvailableMinID: appliedMinID,
AvailableMinChanged: changed,
}, nil
} }
if !canDeleteAnyChannelMessage(member) { if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired

View file

@ -25,8 +25,16 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
query := strings.ToLower(strings.TrimSpace(filter.Query)) query := strings.ToLower(strings.TrimSpace(filter.Query))
matched := make([]domain.ChannelMessage, 0, len(items)) matched := make([]domain.ChannelMessage, 0, len(items))
monoforumUserView := channel.Monoforum && !member.CanManageDirectMessages() monoforumUserView := channel.Monoforum && !member.CanManageDirectMessages()
anchorID := 0
if filter.IncludeHistoryClearAnchor &&
member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
anchorID = member.HistoryClearAnchorID
}
anchorSeen := false
for _, msg := range items { for _, msg := range items {
if msg.Deleted { isAnchor := anchorID > 0 && msg.ID == anchorID
if msg.Deleted && !isAnchor {
continue continue
} }
if channel.Monoforum { if channel.Monoforum {
@ -37,9 +45,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
continue continue
} }
} }
if msg.ID <= member.AvailableMinID { if msg.ID < member.AvailableMinID || (msg.ID == member.AvailableMinID && !isAnchor) {
continue continue
} }
if isAnchor {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
filter.ChannelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
anchorSeen = true
}
if filter.PinnedOnly && !msg.Pinned { if filter.PinnedOnly && !msg.Pinned {
continue continue
} }
@ -66,6 +83,24 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
} }
matched = append(matched, msg) matched = append(matched, msg)
} }
if anchorID > 0 && !anchorSeen {
msg := domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
filter.ChannelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
if (filter.MinDate <= 0 || msg.Date > filter.MinDate) &&
(filter.MaxDate <= 0 || msg.Date < filter.MaxDate) &&
(filter.MaxID <= 0 || msg.ID <= filter.MaxID) &&
(filter.MinID <= 0 || msg.ID > filter.MinID) &&
!filter.PinnedOnly &&
!filter.MusicOnly &&
query == "" &&
filter.SenderUserID == 0 {
matched = append(matched, msg)
}
}
extraChannels := []domain.Channel(nil) extraChannels := []domain.Channel(nil)
if channel.Monoforum && channel.LinkedMonoforumID != 0 { if channel.Monoforum && channel.LinkedMonoforumID != 0 {
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted { if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
@ -179,6 +214,7 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
} }
s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out)
s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) s.populateChannelMessageReactionsLocked(viewerUserID, channel, out)
projectMemoryChannelHistoryClearMessages(channel.ID, member, out)
return domain.ChannelHistory{ return domain.ChannelHistory{
Channel: channel, Channel: channel,
Self: member, Self: member,
@ -318,17 +354,59 @@ func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, chann
if _, ok := wanted[msg.ID]; !ok { if _, ok := wanted[msg.ID]; !ok {
continue continue
} }
if msg.ID == member.HistoryClearAnchorID &&
member.HistoryClearAnchorID == member.AvailableMinID {
messages = append(messages, domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
delete(wanted, msg.ID)
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID { if msg.Deleted || msg.ID <= member.AvailableMinID {
continue continue
} }
messages = append(messages, cloneChannelMessage(msg)) messages = append(messages, cloneChannelMessage(msg))
delete(wanted, msg.ID)
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
if _, ok := wanted[member.HistoryClearAnchorID]; ok {
messages = append(messages, domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
}
} }
sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID })
s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages) s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages)
s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages) s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages)
projectMemoryChannelHistoryClearMessages(channelID, member, messages)
return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil
} }
func projectMemoryChannelHistoryClearMessages(channelID int64, member domain.ChannelMember, messages []domain.ChannelMessage) {
if member.HistoryClearAnchorID <= 0 ||
member.HistoryClearAnchorID != member.AvailableMinID {
return
}
for i := range messages {
if messages[i].ID != member.HistoryClearAnchorID {
continue
}
messages[i] = domain.ProjectChannelHistoryClearMessage(
messages[i],
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
}
}
func (s *ChannelStore) ListStoryMessageForwards(_ context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) { func (s *ChannelStore) ListStoryMessageForwards(_ context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
@ -631,9 +709,40 @@ func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel
return msg.ID return msg.ID
} }
} }
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
return member.HistoryClearAnchorID
}
return 0 return 0
} }
func (s *ChannelStore) channelMessageForMemberLocked(userID, channelID int64, messageID int) (domain.ChannelMessage, bool) {
member, ok := s.members[channelID][userID]
if !ok {
msg, found := s.findMessageLocked(channelID, messageID)
if !found || msg.Deleted {
return domain.ChannelMessage{}, false
}
return cloneChannelMessage(msg), true
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID &&
messageID == member.HistoryClearAnchorID {
source, _ := s.findMessageLocked(channelID, messageID)
return domain.ProjectChannelHistoryClearMessage(
source,
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
), true
}
msg, ok := s.findMessageLocked(channelID, messageID)
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
return domain.ChannelMessage{}, false
}
return cloneChannelMessage(msg), true
}
func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool { func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool {
for _, msg := range s.messages[channelID] { for _, msg := range s.messages[channelID] {
if msg.Deleted { if msg.Deleted {

View file

@ -81,6 +81,11 @@ type ChannelStore struct {
msgViewers map[int64]map[int]map[int64]struct{} msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint retention map[int64]domain.ChannelUpdateRetentionCheckpoint
// historyClearDates is the no-PTS recovery timestamp for a future
// owner-local clear, keyed by channel then user. The member remains the
// absolute boundary authority; this map only makes account difference
// discovery bounded without scanning messages.
historyClearDates map[int64]map[int64]int
adminLogs map[int64][]domain.ChannelAdminLogEvent adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter importers map[int64]map[int64]domain.ChannelInviteImporter
@ -137,6 +142,7 @@ func NewChannelStore() *ChannelStore {
msgViewers: make(map[int64]map[int]map[int64]struct{}), msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent), events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint), retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
historyClearDates: make(map[int64]map[int64]int),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite), invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter), importers: make(map[int64]map[int64]domain.ChannelInviteImporter),

View file

@ -873,6 +873,7 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
CreatorUserID: 1, CreatorUserID: 1,
Title: "monotonic local clear", Title: "monotonic local clear",
Megagroup: true, Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_250, Date: 1_700_000_250,
}) })
if err != nil { if err != nil {
@ -911,6 +912,20 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if high.AvailableMinID != second.Message.ID { if high.AvailableMinID != second.Message.ID {
t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID) t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID)
} }
if !high.AvailableMinChanged {
t.Fatal("high clear did not report an advanced owner-local boundary")
}
dirtyAfterClear, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_253, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after owner-local clear: %v", err)
}
if len(dirtyAfterClear) != 1 ||
dirtyAfterClear[0].ChannelID != created.Channel.ID ||
dirtyAfterClear[0].AvailableMinID != second.Message.ID ||
dirtyAfterClear[0].HistoryClearDate != 1_700_000_253 ||
dirtyAfterClear[0].ChannelUpdatesDirty {
t.Fatalf("dirty owner-local clear = %+v, want only absolute boundary %d at date 1700000253", dirtyAfterClear, second.Message.ID)
}
stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1, UserID: 1,
@ -924,13 +939,38 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if stale.AvailableMinID != second.Message.ID { if stale.AvailableMinID != second.Message.ID {
t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID) t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID)
} }
if stale.AvailableMinChanged {
t.Fatal("stale clear unexpectedly replaced the owner-local anchor")
}
dirtyAfterStale, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_254, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after stale owner-local clear: %v", err)
}
if len(dirtyAfterStale) != 0 {
t.Fatalf("stale clear refreshed recovery timestamp: %+v", dirtyAfterStale)
}
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil { if err != nil {
t.Fatalf("list history: %v", err) t.Fatalf("list history: %v", err)
} }
if len(history.Messages) != 0 { if len(history.Messages) != 0 {
t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages) t.Fatalf("unprojected history after stale clear = %+v, want no shared messages", history.Messages)
}
history, err = store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list projected history: %v", err)
}
if len(history.Messages) != 1 ||
history.Messages[0].ID != second.Message.ID ||
!domain.IsChannelHistoryClearMessage(history.Messages[0]) ||
history.Messages[0].Body != "" ||
history.Messages[0].Media != nil {
t.Fatalf("projected history after stale clear = %+v, want sanitized history-clear anchor %d", history.Messages, second.Message.ID)
} }
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil { if err != nil {
@ -939,8 +979,140 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if len(dialogs.Dialogs) != 1 { if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs)
} }
if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 { if dialogs.Dialogs[0].TopMessage != second.Message.ID ||
t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID) dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID ||
dialogs.Dialogs[0].UnreadCount != 0 ||
len(dialogs.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) {
t.Fatalf("dialog after stale clear = %+v messages=%+v, want anchored top=%d read=%d unread=0", dialogs.Dialogs[0], dialogs.Messages, second.Message.ID, second.Message.ID)
}
otherDialogs, err := store.GetChannelDialogs(ctx, 2, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get other member dialog: %v", err)
}
if len(otherDialogs.Dialogs) != 1 ||
otherDialogs.Dialogs[0].TopMessage != second.Message.ID ||
len(otherDialogs.Messages) != 1 ||
otherDialogs.Messages[0].Body != "second visible" ||
otherDialogs.Messages[0].Action != nil {
t.Fatalf("other member projection changed by owner clear: dialogs=%+v messages=%+v", otherDialogs.Dialogs, otherDialogs.Messages)
}
}
func TestChannelDeleteHistoryLocalClearKeepsMegagroupAndBroadcastDialogs(t *testing.T) {
for _, tc := range []struct {
name string
megagroup bool
broadcast bool
}{
{name: "megagroup", megagroup: true},
{name: "broadcast", broadcast: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 11,
Title: tc.name + " local clear",
Megagroup: tc.megagroup,
Broadcast: tc.broadcast,
Date: 1_700_000_270,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 11,
ChannelID: created.Channel.ID,
RandomID: 31_001,
Message: "clear this",
Date: 1_700_000_271,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
channelPts := sent.Channel.Pts
cleared, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 11,
ChannelID: created.Channel.ID,
MaxID: sent.Message.ID,
Date: 1_700_000_272,
})
if err != nil {
t.Fatalf("clear local history: %v", err)
}
if cleared.Channel.Pts != channelPts || cleared.Event.Pts != 0 {
t.Fatalf("local clear changed channel pts: before=%d result=%+v", channelPts, cleared)
}
dialogs, err := store.GetChannelDialogs(ctx, 11, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get dialogs after clear: %v", err)
}
if len(dialogs.Dialogs) != 1 ||
dialogs.Dialogs[0].TopMessage != sent.Message.ID ||
len(dialogs.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) {
t.Fatalf("dialog disappeared after %s clear: dialogs=%+v messages=%+v", tc.name, dialogs.Dialogs, dialogs.Messages)
}
})
}
}
func TestChannelPrehistoryBoundaryDoesNotCreateHistoryClearAnchor(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 21,
Title: "hidden prehistory",
Megagroup: true,
Date: 1_700_000_280,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
old, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 21,
ChannelID: created.Channel.ID,
RandomID: 32_001,
Message: "prehistory",
Date: 1_700_000_281,
})
if err != nil {
t.Fatalf("send prehistory message: %v", err)
}
if _, err := store.SetPreHistoryHidden(ctx, 21, created.Channel.ID, true); err != nil {
t.Fatalf("hide prehistory: %v", err)
}
invited, err := store.InviteToChannel(ctx, created.Channel.ID, 21, []int64{22}, 1_700_000_282)
if err != nil {
t.Fatalf("invite member: %v", err)
}
if len(invited.Members) != 1 ||
invited.Members[0].AvailableMinID != old.Message.ID ||
invited.Members[0].HistoryClearAnchorID != 0 ||
invited.Members[0].HistoryClearAnchorDate != 0 {
t.Fatalf("invited member = %+v, want prehistory boundary without local-clear anchor", invited.Members)
}
history, err := store.ListChannelHistory(ctx, 22, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list invited member history: %v", err)
}
for _, message := range history.Messages {
if message.ID == old.Message.ID || domain.IsChannelHistoryClearMessage(message) {
t.Fatalf("prehistory boundary leaked a local-clear marker: %+v", history.Messages)
}
}
byID, err := store.GetChannelMessages(ctx, 22, created.Channel.ID, []int{old.Message.ID})
if err != nil {
t.Fatalf("get prehistory message by id: %v", err)
}
if len(byID.Messages) != 0 {
t.Fatalf("prehistory message by id = %+v, want hidden without fabricated marker", byID.Messages)
} }
} }

View file

@ -317,6 +317,7 @@ func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, chan
SELECT `+channelColumns+`, SELECT `+channelColumns+`,
m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.history_clear_anchor_id, m.history_clear_anchor_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date
FROM channels c FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
@ -529,7 +530,8 @@ func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Chann
var participants, admins, kicked, banned int var participants, admins, kicked, banned int
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members FROM channel_members
WHERE channel_id = $1`, channel.ID) WHERE channel_id = $1`, channel.ID)
if err != nil { if err != nil {

View file

@ -18,6 +18,26 @@ type channelDialogListItem struct {
defaultSendAs *domain.Peer defaultSendAs *domain.Peer
} }
func channelDialogVisibleTopIDSQL() string {
return `CASE
WHEN c.top_message_id > m.available_min_id THEN c.top_message_id
WHEN m.history_clear_anchor_id > 0
AND m.history_clear_anchor_id = m.available_min_id
THEN m.history_clear_anchor_id
ELSE 0
END`
}
func channelDialogVisibleTopDateSQL(globalTopDate, emptyFallback string) string {
return `CASE
WHEN c.top_message_id > m.available_min_id THEN ` + globalTopDate + `
WHEN m.history_clear_anchor_id > 0
AND m.history_clear_anchor_id = m.available_min_id
THEN m.history_clear_anchor_date
ELSE ` + emptyFallback + `
END`
}
func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 { if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil return domain.ChannelDialogList{}, nil
@ -33,8 +53,8 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
if len(channelIDs) == 0 { if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil return domain.ChannelDialogList{}, nil
} }
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
args := []any{viewerUserID, channelIDs} args := []any{viewerUserID, channelIDs}
@ -139,7 +159,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false), COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false), COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type, d.default_send_as_peer_type,
d.default_send_as_peer_id d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -232,6 +254,7 @@ LIMIT `+limitArg, args...)
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
return domain.ChannelDialogList{}, err return domain.ChannelDialogList{}, err
} }
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil return out, nil
} }
@ -255,7 +278,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false), COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false), COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type, d.default_send_as_peer_type,
d.default_send_as_peer_id d.default_send_as_peer_id,
0,
0
FROM user_channel_member_index i FROM user_channel_member_index i
JOIN channel_members pm ON pm.user_id = i.user_id AND pm.channel_id = i.channel_id JOIN channel_members pm ON pm.user_id = i.user_id AND pm.channel_id = i.channel_id
JOIN channels parent ON parent.id = i.channel_id AND parent.id = ANY($2::bigint[]) AND parent.broadcast AND parent.linked_monoforum_id <> 0 AND NOT parent.deleted JOIN channels parent ON parent.id = i.channel_id AND parent.id = ANY($2::bigint[]) AND parent.broadcast AND parent.linked_monoforum_id <> 0 AND NOT parent.deleted
@ -357,6 +382,20 @@ WHERE `+where.String(), args...)
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan channel dialog top messages: %w", err) return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
} }
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
out[key] = domain.ProjectChannelHistoryClearMessage(
out[key],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
return out, nil return out, nil
} }
@ -413,6 +452,14 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
} }
} }
msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID) msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID)
if dialog.TopMessageID > 0 && dialog.TopMessageID == dialog.HistoryClearAnchorID {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
if msg.ID != 0 { if msg.ID != 0 {
dialog.TopMessageDate = msg.Date dialog.TopMessageDate = msg.Date
out.Messages = append(out.Messages, msg) out.Messages = append(out.Messages, msg)
@ -425,9 +472,34 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
return domain.ChannelDialogList{}, err return domain.ChannelDialogList{}, err
} }
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil return out, nil
} }
func projectChannelDialogHistoryClearMessages(dialogs []domain.Dialog, messages []domain.ChannelMessage) {
anchors := make(map[channelMessageLookupKey]domain.Dialog)
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
anchors[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}] = dialog
}
for i := range messages {
dialog, ok := anchors[channelMessageLookupKey{channelID: messages[i].ChannelID, id: messages[i].ID}]
if !ok {
continue
}
messages[i] = domain.ProjectChannelHistoryClearMessage(
messages[i],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
}
func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) {
if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 {
return domain.CommonChannelsResult{}, domain.ErrChannelInvalid return domain.CommonChannelsResult{}, domain.ErrChannelInvalid
@ -553,6 +625,7 @@ func (s *ChannelStore) leftChannelsByIDs(ctx context.Context, userID int64, ids
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members FROM channel_members
WHERE user_id = $1 WHERE user_id = $1
@ -603,8 +676,8 @@ func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, l
if len(channelIDs) == 0 { if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil return domain.ChannelDialogList{}, nil
} }
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END" visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "GREATEST(c.date, m.joined_at)")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
@ -619,7 +692,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false), COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false), COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type, d.default_send_as_peer_type,
d.default_send_as_peer_id d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -856,6 +931,7 @@ func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64,
// 归档必须 ensure-INSERT:从未读过/置顶过的频道还没有 dialog 行, // 归档必须 ensure-INSERT:从未读过/置顶过的频道还没有 dialog 行,
// 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位, // 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位,
// 避免 0 水位缓存行遮蔽未读/已读状态。 // 避免 0 水位缓存行遮蔽未读/已读状态。
visibleTopID := channelDialogVisibleTopIDSQL()
if _, err := s.db.Exec(ctx, ` if _, err := s.db.Exec(ctx, `
WITH requested AS ( WITH requested AS (
SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id
@ -870,7 +946,7 @@ deduped AS (
) )
INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, updated_at) INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, updated_at)
SELECT $1, m.channel_id, deduped.folder_id, SELECT $1, m.channel_id, deduped.folder_id,
CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END, `+visibleTopID+`,
m.read_inbox_max_id, m.read_outbox_max_id, m.read_inbox_max_id, m.read_outbox_max_id,
( (
SELECT COUNT(*)::int SELECT COUNT(*)::int
@ -904,7 +980,7 @@ func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int
// JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。 // JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。
// unread 读时动态派生(H4a):不再消费 channel_dialogs.unread_count 缓存列;归档集合 // unread 读时动态派生(H4a):不再消费 channel_dialogs.unread_count 缓存列;归档集合
// 有界(需显式归档建行),每行动态 COUNT 已被 cap 钳制。 // 有界(需显式归档建行),每行动态 COUNT 已被 cap 钳制。
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
if err := s.db.QueryRow(ctx, ` if err := s.db.QueryRow(ctx, `
SELECT SELECT
@ -937,7 +1013,7 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID} dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID}
var defaultSendAsType sql.NullString var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64 var defaultSendAsID sql.NullInt64
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
// 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次 // 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次
@ -946,7 +1022,10 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
// message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退 // message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退
// COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态 // COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态
// (ELSE 0)刻意保持各自既有差异,本改动只去往返、不改单频道输出。 // (ELSE 0)刻意保持各自既有差异,本改动只去往返、不改单频道输出。
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id AND top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END" visibleTopDate := channelDialogVisibleTopDateSQL(
"CASE WHEN top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END",
"COALESCE(d.top_message_date, c.date)",
)
err := db.QueryRow(ctx, ` err := db.QueryRow(ctx, `
SELECT `+visibleTopID+`, SELECT `+visibleTopID+`,
`+visibleTopDate+`, `+visibleTopDate+`,
@ -962,7 +1041,9 @@ SELECT `+visibleTopID+`,
COALESCE(d.view_forum_as_messages, false), COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false), COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type, d.default_send_as_peer_type,
d.default_send_as_peer_id d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channels c FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id
@ -983,6 +1064,8 @@ WHERE c.id = $2`, userID, channel.ID).Scan(
&dialog.HasScheduled, &dialog.HasScheduled,
&defaultSendAsType, &defaultSendAsType,
&defaultSendAsID, &defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
) )
if err != nil && !errors.Is(err, pgx.ErrNoRows) { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err) return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err)
@ -1037,8 +1120,8 @@ func (s *ChannelStore) getChannelDialogsUncached(ctx context.Context, db sqlcgen
if userID == 0 || len(channelIDs) == 0 { if userID == 0 || len(channelIDs) == 0 {
return nil, nil return nil, nil
} }
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
@ -1057,7 +1140,9 @@ SELECT c.id,
COALESCE(d.view_forum_as_messages, false), COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false), COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type, d.default_send_as_peer_type,
d.default_send_as_peer_id d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channels c FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -1089,6 +1174,8 @@ WHERE c.id = ANY($2::bigint[])`, userID, channelIDs)
&dialog.HasScheduled, &dialog.HasScheduled,
&defaultSendAsType, &defaultSendAsType,
&defaultSendAsID, &defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -1197,6 +1284,7 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
var rights, reactionPolicy string var rights, reactionPolicy string
var wallpaper *string var wallpaper *string
var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int
var historyClearAnchorID, historyClearAnchorDate int
var pinned, unreadMark, viewForumAsMessages, hasScheduled bool var pinned, unreadMark, viewForumAsMessages, hasScheduled bool
var defaultSendAsType sql.NullString var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64 var defaultSendAsID sql.NullInt64
@ -1204,6 +1292,7 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
&topID, &topDate, &topID, &topDate,
&folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled, &folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled,
&defaultSendAsType, &defaultSendAsID, &defaultSendAsType, &defaultSendAsID,
&historyClearAnchorID, &historyClearAnchorDate,
) )
if err := row.Scan(dest...); err != nil { if err := row.Scan(dest...); err != nil {
return domain.Channel{}, domain.Dialog{}, nil, err return domain.Channel{}, domain.Dialog{}, nil, err
@ -1214,6 +1303,8 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
FolderID: folderID, FolderID: folderID,
TopMessage: topID, TopMessage: topID,
TopMessageDate: topDate, TopMessageDate: topDate,
HistoryClearAnchorID: historyClearAnchorID,
HistoryClearAnchorDate: historyClearAnchorDate,
ReadInboxMaxID: readInbox, ReadInboxMaxID: readInbox,
ReadOutboxMaxID: readOutbox, ReadOutboxMaxID: readOutbox,
UnreadCount: unreadCount, UnreadCount: unreadCount,
@ -1240,6 +1331,8 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.D
FolderID: dialog.FolderID, FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID, TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate, TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID, ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount, UnreadCount: dialog.UnreadCount,
@ -1261,6 +1354,8 @@ func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelD
FolderID: dialog.FolderID, FolderID: dialog.FolderID,
TopMessageID: dialog.TopMessage, TopMessageID: dialog.TopMessage,
TopMessageDate: dialog.TopMessageDate, TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID, ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount, UnreadCount: dialog.UnreadCount,
@ -1392,13 +1487,22 @@ func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.Di
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID { if topMessageID <= member.AvailableMinID {
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0 topMessageID = 0
} }
}
topMessageDate := channel.Date
if topMessageID > 0 && topMessageID == member.HistoryClearAnchorID {
topMessageDate = member.HistoryClearAnchorDate
}
return domain.ChannelDialog{ return domain.ChannelDialog{
UserID: userID, UserID: userID,
ChannelID: channel.ID, ChannelID: channel.ID,
TopMessageID: topMessageID, TopMessageID: topMessageID,
TopMessageDate: channel.Date, TopMessageDate: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
} }

View file

@ -31,8 +31,11 @@ func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.
} }
topMessageID := channel.TopMessageID topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID { if topMessageID <= member.AvailableMinID {
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0 topMessageID = 0
} }
}
if _, err := s.db.Exec(ctx, ` if _, err := s.db.Exec(ctx, `
INSERT INTO channel_dialogs ( INSERT INTO channel_dialogs (
user_id, channel_id, top_message_id, top_message_date, user_id, channel_id, top_message_id, top_message_date,
@ -393,15 +396,59 @@ LIMIT $4`, userID, sinceDate, afterChannelID, limit)
return nil, fmt.Errorf("list dirty active channels for user: %w", err) return nil, fmt.Errorf("list dirty active channels for user: %w", err)
} }
defer rows.Close() defer rows.Close()
out := make([]domain.DirtyChannel, 0, limit) byChannelID := make(map[int64]domain.DirtyChannel, limit*2)
for rows.Next() { for rows.Next() {
var item domain.DirtyChannel var item domain.DirtyChannel
if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil { if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil {
return nil, err return nil, err
} }
item.ChannelUpdatesDirty = true
byChannelID[item.ChannelID] = item
}
if err := rows.Err(); err != nil {
return nil, err
}
clearRows, err := s.db.Query(ctx, `
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND i.history_clear_anchor_id > 0
AND i.history_clear_anchor_id = i.available_min_id
AND i.history_clear_updated_at >= $2
ORDER BY i.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil {
return nil, fmt.Errorf("list owner-local channel history clears for user: %w", err)
}
defer clearRows.Close()
for clearRows.Next() {
var item domain.DirtyChannel
if err := clearRows.Scan(&item.ChannelID, &item.Pts, &item.AvailableMinID, &item.HistoryClearDate); err != nil {
return nil, err
}
if existing, ok := byChannelID[item.ChannelID]; ok {
item.ChannelUpdatesDirty = existing.ChannelUpdatesDirty
}
byChannelID[item.ChannelID] = item
}
if err := clearRows.Err(); err != nil {
return nil, err
}
out := make([]domain.DirtyChannel, 0, len(byChannelID))
for _, item := range byChannelID {
out = append(out, item) out = append(out, item)
} }
return out, rows.Err() sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
} }
type rowScanner interface { type rowScanner interface {

View file

@ -189,6 +189,7 @@ func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userI
row := db.QueryRow(ctx, ` row := db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members FROM channel_members
WHERE channel_id = $1 AND user_id = $2`, channelID, userID) WHERE channel_id = $1 AND user_id = $2`, channelID, userID)
@ -214,8 +215,9 @@ func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channe
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO channel_members ( INSERT INTO channel_members (
channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights, channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (channel_id, user_id) DO UPDATE SET ON CONFLICT (channel_id, user_id) DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id, inviter_user_id = EXCLUDED.inviter_user_id,
role = EXCLUDED.role, role = EXCLUDED.role,
@ -231,7 +233,8 @@ ON CONFLICT (channel_id, user_id) DO UPDATE SET
updated_at = now()`, updated_at = now()`,
member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status), member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status),
member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID, member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID,
member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil { member.AvailableMinPts, member.HistoryClearAnchorID, member.HistoryClearAnchorDate,
member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
return fmt.Errorf("upsert channel member: %w", err) return fmt.Errorf("upsert channel member: %w", err)
} }
return upsertUserChannelMemberIndexTx(ctx, tx, channel, member) return upsertUserChannelMemberIndexTx(ctx, tx, channel, member)
@ -316,7 +319,8 @@ func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember
dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper), dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper),
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, &member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
) )
if err := row.Scan(dest...); err != nil { if err := row.Scan(dest...); err != nil {
return domain.Channel{}, domain.ChannelMember{}, err return domain.Channel{}, domain.ChannelMember{}, err
@ -351,7 +355,8 @@ func scanChannelMember(row rowScanner) (domain.ChannelMember, error) {
if err := row.Scan( if err := row.Scan(
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, &member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
); err != nil { ); err != nil {
return domain.ChannelMember{}, err return domain.ChannelMember{}, err
} }
@ -370,7 +375,8 @@ func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, erro
if err := row.Scan( if err := row.Scan(
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, &member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&count, &count,
); err != nil { ); err != nil {
return domain.ChannelMember{}, 0, err return domain.ChannelMember{}, 0, err
@ -471,6 +477,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
} }
member.AvailableMinID = 0 member.AvailableMinID = 0
member.AvailableMinPts = 0 member.AvailableMinPts = 0
member.HistoryClearAnchorID = 0
member.HistoryClearAnchorDate = 0
member.ReadInboxMaxID = mono.TopMessageID member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false member.UnreadMark = false

View file

@ -267,6 +267,7 @@ func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.D
member, err := scanChannelMember(db.QueryRow(ctx, ` member, err := scanChannelMember(db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members FROM channel_members
WHERE channel_id = $1 WHERE channel_id = $1

View file

@ -128,7 +128,8 @@ func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channe
} }
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
`+from+` `+from+`
WHERE `+strings.Join(where, " AND ")+` WHERE `+strings.Join(where, " AND ")+`
ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id
@ -221,7 +222,8 @@ func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserI
} }
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members FROM channel_members
WHERE channel_id = $1 AND status = 'active' WHERE channel_id = $1 AND status = 'active'
ORDER BY user_id ORDER BY user_id
@ -264,6 +266,7 @@ func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUs
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.history_clear_anchor_id, m.history_clear_anchor_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date, m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date,
COUNT(*) OVER()::int COUNT(*) OVER()::int
FROM bots b FROM bots b

View file

@ -2,6 +2,7 @@ package postgres
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"sort" "sort"
@ -223,19 +224,68 @@ func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.Dele
} }
if !req.ForEveryone { if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID) appliedMinID := maxInt(member.AvailableMinID, maxID)
changed := appliedMinID > member.AvailableMinID
anchorID := member.HistoryClearAnchorID
anchorDate := member.HistoryClearAnchorDate
if changed {
anchorID = appliedMinID
anchorDate = req.Date
var messageDate int
err := tx.QueryRow(ctx, `
SELECT message_date
FROM channel_messages
WHERE channel_id = $1 AND id = $2`, req.ChannelID, appliedMinID).Scan(&messageDate)
switch {
case err == nil && messageDate > 0:
anchorDate = messageDate
case errors.Is(err, pgx.ErrNoRows):
// max_id may name an already-pruned/hole id. The owner-local
// marker is still a valid monotonic boundary; retain request
// time so its dialog projection remains loadable.
case err != nil:
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("select channel clear anchor date: %w", err)
}
if anchorDate <= 0 {
anchorDate = channel.Date
}
}
topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date) topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date)
if err != nil { if err != nil {
return domain.DeleteChannelHistoryResult{}, err return domain.DeleteChannelHistoryResult{}, err
} }
if topID == 0 && anchorID > 0 && anchorID == appliedMinID {
topID = anchorID
topDate = anchorDate
}
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
UPDATE channel_members UPDATE channel_members
SET available_min_id = GREATEST(available_min_id, $3), SET available_min_id = GREATEST(available_min_id, $3),
history_clear_anchor_id = CASE WHEN available_min_id < $3 THEN $3 ELSE history_clear_anchor_id END,
history_clear_anchor_date = CASE WHEN available_min_id < $3 THEN $4 ELSE history_clear_anchor_date END,
read_inbox_max_id = GREATEST(read_inbox_max_id, $3), read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
unread_mark = false, unread_mark = false,
updated_at = now() updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil { WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID, anchorDate); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err) return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err)
} }
if changed {
member.AvailableMinID = appliedMinID
member.HistoryClearAnchorID = anchorID
member.HistoryClearAnchorDate = anchorDate
if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if _, err := tx.Exec(ctx, `
UPDATE user_channel_member_index
SET available_min_id = $3,
history_clear_anchor_id = $3,
history_clear_updated_at = $4,
updated_at = now()
WHERE user_id = $1 AND channel_id = $2`,
req.UserID, req.ChannelID, appliedMinID, req.Date); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear recovery index: %w", err)
}
}
if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil { if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil {
return domain.DeleteChannelHistoryResult{}, err return domain.DeleteChannelHistoryResult{}, err
} }
@ -259,7 +309,17 @@ ON CONFLICT (user_id, channel_id) DO UPDATE SET
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err) return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err)
} }
committed = true committed = true
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil if s.memberCacheActive(s.db) {
s.memberCache.delete(req.ChannelID, req.UserID)
}
if s.dialogCacheActive(s.db) {
s.dialogCache.delete(req.UserID, req.ChannelID)
}
return domain.DeleteChannelHistoryResult{
Channel: channel,
AvailableMinID: appliedMinID,
AvailableMinChanged: changed,
}, nil
} }
if !canDeleteAnyChannelMessage(member) { if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired

View file

@ -79,6 +79,10 @@ AND EXISTS (
baseArgs = append(baseArgs, filter.MinID) baseArgs = append(baseArgs, filter.MinID)
base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) base += fmt.Sprintf(" AND id > $%d", len(baseArgs))
} }
historyClearAnchor, hasHistoryClearAnchor, err := s.channelHistoryClearAnchor(ctx, channel, member, filter)
if err != nil {
return domain.ChannelHistory{}, err
}
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels} out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
needExactTotal := filter.NeedTotalCount || filter.CountOnly needExactTotal := filter.NeedTotalCount || filter.CountOnly
exactTotal := 0 exactTotal := 0
@ -92,6 +96,9 @@ AND EXISTS (
).Scan(&exactTotal); err != nil { ).Scan(&exactTotal); err != nil {
return domain.ChannelHistory{}, fmt.Errorf("count channel history: %w", err) return domain.ChannelHistory{}, fmt.Errorf("count channel history: %w", err)
} }
if hasHistoryClearAnchor {
exactTotal++
}
out.Count = exactTotal out.Count = exactTotal
} }
if filter.CountOnly { if filter.CountOnly {
@ -134,6 +141,18 @@ AND EXISTS (
} }
return "false" return "false"
} }
anchorMatchesForward := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date >= filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID > filter.OffsetID
}
return false
}
aroundOlderCond := func(args *[]any) string { aroundOlderCond := func(args *[]any) string {
if filter.OffsetDate > 0 { if filter.OffsetDate > 0 {
*args = append(*args, filter.OffsetDate) *args = append(*args, filter.OffsetDate)
@ -145,6 +164,30 @@ AND EXISTS (
} }
return "true" return "true"
} }
anchorMatchesAroundOlder := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date < filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID <= filter.OffsetID
}
return true
}
anchorMatchesBackward := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date < filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID < filter.OffsetID
}
return true
}
switch { switch {
case addOffset < 0 && addOffset+limit > 0: case addOffset < 0 && addOffset+limit > 0:
// around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条 // around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
@ -157,6 +200,12 @@ AND EXISTS (
if err != nil { if err != nil {
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
if anchorMatchesForward() {
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
if len(newer) > fwdLimit {
newer = newer[:fwdLimit]
}
}
bwdArgs := append([]any{}, baseArgs...) bwdArgs := append([]any{}, baseArgs...)
bwdWhere := aroundOlderCond(&bwdArgs) bwdWhere := aroundOlderCond(&bwdArgs)
bwdArgs = append(bwdArgs, bwdLimit+1) bwdArgs = append(bwdArgs, bwdLimit+1)
@ -164,6 +213,9 @@ AND EXISTS (
if err != nil { if err != nil {
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
if anchorMatchesAroundOlder() {
older = append(older, historyClearAnchor)
}
if len(older) > bwdLimit { if len(older) > bwdLimit {
older = older[:bwdLimit] older = older[:bwdLimit]
hasMoreOlder = true hasMoreOlder = true
@ -181,6 +233,9 @@ AND EXISTS (
if err != nil { if err != nil {
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
if anchorMatchesForward() {
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
}
if len(newer) > limit { if len(newer) > limit {
newer = newer[:limit] newer = newer[:limit]
} }
@ -198,18 +253,26 @@ AND EXISTS (
args = append(args, filter.OffsetID) args = append(args, filter.OffsetID)
where += fmt.Sprintf(" AND id < $%d", len(args)) where += fmt.Sprintf(" AND id < $%d", len(args))
} }
args = append(args, limit+1) // Fetch the bounded add_offset window and slice it in memory. This keeps
// the shared-history branch on its ordered (channel_id,id) seek index;
// the owner-local anchor is one separate PK lookup and never adds an OR
// that would force BitmapOr + Sort for large channels.
args = append(args, addOffset+limit+1)
limIdx := len(args) limIdx := len(args)
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC" sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where +
if addOffset > 0 { fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", limIdx)
args = append(args, addOffset)
sql += fmt.Sprintf(" OFFSET $%d", len(args))
}
sql += fmt.Sprintf(" LIMIT $%d", limIdx)
older, err := scanList(sql, args) older, err := scanList(sql, args)
if err != nil { if err != nil {
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
if anchorMatchesBackward() {
older = append(older, historyClearAnchor)
}
if addOffset >= len(older) {
older = nil
} else if addOffset > 0 {
older = older[addOffset:]
}
if len(older) > limit { if len(older) > limit {
older = older[:limit] older = older[:limit]
hasMoreOlder = true hasMoreOlder = true
@ -228,9 +291,63 @@ AND EXISTS (
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
if hasHistoryClearAnchor {
for i := range out.Messages {
if out.Messages[i].ID == historyClearAnchor.ID {
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
out.Messages[i],
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
}
}
}
return out, nil return out, nil
} }
func (s *ChannelStore) channelHistoryClearAnchor(
ctx context.Context,
channel domain.Channel,
member domain.ChannelMember,
filter domain.ChannelHistoryFilter,
) (domain.ChannelMessage, bool, error) {
if !filter.IncludeHistoryClearAnchor ||
member.HistoryClearAnchorID <= 0 ||
member.HistoryClearAnchorID != member.AvailableMinID ||
filter.PinnedOnly ||
filter.MusicOnly ||
filter.Query != "" {
return domain.ChannelMessage{}, false, nil
}
source, err := s.getChannelMessage(ctx, s.db, channel.ID, member.HistoryClearAnchorID)
if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) {
return domain.ChannelMessage{}, false, fmt.Errorf("load channel history-clear anchor: %w", err)
}
anchor := domain.ProjectChannelHistoryClearMessage(
source,
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
if filter.SenderUserID != 0 && anchor.SenderUserID != filter.SenderUserID {
return domain.ChannelMessage{}, false, nil
}
if filter.MinDate > 0 && anchor.Date <= filter.MinDate {
return domain.ChannelMessage{}, false, nil
}
if filter.MaxDate > 0 && anchor.Date >= filter.MaxDate {
return domain.ChannelMessage{}, false, nil
}
if filter.MaxID > 0 && anchor.ID > filter.MaxID {
return domain.ChannelMessage{}, false, nil
}
if filter.MinID > 0 && anchor.ID <= filter.MinID {
return domain.ChannelMessage{}, false, nil
}
return anchor, true, nil
}
func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
query := strings.TrimSpace(req.Query) query := strings.TrimSpace(req.Query)
if viewerUserID == 0 || (query == "" && !req.MusicOnly) { if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
@ -390,8 +507,15 @@ func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUs
// 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory // 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory
// 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序 // 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序
// (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。 // (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。
args := []any{channel.ID, id32, member.AvailableMinID} anchorID := 0
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)" if member.HistoryClearAnchorID > 0 && member.HistoryClearAnchorID == member.AvailableMinID {
anchorID = member.HistoryClearAnchorID
}
args := []any{channel.ID, id32, member.AvailableMinID, anchorID}
where := `channel_id = $1
AND id = ANY($2::int[])
AND (NOT deleted OR ($4 > 0 AND id = $4))
AND (($3 <= 0 OR id > $3) OR ($4 > 0 AND id = $4))`
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+` SELECT `+channelMessageColumns+`
FROM channel_messages FROM channel_messages
@ -419,6 +543,36 @@ ORDER BY id DESC`, args...)
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
if anchorID > 0 {
anchorFound := false
for i := range out.Messages {
if out.Messages[i].ID != anchorID {
continue
}
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
out.Messages[i],
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
anchorFound = true
}
if !anchorFound {
for _, id := range ids {
if id != anchorID {
continue
}
out.Messages = append(out.Messages, domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
out.Count = len(out.Messages)
break
}
}
}
return out, nil return out, nil
} }

View file

@ -189,6 +189,17 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
t.Fatalf("read participants = %+v, want friend read date", readers.Participants) t.Fatalf("read participants = %+v, want friend read date", readers.Participants)
} }
var channelPtsBeforeClear, channelEventsBeforeClear int
if err := pool.QueryRow(ctx, `
SELECT c.pts, (
SELECT count(*)::int
FROM channel_update_events e
WHERE e.channel_id = c.id
)
FROM channels c
WHERE c.id = $1`, channelID).Scan(&channelPtsBeforeClear, &channelEventsBeforeClear); err != nil {
t.Fatalf("query channel state before local clear: %v", err)
}
cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: friend.ID, UserID: friend.ID,
ChannelID: channelID, ChannelID: channelID,
@ -201,6 +212,70 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if cleared.AvailableMinID != sent.Message.ID { if cleared.AvailableMinID != sent.Message.ID {
t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID)
} }
if !cleared.AvailableMinChanged {
t.Fatal("local clear did not report an advanced owner-local boundary")
}
var channelPtsAfterClear, channelEventsAfterClear int
if err := pool.QueryRow(ctx, `
SELECT c.pts, (
SELECT count(*)::int
FROM channel_update_events e
WHERE e.channel_id = c.id
)
FROM channels c
WHERE c.id = $1`, channelID).Scan(&channelPtsAfterClear, &channelEventsAfterClear); err != nil {
t.Fatalf("query channel state after local clear: %v", err)
}
if channelPtsAfterClear != channelPtsBeforeClear || channelEventsAfterClear != channelEventsBeforeClear {
t.Fatalf("local clear changed channel sequence: pts %d->%d events %d->%d",
channelPtsBeforeClear, channelPtsAfterClear, channelEventsBeforeClear, channelEventsAfterClear)
}
var recoveryMinID, recoveryAnchorID, recoveryDate int
if err := pool.QueryRow(ctx, `
SELECT available_min_id, history_clear_anchor_id, history_clear_updated_at
FROM user_channel_member_index
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(
&recoveryMinID, &recoveryAnchorID, &recoveryDate,
); err != nil {
t.Fatalf("query owner-local clear recovery index: %v", err)
}
if recoveryMinID != sent.Message.ID || recoveryAnchorID != sent.Message.ID || recoveryDate != 1700000302 {
t.Fatalf("recovery index = min %d anchor %d date %d, want %d/%d/1700000302",
recoveryMinID, recoveryAnchorID, recoveryDate, sent.Message.ID, sent.Message.ID)
}
dirtyAfterClear, err := channels.ListDirtyActiveChannelsForUser(ctx, friend.ID, 1700000302, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after local clear: %v", err)
}
if len(dirtyAfterClear) != 1 ||
dirtyAfterClear[0].ChannelID != channelID ||
dirtyAfterClear[0].AvailableMinID != sent.Message.ID ||
dirtyAfterClear[0].HistoryClearDate != 1700000302 {
t.Fatalf("dirty channel recovery = %+v, want channel %d boundary %d", dirtyAfterClear, channelID, sent.Message.ID)
}
planTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin history-clear recovery plan transaction: %v", err)
}
defer func() { _ = planTx.Rollback(ctx) }()
if _, err := planTx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan for history-clear recovery plan: %v", err)
}
clearPlan := explainText(t, ctx, planTx, `
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND i.history_clear_anchor_id > 0
AND i.history_clear_anchor_id = i.available_min_id
AND i.history_clear_updated_at >= $2
ORDER BY i.channel_id ASC
LIMIT $4`, friend.ID, 1700000302, int64(0), 10)
requirePlanContains(t, clearPlan, "user_channel_member_index_history_clear_idx")
_ = planTx.Rollback(ctx)
staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: friend.ID, UserID: friend.ID,
ChannelID: channelID, ChannelID: channelID,
@ -213,12 +288,60 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if staleClear.AvailableMinID != sent.Message.ID { if staleClear.AvailableMinID != sent.Message.ID {
t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID) t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID)
} }
if staleClear.AvailableMinChanged {
t.Fatal("stale local clear unexpectedly replaced the owner-local anchor")
}
if err := pool.QueryRow(ctx, `
SELECT history_clear_updated_at
FROM user_channel_member_index
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(&recoveryDate); err != nil {
t.Fatalf("query recovery timestamp after stale clear: %v", err)
}
if recoveryDate != 1700000302 {
t.Fatalf("stale clear recovery date = %d, want unchanged 1700000302", recoveryDate)
}
afterClear, err := channels.GetChannel(ctx, friend.ID, channelID) afterClear, err := channels.GetChannel(ctx, friend.ID, channelID)
if err != nil { if err != nil {
t.Fatalf("get channel after clear: %v", err) t.Fatalf("get channel after clear: %v", err)
} }
if afterClear.Dialog.TopMessageID != 0 { if afterClear.Dialog.TopMessageID != sent.Message.ID ||
t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog) afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("dialog after clear = %+v, want owner-local anchored top %d", afterClear.Dialog, sent.Message.ID)
}
dialogsAfterClear, err := channels.GetChannelDialogs(ctx, friend.ID, []int64{channelID})
if err != nil {
t.Fatalf("get channel dialogs after clear: %v", err)
}
if len(dialogsAfterClear.Dialogs) != 1 ||
len(dialogsAfterClear.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogsAfterClear.Messages[0]) ||
dialogsAfterClear.Messages[0].ID != sent.Message.ID ||
dialogsAfterClear.Messages[0].Body != "" {
t.Fatalf("dialog projection after clear = dialogs=%+v messages=%+v", dialogsAfterClear.Dialogs, dialogsAfterClear.Messages)
}
projectedHistory, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("project history-clear anchor: %v", err)
}
if len(projectedHistory.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(projectedHistory.Messages[0]) ||
projectedHistory.Messages[0].ID != sent.Message.ID {
t.Fatalf("projected history after clear = %+v, want anchor %d", projectedHistory.Messages, sent.Message.ID)
}
ownerDialogsAfterClear, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
if err != nil {
t.Fatalf("get unaffected owner dialog after friend clear: %v", err)
}
if len(ownerDialogsAfterClear.Messages) != 1 ||
ownerDialogsAfterClear.Messages[0].ID != sent.Message.ID ||
ownerDialogsAfterClear.Messages[0].Body != "first visible channel text" ||
ownerDialogsAfterClear.Messages[0].Action != nil {
t.Fatalf("friend clear changed shared owner projection: %+v", ownerDialogsAfterClear.Messages)
} }
next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
@ -238,6 +361,59 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 { if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 {
t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID) t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID)
} }
firstPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 1,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list first page after next message: %v", err)
}
if len(firstPage.Messages) != 1 ||
firstPage.Messages[0].ID != next.Message.ID ||
firstPage.Count != 2 {
t.Fatalf("first page after next = %+v count=%d, want next message and bounded has-more count", firstPage.Messages, firstPage.Count)
}
offsetPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
AddOffset: 1,
Limit: 1,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list add-offset page after next message: %v", err)
}
if len(offsetPage.Messages) != 1 ||
offsetPage.Messages[0].ID != sent.Message.ID ||
!domain.IsChannelHistoryClearMessage(offsetPage.Messages[0]) {
t.Fatalf("add-offset page after next = %+v, want retained clear anchor %d", offsetPage.Messages, sent.Message.ID)
}
exactCount, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
CountOnly: true,
NeedTotalCount: true,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("count history after next message: %v", err)
}
if exactCount.Count != 2 {
t.Fatalf("exact history count after next = %d, want shared message plus clear anchor", exactCount.Count)
}
olderPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
OffsetID: next.Message.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list older page after next message: %v", err)
}
if len(olderPage.Messages) != 1 ||
olderPage.Messages[0].ID != sent.Message.ID ||
!domain.IsChannelHistoryClearMessage(olderPage.Messages[0]) {
t.Fatalf("older page after next = %+v, want retained clear anchor %d", olderPage.Messages, sent.Message.ID)
}
} }
func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) { func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) {

View file

@ -574,8 +574,10 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop,
if err != nil { if err != nil {
t.Fatalf("get large channel after local clear: %v", err) t.Fatalf("get large channel after local clear: %v", err)
} }
if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 { if afterClear.Dialog.TopMessageID != sent.Message.ID ||
t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog) afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("large dialog after local clear = %+v, want anchored top %d with no unread", afterClear.Dialog, sent.Message.ID)
} }
} }

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err) t.Fatalf("migrate star gift lifecycle schema: %v", err)
} }
if status.Dirty || status.Empty || status.Version != 158 { if status.Dirty || status.Empty || status.Version != 162 {
t.Fatalf("migration status = %+v, want clean version 158", status) t.Fatalf("migration status = %+v, want clean version 162", status)
} }
} }