fix(channels): sync preserve locally cleared dialogs
This commit is contained in:
parent
e22fac1c3f
commit
73cf6a8184
50 changed files with 1525 additions and 327 deletions
|
|
@ -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])))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[])
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
14
deploy/migrations/0161_channel_history_clear_anchor.up.sql
Normal file
14
deploy/migrations/0161_channel_history_clear_anchor.up.sql
Normal 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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
22
deploy/migrations/0162_channel_history_clear_recovery.up.sql
Normal file
22
deploy/migrations/0162_channel_history_clear_recovery.up.sql
Normal 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;
|
||||||
|
|
@ -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 自愈)。
|
||||||
|
|
|
||||||
|
|
@ -408,22 +408,24 @@ func isChannelPreviewAccessError(err error) bool {
|
||||||
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
|
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
|
||||||
dialog := view.Dialog
|
dialog := view.Dialog
|
||||||
return domain.Dialog{
|
return domain.Dialog{
|
||||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||||
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
||||||
FolderID: dialog.FolderID,
|
FolderID: dialog.FolderID,
|
||||||
TopMessage: dialog.TopMessageID,
|
TopMessage: dialog.TopMessageID,
|
||||||
TopMessageDate: dialog.TopMessageDate,
|
TopMessageDate: dialog.TopMessageDate,
|
||||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||||
UnreadCount: dialog.UnreadCount,
|
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||||
UnreadMentions: dialog.UnreadMentions,
|
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||||
UnreadReactions: dialog.UnreadReactions,
|
UnreadCount: dialog.UnreadCount,
|
||||||
Pinned: dialog.Pinned,
|
UnreadMentions: dialog.UnreadMentions,
|
||||||
PinnedOrder: dialog.PinnedOrder,
|
UnreadReactions: dialog.UnreadReactions,
|
||||||
UnreadMark: dialog.UnreadMark,
|
Pinned: dialog.Pinned,
|
||||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
PinnedOrder: dialog.PinnedOrder,
|
||||||
HasScheduled: dialog.HasScheduled,
|
UnreadMark: dialog.UnreadMark,
|
||||||
Pts: view.Channel.Pts,
|
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||||
|
HasScheduled: dialog.HasScheduled,
|
||||||
|
Pts: view.Channel.Pts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -485,23 +485,29 @@ func (c Channel) MembersListAdminOnly() bool {
|
||||||
|
|
||||||
// ChannelMember is one user's channel membership and read state.
|
// ChannelMember is one user's channel membership and read state.
|
||||||
type ChannelMember struct {
|
type ChannelMember struct {
|
||||||
ChannelID int64
|
ChannelID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
InviterUserID int64
|
InviterUserID int64
|
||||||
Role ChannelMemberRole
|
Role ChannelMemberRole
|
||||||
Status ChannelMemberStatus
|
Status ChannelMemberStatus
|
||||||
JoinedAt int
|
JoinedAt int
|
||||||
LeftAt int
|
LeftAt int
|
||||||
AdminRights ChannelAdminRights
|
AdminRights ChannelAdminRights
|
||||||
BannedRights ChannelBannedRights
|
BannedRights ChannelBannedRights
|
||||||
Rank string
|
Rank string
|
||||||
AvailableMinID int
|
AvailableMinID int
|
||||||
AvailableMinPts int
|
AvailableMinPts int
|
||||||
ReadInboxMaxID int
|
// HistoryClearAnchorID/Date identify an owner-local channels.deleteHistory
|
||||||
ReadInboxDate int
|
// boundary. They are deliberately separate from AvailableMinID: joining a
|
||||||
ReadOutboxMaxID int
|
// hidden-prehistory channel also advances AvailableMinID but must not
|
||||||
UnreadMark bool
|
// manufacture a "History cleared" service message.
|
||||||
SlowmodeLastSendDate int
|
HistoryClearAnchorID int
|
||||||
|
HistoryClearAnchorDate int
|
||||||
|
ReadInboxMaxID int
|
||||||
|
ReadInboxDate int
|
||||||
|
ReadOutboxMaxID int
|
||||||
|
UnreadMark bool
|
||||||
|
SlowmodeLastSendDate int
|
||||||
// Guest is a computed, non-persisted view used for subscribers accessing a
|
// Guest is a computed, non-persisted view used for subscribers accessing a
|
||||||
// private linked discussion group without joining it. Guest must never be
|
// private linked discussion group without joining it. Guest must never be
|
||||||
// written to channel_members and is still projected to clients as left.
|
// written to channel_members and is still projected to clients as left.
|
||||||
|
|
@ -545,22 +551,24 @@ func (m ChannelMember) CanPostChannelMessages() bool {
|
||||||
|
|
||||||
// ChannelDialog is the current user's owner-view dialog state for a channel.
|
// ChannelDialog is the current user's owner-view dialog state for a channel.
|
||||||
type ChannelDialog struct {
|
type ChannelDialog struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
ChannelID int64
|
ChannelID int64
|
||||||
FolderID int
|
FolderID int
|
||||||
TopMessageID int
|
TopMessageID int
|
||||||
TopMessageDate int
|
TopMessageDate int
|
||||||
ReadInboxMaxID int
|
HistoryClearAnchorID int
|
||||||
ReadOutboxMaxID int
|
HistoryClearAnchorDate int
|
||||||
UnreadCount int
|
ReadInboxMaxID int
|
||||||
UnreadMentions int
|
ReadOutboxMaxID int
|
||||||
UnreadReactions int
|
UnreadCount int
|
||||||
Pinned bool
|
UnreadMentions int
|
||||||
PinnedOrder int
|
UnreadReactions int
|
||||||
UnreadMark bool
|
Pinned bool
|
||||||
ViewForumAsMessages bool
|
PinnedOrder int
|
||||||
HasScheduled bool
|
UnreadMark bool
|
||||||
DefaultSendAs *Peer
|
ViewForumAsMessages bool
|
||||||
|
HasScheduled bool
|
||||||
|
DefaultSendAs *Peer
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMessageActionType identifies service messages generated by channel operations.
|
// ChannelMessageActionType identifies service messages generated by channel operations.
|
||||||
|
|
@ -573,7 +581,11 @@ 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"
|
||||||
ChannelActionChatJoined ChannelMessageActionType = "chat_joined"
|
// 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"
|
||||||
// ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息,
|
// ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息,
|
||||||
// 渲染为 "X joined the group via invite link"。
|
// 渲染为 "X joined the group via invite link"。
|
||||||
ChannelActionChatJoinedByLink ChannelMessageActionType = "chat_joined_by_link"
|
ChannelActionChatJoinedByLink ChannelMessageActionType = "chat_joined_by_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,19 +1335,27 @@ type ChannelDifference struct {
|
||||||
Events []ChannelUpdateEvent
|
Events []ChannelUpdateEvent
|
||||||
NewMessages []ChannelMessage
|
NewMessages []ChannelMessage
|
||||||
OtherUpdates []ChannelUpdateEvent
|
OtherUpdates []ChannelUpdateEvent
|
||||||
Users []User
|
// AvailableMinID is an owner-local, absolute no-PTS boundary appended to a
|
||||||
Channels []Channel
|
// normal channel difference. It must never be represented as a synthetic
|
||||||
Pts int
|
// ChannelUpdateEvent because updateChannelAvailableMessages has no pts.
|
||||||
Final bool
|
AvailableMinID int
|
||||||
TooLong bool
|
Users []User
|
||||||
Dialog ChannelDialog
|
Channels []Channel
|
||||||
Timeout int
|
Pts int
|
||||||
|
Final bool
|
||||||
|
TooLong bool
|
||||||
|
Dialog ChannelDialog
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -69,24 +69,29 @@ func (p Peer) IsSelfUser(userID int64) bool {
|
||||||
|
|
||||||
// Dialog 是账号的一条会话摘要。
|
// Dialog 是账号的一条会话摘要。
|
||||||
type Dialog struct {
|
type Dialog struct {
|
||||||
Peer Peer
|
Peer Peer
|
||||||
ChannelLeft bool
|
ChannelLeft bool
|
||||||
FolderID int
|
FolderID int
|
||||||
TopMessage int
|
TopMessage int
|
||||||
TopMessageDate int
|
TopMessageDate int
|
||||||
ReadInboxMaxID int
|
// HistoryClearAnchorID/Date are internal owner-view projection metadata.
|
||||||
ReadOutboxMaxID int
|
// They are not TL dialog fields; dialog response assembly uses them to
|
||||||
UnreadCount int
|
// materialize the matching messageActionHistoryClear top payload.
|
||||||
UnreadMentions int
|
HistoryClearAnchorID int
|
||||||
UnreadReactions int
|
HistoryClearAnchorDate int
|
||||||
TTLPeriod int
|
ReadInboxMaxID int
|
||||||
ThemeEmoticon string
|
ReadOutboxMaxID int
|
||||||
HasScheduled bool
|
UnreadCount int
|
||||||
Pinned bool
|
UnreadMentions int
|
||||||
PinnedOrder int
|
UnreadReactions int
|
||||||
UnreadMark bool
|
TTLPeriod int
|
||||||
ViewForumAsMessages bool
|
ThemeEmoticon string
|
||||||
PeerSettingsBarHidden bool
|
HasScheduled bool
|
||||||
|
Pinned bool
|
||||||
|
PinnedOrder int
|
||||||
|
UnreadMark bool
|
||||||
|
ViewForumAsMessages bool
|
||||||
|
PeerSettingsBarHidden bool
|
||||||
// Pts 是 channel peer 当前 channel pts;客户端用 dialog.pts 初始化本地
|
// Pts 是 channel peer 当前 channel pts;客户端用 dialog.pts 初始化本地
|
||||||
// channel 序列并决定 getChannelDifference 起点,channel dialog 必填。
|
// channel 序列并决定 getChannelDifference 起点,channel dialog 必填。
|
||||||
Pts int
|
Pts int
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -178,7 +176,10 @@ type UpdateDifference struct {
|
||||||
|
|
||||||
// ChannelDifferenceNudge is a computed account-level hint that a channel diff is dirty.
|
// ChannelDifferenceNudge is a computed account-level hint that a channel diff is dirty.
|
||||||
type ChannelDifferenceNudge struct {
|
type ChannelDifferenceNudge struct {
|
||||||
ChannelID int64
|
ChannelID int64
|
||||||
Pts int
|
Pts int
|
||||||
Channel *ChannelView
|
ChannelUpdatesDirty bool
|
||||||
|
AvailableMinID int
|
||||||
|
HistoryClearDate int
|
||||||
|
Channel *ChannelView
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
r.pushUserUpdates(ctx, userID, updates)
|
// 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)
|
||||||
|
}
|
||||||
return updates, nil
|
return updates, nil
|
||||||
}
|
}
|
||||||
pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates {
|
pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,23 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
|
||||||
if nudge.ChannelID == 0 {
|
if nudge.ChannelID == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
|
if nudge.AvailableMinID > 0 {
|
||||||
if nudge.Pts > 0 {
|
out.OtherUpdates = append(out.OtherUpdates, &tg.UpdateChannelAvailableMessages{
|
||||||
update.SetPts(nudge.Pts)
|
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}
|
||||||
|
if nudge.Pts > 0 {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil
|
// messages.affectedHistory.pts is the caller's account-state snapshot.
|
||||||
|
// 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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -604,14 +604,15 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
||||||
return &tg.MessagesMessages{}, nil
|
return &tg.MessagesMessages{}, nil
|
||||||
}
|
}
|
||||||
history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{
|
history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{
|
||||||
ChannelID: filter.Peer.ID,
|
ChannelID: filter.Peer.ID,
|
||||||
OffsetID: filter.OffsetID,
|
OffsetID: filter.OffsetID,
|
||||||
OffsetDate: filter.OffsetDate,
|
OffsetDate: filter.OffsetDate,
|
||||||
AddOffset: filter.AddOffset,
|
AddOffset: filter.AddOffset,
|
||||||
Limit: filter.Limit,
|
Limit: filter.Limit,
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -470,36 +470,47 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberSt
|
||||||
return domain.Dialog{
|
return domain.Dialog{
|
||||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||||
// 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。
|
// 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。
|
||||||
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
|
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
|
||||||
FolderID: dialog.FolderID,
|
FolderID: dialog.FolderID,
|
||||||
TopMessage: dialog.TopMessageID,
|
TopMessage: dialog.TopMessageID,
|
||||||
TopMessageDate: dialog.TopMessageDate,
|
TopMessageDate: dialog.TopMessageDate,
|
||||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||||
UnreadCount: dialog.UnreadCount,
|
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||||
UnreadMentions: dialog.UnreadMentions,
|
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||||
UnreadReactions: dialog.UnreadReactions,
|
UnreadCount: dialog.UnreadCount,
|
||||||
Pinned: dialog.Pinned,
|
UnreadMentions: dialog.UnreadMentions,
|
||||||
PinnedOrder: dialog.PinnedOrder,
|
UnreadReactions: dialog.UnreadReactions,
|
||||||
UnreadMark: dialog.UnreadMark,
|
Pinned: dialog.Pinned,
|
||||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
PinnedOrder: dialog.PinnedOrder,
|
||||||
HasScheduled: dialog.HasScheduled,
|
UnreadMark: dialog.UnreadMark,
|
||||||
Pts: channelPts,
|
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||||
|
HasScheduled: dialog.HasScheduled,
|
||||||
|
Pts: channelPts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = 0
|
topMessageID = member.HistoryClearAnchorID
|
||||||
|
if topMessageID != member.AvailableMinID {
|
||||||
|
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,
|
||||||
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
|
HistoryClearAnchorID: member.HistoryClearAnchorID,
|
||||||
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
|
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
|
||||||
|
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
|
||||||
|
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,19 @@ type ChannelStore struct {
|
||||||
messages map[int64][]domain.ChannelMessage
|
messages map[int64][]domain.ChannelMessage
|
||||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||||
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
||||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||||
top map[int64]map[string]domain.TopMessageReaction
|
top map[int64]map[string]domain.TopMessageReaction
|
||||||
recent map[int64]map[string]domain.RecentMessageReaction
|
recent map[int64]map[string]domain.RecentMessageReaction
|
||||||
mentions map[int64]map[int64]map[int]memoryMention
|
mentions map[int64]map[int64]map[int]memoryMention
|
||||||
msgViews map[int64]map[int]int
|
msgViews map[int64]map[int]int
|
||||||
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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,27 +1292,30 @@ 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
|
||||||
}
|
}
|
||||||
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
|
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
|
||||||
dialog := domain.Dialog{
|
dialog := domain.Dialog{
|
||||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
|
||||||
FolderID: folderID,
|
FolderID: folderID,
|
||||||
TopMessage: topID,
|
TopMessage: topID,
|
||||||
TopMessageDate: topDate,
|
TopMessageDate: topDate,
|
||||||
ReadInboxMaxID: readInbox,
|
HistoryClearAnchorID: historyClearAnchorID,
|
||||||
ReadOutboxMaxID: readOutbox,
|
HistoryClearAnchorDate: historyClearAnchorDate,
|
||||||
UnreadCount: unreadCount,
|
ReadInboxMaxID: readInbox,
|
||||||
UnreadMentions: unreadMentions,
|
ReadOutboxMaxID: readOutbox,
|
||||||
UnreadReactions: unreadReactions,
|
UnreadCount: unreadCount,
|
||||||
Pinned: pinned,
|
UnreadMentions: unreadMentions,
|
||||||
PinnedOrder: pinnedOrder,
|
UnreadReactions: unreadReactions,
|
||||||
UnreadMark: unreadMark,
|
Pinned: pinned,
|
||||||
ViewForumAsMessages: viewForumAsMessages,
|
PinnedOrder: pinnedOrder,
|
||||||
HasScheduled: hasScheduled,
|
UnreadMark: unreadMark,
|
||||||
Pts: ch.Pts,
|
ViewForumAsMessages: viewForumAsMessages,
|
||||||
|
HasScheduled: hasScheduled,
|
||||||
|
Pts: ch.Pts,
|
||||||
}
|
}
|
||||||
var defaultSendAs *domain.Peer
|
var defaultSendAs *domain.Peer
|
||||||
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
|
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
|
||||||
|
|
@ -1236,41 +1327,45 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
||||||
|
|
||||||
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
|
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
|
||||||
return domain.Dialog{
|
return domain.Dialog{
|
||||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||||
FolderID: dialog.FolderID,
|
FolderID: dialog.FolderID,
|
||||||
TopMessage: dialog.TopMessageID,
|
TopMessage: dialog.TopMessageID,
|
||||||
TopMessageDate: dialog.TopMessageDate,
|
TopMessageDate: dialog.TopMessageDate,
|
||||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||||
UnreadCount: dialog.UnreadCount,
|
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||||
UnreadMentions: dialog.UnreadMentions,
|
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||||
UnreadReactions: dialog.UnreadReactions,
|
UnreadCount: dialog.UnreadCount,
|
||||||
Pinned: dialog.Pinned,
|
UnreadMentions: dialog.UnreadMentions,
|
||||||
PinnedOrder: dialog.PinnedOrder,
|
UnreadReactions: dialog.UnreadReactions,
|
||||||
UnreadMark: dialog.UnreadMark,
|
Pinned: dialog.Pinned,
|
||||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
PinnedOrder: dialog.PinnedOrder,
|
||||||
HasScheduled: dialog.HasScheduled,
|
UnreadMark: dialog.UnreadMark,
|
||||||
Pts: channelPts,
|
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||||
|
HasScheduled: dialog.HasScheduled,
|
||||||
|
Pts: channelPts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog {
|
func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog {
|
||||||
return domain.ChannelDialog{
|
return domain.ChannelDialog{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ChannelID: dialog.Peer.ID,
|
ChannelID: dialog.Peer.ID,
|
||||||
FolderID: dialog.FolderID,
|
FolderID: dialog.FolderID,
|
||||||
TopMessageID: dialog.TopMessage,
|
TopMessageID: dialog.TopMessage,
|
||||||
TopMessageDate: dialog.TopMessageDate,
|
TopMessageDate: dialog.TopMessageDate,
|
||||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||||
UnreadCount: dialog.UnreadCount,
|
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||||
UnreadMentions: dialog.UnreadMentions,
|
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||||
UnreadReactions: dialog.UnreadReactions,
|
UnreadCount: dialog.UnreadCount,
|
||||||
Pinned: dialog.Pinned,
|
UnreadMentions: dialog.UnreadMentions,
|
||||||
PinnedOrder: dialog.PinnedOrder,
|
UnreadReactions: dialog.UnreadReactions,
|
||||||
UnreadMark: dialog.UnreadMark,
|
Pinned: dialog.Pinned,
|
||||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
PinnedOrder: dialog.PinnedOrder,
|
||||||
HasScheduled: dialog.HasScheduled,
|
UnreadMark: dialog.UnreadMark,
|
||||||
|
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||||
|
HasScheduled: dialog.HasScheduled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1392,14 +1487,23 @@ 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 = 0
|
topMessageID = member.HistoryClearAnchorID
|
||||||
|
if topMessageID != member.AvailableMinID {
|
||||||
|
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,
|
||||||
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
|
HistoryClearAnchorID: member.HistoryClearAnchorID,
|
||||||
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
|
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
|
||||||
|
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
|
||||||
|
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.
|
||||||
}
|
}
|
||||||
topMessageID := channel.TopMessageID
|
topMessageID := channel.TopMessageID
|
||||||
if topMessageID <= member.AvailableMinID {
|
if topMessageID <= member.AvailableMinID {
|
||||||
topMessageID = 0
|
topMessageID = member.HistoryClearAnchorID
|
||||||
|
if topMessageID != member.AvailableMinID {
|
||||||
|
topMessageID = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := s.db.Exec(ctx, `
|
if _, err := s.db.Exec(ctx, `
|
||||||
INSERT INTO channel_dialogs (
|
INSERT INTO channel_dialogs (
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue