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
|
|
@ -317,6 +317,7 @@ func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, chan
|
|||
SELECT `+channelColumns+`,
|
||||
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.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
|
||||
FROM channels c
|
||||
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
|
||||
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,
|
||||
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
|
||||
WHERE channel_id = $1`, channel.ID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,26 @@ type channelDialogListItem struct {
|
|||
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) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
|
|
@ -33,8 +53,8 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
|
|||
if len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
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"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
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)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
args := []any{viewerUserID, channelIDs}
|
||||
|
|
@ -139,7 +159,9 @@ SELECT `+channelColumns+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
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
|
||||
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
|
||||
|
|
@ -232,6 +254,7 @@ LIMIT `+limitArg, args...)
|
|||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +278,9 @@ SELECT `+channelColumns+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
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
|
||||
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
|
||||
|
|
@ -357,6 +382,20 @@ WHERE `+where.String(), args...)
|
|||
if err := rows.Err(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +452,14 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
|
|||
}
|
||||
}
|
||||
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 {
|
||||
dialog.TopMessageDate = msg.Date
|
||||
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 {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
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) {
|
||||
if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 {
|
||||
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, `
|
||||
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,
|
||||
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
|
||||
WHERE user_id = $1
|
||||
|
|
@ -603,8 +676,8 @@ func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, l
|
|||
if len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
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"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
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)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
|
|
@ -619,7 +692,9 @@ SELECT `+channelColumns+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
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
|
||||
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
|
||||
|
|
@ -856,6 +931,7 @@ func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64,
|
|||
// 归档必须 ensure-INSERT:从未读过/置顶过的频道还没有 dialog 行,
|
||||
// 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位,
|
||||
// 避免 0 水位缓存行遮蔽未读/已读状态。
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
WITH requested AS (
|
||||
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)
|
||||
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,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
|
|
@ -904,7 +980,7 @@ func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int
|
|||
// JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。
|
||||
// unread 读时动态派生(H4a):不再消费 channel_dialogs.unread_count 缓存列;归档集合
|
||||
// 有界(需显式归档建行),每行动态 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)"
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
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}
|
||||
var defaultSendAsType sql.NullString
|
||||
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)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
// 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次
|
||||
|
|
@ -946,7 +1022,10 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
|
|||
// message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退
|
||||
// COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态
|
||||
// (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, `
|
||||
SELECT `+visibleTopID+`,
|
||||
`+visibleTopDate+`,
|
||||
|
|
@ -962,7 +1041,9 @@ SELECT `+visibleTopID+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
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
|
||||
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
|
||||
|
|
@ -983,6 +1064,8 @@ WHERE c.id = $2`, userID, channel.ID).Scan(
|
|||
&dialog.HasScheduled,
|
||||
&defaultSendAsType,
|
||||
&defaultSendAsID,
|
||||
&dialog.HistoryClearAnchorID,
|
||||
&dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
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"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
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)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
rows, err := db.Query(ctx, `
|
||||
|
|
@ -1057,7 +1140,9 @@ SELECT c.id,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
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
|
||||
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
|
||||
|
|
@ -1089,6 +1174,8 @@ WHERE c.id = ANY($2::bigint[])`, userID, channelIDs)
|
|||
&dialog.HasScheduled,
|
||||
&defaultSendAsType,
|
||||
&defaultSendAsID,
|
||||
&dialog.HistoryClearAnchorID,
|
||||
&dialog.HistoryClearAnchorDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1197,6 +1284,7 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
|||
var rights, reactionPolicy string
|
||||
var wallpaper *string
|
||||
var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int
|
||||
var historyClearAnchorID, historyClearAnchorDate int
|
||||
var pinned, unreadMark, viewForumAsMessages, hasScheduled bool
|
||||
var defaultSendAsType sql.NullString
|
||||
var defaultSendAsID sql.NullInt64
|
||||
|
|
@ -1204,27 +1292,30 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
|||
&topID, &topDate,
|
||||
&folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled,
|
||||
&defaultSendAsType, &defaultSendAsID,
|
||||
&historyClearAnchorID, &historyClearAnchorDate,
|
||||
)
|
||||
if err := row.Scan(dest...); err != nil {
|
||||
return domain.Channel{}, domain.Dialog{}, nil, err
|
||||
}
|
||||
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
|
||||
dialog := domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
|
||||
FolderID: folderID,
|
||||
TopMessage: topID,
|
||||
TopMessageDate: topDate,
|
||||
ReadInboxMaxID: readInbox,
|
||||
ReadOutboxMaxID: readOutbox,
|
||||
UnreadCount: unreadCount,
|
||||
UnreadMentions: unreadMentions,
|
||||
UnreadReactions: unreadReactions,
|
||||
Pinned: pinned,
|
||||
PinnedOrder: pinnedOrder,
|
||||
UnreadMark: unreadMark,
|
||||
ViewForumAsMessages: viewForumAsMessages,
|
||||
HasScheduled: hasScheduled,
|
||||
Pts: ch.Pts,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
|
||||
FolderID: folderID,
|
||||
TopMessage: topID,
|
||||
TopMessageDate: topDate,
|
||||
HistoryClearAnchorID: historyClearAnchorID,
|
||||
HistoryClearAnchorDate: historyClearAnchorDate,
|
||||
ReadInboxMaxID: readInbox,
|
||||
ReadOutboxMaxID: readOutbox,
|
||||
UnreadCount: unreadCount,
|
||||
UnreadMentions: unreadMentions,
|
||||
UnreadReactions: unreadReactions,
|
||||
Pinned: pinned,
|
||||
PinnedOrder: pinnedOrder,
|
||||
UnreadMark: unreadMark,
|
||||
ViewForumAsMessages: viewForumAsMessages,
|
||||
HasScheduled: hasScheduled,
|
||||
Pts: ch.Pts,
|
||||
}
|
||||
var defaultSendAs *domain.Peer
|
||||
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 {
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
Pts: channelPts,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
Pts: channelPts,
|
||||
}
|
||||
}
|
||||
|
||||
func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog {
|
||||
return domain.ChannelDialog{
|
||||
UserID: userID,
|
||||
ChannelID: dialog.Peer.ID,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessageID: dialog.TopMessage,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
UserID: userID,
|
||||
ChannelID: dialog.Peer.ID,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessageID: dialog.TopMessage,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
|
||||
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
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 {
|
||||
topMessageID := channel.TopMessageID
|
||||
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{
|
||||
UserID: userID,
|
||||
ChannelID: channel.ID,
|
||||
TopMessageID: topMessageID,
|
||||
TopMessageDate: channel.Date,
|
||||
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
|
||||
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
|
||||
UserID: userID,
|
||||
ChannelID: channel.ID,
|
||||
TopMessageID: topMessageID,
|
||||
TopMessageDate: topMessageDate,
|
||||
HistoryClearAnchorID: member.HistoryClearAnchorID,
|
||||
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
|
||||
if topMessageID <= member.AvailableMinID {
|
||||
topMessageID = 0
|
||||
topMessageID = member.HistoryClearAnchorID
|
||||
if topMessageID != member.AvailableMinID {
|
||||
topMessageID = 0
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
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)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.DirtyChannel, 0, limit)
|
||||
byChannelID := make(map[int64]domain.DirtyChannel, limit*2)
|
||||
for rows.Next() {
|
||||
var item domain.DirtyChannel
|
||||
if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userI
|
|||
row := db.QueryRow(ctx, `
|
||||
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,
|
||||
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
|
||||
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, `
|
||||
INSERT INTO channel_members (
|
||||
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
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
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
|
||||
) 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
|
||||
inviter_user_id = EXCLUDED.inviter_user_id,
|
||||
role = EXCLUDED.role,
|
||||
|
|
@ -231,7 +233,8 @@ ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
|||
updated_at = now()`,
|
||||
member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status),
|
||||
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 upsertUserChannelMemberIndexTx(ctx, tx, channel, member)
|
||||
|
|
@ -316,7 +319,8 @@ func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember
|
|||
dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper),
|
||||
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
|
||||
&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 {
|
||||
return domain.Channel{}, domain.ChannelMember{}, err
|
||||
|
|
@ -351,7 +355,8 @@ func scanChannelMember(row rowScanner) (domain.ChannelMember, error) {
|
|||
if err := row.Scan(
|
||||
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
|
||||
&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 {
|
||||
return domain.ChannelMember{}, err
|
||||
}
|
||||
|
|
@ -370,7 +375,8 @@ func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, erro
|
|||
if err := row.Scan(
|
||||
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
|
||||
&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,
|
||||
); err != nil {
|
||||
return domain.ChannelMember{}, 0, err
|
||||
|
|
@ -471,6 +477,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
|
|||
}
|
||||
member.AvailableMinID = 0
|
||||
member.AvailableMinPts = 0
|
||||
member.HistoryClearAnchorID = 0
|
||||
member.HistoryClearAnchorDate = 0
|
||||
member.ReadInboxMaxID = mono.TopMessageID
|
||||
member.ReadOutboxMaxID = mono.TopMessageID
|
||||
member.UnreadMark = false
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.D
|
|||
member, err := scanChannelMember(db.QueryRow(ctx, `
|
||||
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,
|
||||
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
|
||||
WHERE channel_id = $1
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channe
|
|||
}
|
||||
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,
|
||||
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+`
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
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, `
|
||||
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
|
||||
WHERE channel_id = $1 AND status = 'active'
|
||||
ORDER BY user_id
|
||||
|
|
@ -264,6 +266,7 @@ func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUs
|
|||
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,
|
||||
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,
|
||||
COUNT(*) OVER()::int
|
||||
FROM bots b
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"sort"
|
||||
|
|
@ -223,19 +224,68 @@ func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.Dele
|
|||
}
|
||||
if !req.ForEveryone {
|
||||
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)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
if topID == 0 && anchorID > 0 && anchorID == appliedMinID {
|
||||
topID = anchorID
|
||||
topDate = anchorDate
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE channel_members
|
||||
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),
|
||||
unread_mark = false,
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ AND EXISTS (
|
|||
baseArgs = append(baseArgs, filter.MinID)
|
||||
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}
|
||||
needExactTotal := filter.NeedTotalCount || filter.CountOnly
|
||||
exactTotal := 0
|
||||
|
|
@ -92,6 +96,9 @@ AND EXISTS (
|
|||
).Scan(&exactTotal); err != nil {
|
||||
return domain.ChannelHistory{}, fmt.Errorf("count channel history: %w", err)
|
||||
}
|
||||
if hasHistoryClearAnchor {
|
||||
exactTotal++
|
||||
}
|
||||
out.Count = exactTotal
|
||||
}
|
||||
if filter.CountOnly {
|
||||
|
|
@ -134,6 +141,18 @@ AND EXISTS (
|
|||
}
|
||||
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 {
|
||||
if filter.OffsetDate > 0 {
|
||||
*args = append(*args, filter.OffsetDate)
|
||||
|
|
@ -145,6 +164,30 @@ AND EXISTS (
|
|||
}
|
||||
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 {
|
||||
case addOffset < 0 && addOffset+limit > 0:
|
||||
// around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
|
||||
|
|
@ -157,6 +200,12 @@ AND EXISTS (
|
|||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesForward() {
|
||||
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
|
||||
if len(newer) > fwdLimit {
|
||||
newer = newer[:fwdLimit]
|
||||
}
|
||||
}
|
||||
bwdArgs := append([]any{}, baseArgs...)
|
||||
bwdWhere := aroundOlderCond(&bwdArgs)
|
||||
bwdArgs = append(bwdArgs, bwdLimit+1)
|
||||
|
|
@ -164,6 +213,9 @@ AND EXISTS (
|
|||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesAroundOlder() {
|
||||
older = append(older, historyClearAnchor)
|
||||
}
|
||||
if len(older) > bwdLimit {
|
||||
older = older[:bwdLimit]
|
||||
hasMoreOlder = true
|
||||
|
|
@ -181,6 +233,9 @@ AND EXISTS (
|
|||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesForward() {
|
||||
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
|
||||
}
|
||||
if len(newer) > limit {
|
||||
newer = newer[:limit]
|
||||
}
|
||||
|
|
@ -198,18 +253,26 @@ AND EXISTS (
|
|||
args = append(args, filter.OffsetID)
|
||||
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)
|
||||
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC"
|
||||
if addOffset > 0 {
|
||||
args = append(args, addOffset)
|
||||
sql += fmt.Sprintf(" OFFSET $%d", len(args))
|
||||
}
|
||||
sql += fmt.Sprintf(" LIMIT $%d", limIdx)
|
||||
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where +
|
||||
fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", limIdx)
|
||||
older, err := scanList(sql, args)
|
||||
if err != nil {
|
||||
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 {
|
||||
older = older[:limit]
|
||||
hasMoreOlder = true
|
||||
|
|
@ -228,9 +291,63 @@ AND EXISTS (
|
|||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
query := strings.TrimSpace(req.Query)
|
||||
if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
|
||||
|
|
@ -390,8 +507,15 @@ func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUs
|
|||
// 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory
|
||||
// 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序
|
||||
// (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。
|
||||
args := []any{channel.ID, id32, member.AvailableMinID}
|
||||
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)"
|
||||
anchorID := 0
|
||||
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, `
|
||||
SELECT `+channelMessageColumns+`
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
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{
|
||||
UserID: friend.ID,
|
||||
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 {
|
||||
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{
|
||||
UserID: friend.ID,
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel after clear: %v", err)
|
||||
}
|
||||
if afterClear.Dialog.TopMessageID != 0 {
|
||||
t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog)
|
||||
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
|
||||
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{
|
||||
|
|
@ -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 {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -574,8 +574,10 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop,
|
|||
if err != nil {
|
||||
t.Fatalf("get large channel after local clear: %v", err)
|
||||
}
|
||||
if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 {
|
||||
t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog)
|
||||
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
|
||||
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 {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 158 {
|
||||
t.Fatalf("migration status = %+v, want clean version 158", status)
|
||||
if status.Dirty || status.Empty || status.Version != 162 {
|
||||
t.Fatalf("migration status = %+v, want clean version 162", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue