internal/store/postgres/channel_dialogs.go
Rama main · réplica de lectura del repositorio original.
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sort"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type channelDialogListItem struct {
channel domain.Channel
dialog domain.Dialog
defaultSendAs *domain.Peer
topMentioned bool
topMediaUnread bool
topUnreadProjected bool
listCacheable bool
}
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) {
return s.listChannelDialogs(ctx, viewerUserID, filter)
}
func (s *ChannelStore) listChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
}
limit := filter.Limit
if limit <= 0 || limit > 100 {
limit = 100
}
channelIDs, hasBroadcastAdmin, err := s.listActiveChannelDialogCandidateIDs(ctx, viewerUserID, false)
if err != nil {
return domain.ChannelDialogList{}, err
}
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
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}
where := []string{"m.user_id = $1", "m.channel_id = ANY($2::bigint[])", "m.status = 'active'"}
from := `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
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id`
// The archive and pinned sets always have explicit channel_dialogs rows. Use that owner index as the entry
// to avoid archive summary(limit=1) and getPinnedDialogs scanning all of the account's active memberships
// for a collection that is usually empty/tiny. Keep the $2 typed no-op so placeholder numbering for later
// dynamic conditions needs no branching; monoforum is still merged after the main query via the original permission path.
if (filter.HasFolderID && filter.FolderID == domain.DialogArchiveFolderID) || filter.PinnedOnly {
from = `FROM channel_dialogs d
JOIN channel_members m ON m.user_id = d.user_id AND m.channel_id = d.channel_id AND m.status = 'active'
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = d.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted`
where = []string{"d.user_id = $1", "cardinality($2::bigint[]) >= 0"}
}
if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID {
args = append(args, filter.FolderID)
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args)))
} else if !filter.HasFolderID {
// No folder_id is treated as the main list (folder 0), consistent with the private-chat side / official semantics.
where = append(where, "COALESCE(d.folder_id, 0) = 0")
}
if filter.PinnedOnly {
where = append(where, "COALESCE(d.pinned, false)")
}
if filter.ExcludePinned {
where = append(where, "NOT COALESCE(d.pinned, false)")
}
switch {
case filter.OffsetDate > 0:
args = append(args, filter.OffsetDate, filter.OffsetID)
dateArg := fmt.Sprintf("$%d", len(args)-1)
idArg := fmt.Sprintf("$%d", len(args))
if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 {
args = append(args, filter.OffsetPeer.ID)
peerArg := fmt.Sprintf("$%d", len(args))
where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s) OR (%s = %s AND %s = %s AND c.id < %s))",
visibleTopDate, dateArg,
visibleTopDate, dateArg, visibleTopID, idArg,
visibleTopDate, dateArg, visibleTopID, idArg, peerArg))
} else {
where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s))",
visibleTopDate, dateArg,
visibleTopDate, dateArg, visibleTopID, idArg))
}
case filter.OffsetID > 0:
args = append(args, filter.OffsetID)
idArg := fmt.Sprintf("$%d", len(args))
if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 {
args = append(args, filter.OffsetPeer.ID)
peerArg := fmt.Sprintf("$%d", len(args))
where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND c.id < %s))",
visibleTopID, idArg, visibleTopID, idArg, peerArg))
} else {
where = append(where, fmt.Sprintf("%s < %s", visibleTopID, idArg))
}
case filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0:
args = append(args, filter.OffsetPeer.ID)
where = append(where, fmt.Sprintf("c.id <> $%d", len(args)))
}
if filter.Folder != nil {
folder := filter.Folder
if folder.ExcludeArchived {
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) <> %d", domain.DialogArchiveFolderID))
}
if folder.ExcludeRead {
where = append(where, fmt.Sprintf(`(COALESCE(d.unread_mark, m.unread_mark) OR %s)`,
channelDialogHasUnreadSQL(visibleReadInbox, visibleTopID)))
}
if excludeIDs := channelFolderPeerIDs(folder.ExcludePeers); len(excludeIDs) > 0 {
args = append(args, excludeIDs)
where = append(where, fmt.Sprintf("NOT (c.id = ANY($%d::bigint[]))", len(args)))
}
includeIDs := channelFolderPeerIDs(folder.IncludePeers, folder.PinnedPeers)
include := make([]string, 0, 3)
if len(includeIDs) > 0 {
args = append(args, includeIDs)
include = append(include, fmt.Sprintf("c.id = ANY($%d::bigint[])", len(args)))
}
if folder.Groups {
include = append(include, "c.megagroup")
}
if folder.Broadcasts {
include = append(include, "c.broadcast")
}
if len(include) > 0 {
where = append(where, "("+strings.Join(include, " OR ")+")")
} else {
// When a custom filter declares no positive channel-dimension condition (only private-chat
// category switches like Contacts/NonContacts/Bots), include no groups/channels—matching the
// final return false of channelDialogMatchesFilter, to avoid pouring all groups/channels the user joined into that folder.
where = append(where, "false")
}
}
// Fetch only one extra row as has-more evidence. The old implementation ignored the RPC limit and always read 500 full
// channel + viewer dialogs, deriving unread dynamically per row; getDialogs(limit=100)
// therefore hydrated up to 5x the objects, and archive summary(limit=1) was amplified up to 500x.
// All folder/offset conditions are already applied before the SQL LIMIT, and monoforum results are also
// merged and sorted below, so taking limit+1 per source is enough to build the correct merged page and continue-pagination signal.
queryLimit := limit + 1
args = append(args, queryLimit)
limitArg := fmt.Sprintf("$%d", len(args))
// Warm write-back epoch guard: snapshot before loading; on write-back, reject the stale projection if an
// invalidation arrived meanwhile (epoch changed), preventing a concurrent invalidation in the scan→put window from being overwritten by a bare Store (@ badge/unread lost-update).
dialogCacheActive := s.dialogCacheActive(s.db)
var dialogCacheEpoch uint64
if dialogCacheActive {
dialogCacheEpoch = s.dialogCache.cacheEpoch()
}
rows, err := s.db.Query(ctx, `
SELECT `+channelColumns+`,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0), `+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)), `+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
`+from+`
WHERE `+strings.Join(where, " AND ")+`
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
`+visibleTopDate+` DESC,
`+visibleTopID+` DESC,
c.id DESC
LIMIT `+limitArg, args...)
if err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list channel dialogs: %w", err)
}
defer rows.Close()
items := make([]channelDialogListItem, 0, limit)
seenChannels := make(map[int64]struct{}, limit)
for rows.Next() {
ch, dialog, defaultSendAs, err := scanChannelDialogRow(rows, viewerUserID)
if err != nil {
return domain.ChannelDialogList{}, err
}
if !channelDialogMatchesFilter(dialog, ch, filter) {
continue
}
items = append(items, channelDialogListItem{channel: ch, dialog: dialog, defaultSendAs: defaultSendAs})
seenChannels[ch.ID] = struct{}{}
}
if err := rows.Err(); err != nil {
return domain.ChannelDialogList{}, err
}
// hasBroadcastAdmin is a page-independent superset gate: only a user who is creator/admin in some
// broadcast channel can have a monoforum direct-messages dialog (linked_monoforum_id<>0 is a subset of that set). Most getDialogs callers do not match,
// so this skips the extra multi-table JOIN round trip of listMonoforumAdminDialogItems (previously run unconditionally on every getDialogs).
// It does not use a row-level flag in the main scan loop, because the main query carries an offset cursor: when paging to later pages the parent broadcast channel may already have been consumed by the
// previous page and no longer scanned, while its direct-messages dialog (independent sort position) still belongs to this page—a row-level flag would miss it. The superset gate reads the full candidate set and is page-independent.
if hasBroadcastAdmin {
monoItems, err := s.listMonoforumAdminDialogItems(ctx, viewerUserID, channelIDs, filter, seenChannels)
if err != nil {
return domain.ChannelDialogList{}, err
}
items = append(items, monoItems...)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].dialog.Pinned != items[j].dialog.Pinned {
return items[i].dialog.Pinned
}
if items[i].dialog.PinnedOrder != items[j].dialog.PinnedOrder {
return items[i].dialog.PinnedOrder > items[j].dialog.PinnedOrder
}
if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate {
return items[i].dialog.TopMessageDate > items[j].dialog.TopMessageDate
}
if items[i].dialog.TopMessage != items[j].dialog.TopMessage {
return items[i].dialog.TopMessage > items[j].dialog.TopMessage
}
return items[i].dialog.Peer.ID > items[j].dialog.Peer.ID
})
out := domain.ChannelDialogList{Count: len(items)}
if len(items) > limit {
items = items[:limit]
}
dialogs := make([]domain.Dialog, 0, len(items))
for _, item := range items {
dialogs = append(dialogs, item.dialog)
}
topMessages, err := s.channelDialogTopMessages(ctx, s.db, dialogs)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, item := range items {
msg := topMessages[channelMessageLookupKey{channelID: item.channel.ID, id: item.dialog.TopMessage}]
if msg.ID != 0 {
item.dialog.TopMessageDate = msg.Date
out.Messages = append(out.Messages, msg)
}
if dialogCacheActive {
// The list warm-cache value must be equivalent to the single-channel full read path: add the
// default_send_as that exists only in channel_dialogs, otherwise getFullChannel/getSendAs hitting the warm cache would lose the "send as channel" default.
cd := channelDialogFromDialog(viewerUserID, item.dialog)
cd.DefaultSendAs = item.defaultSendAs
s.dialogCache.putListProjectionIfEpoch(cd, false, false, false, dialogCacheEpoch)
}
out.Dialogs = append(out.Dialogs, item.dialog)
out.Channels = append(out.Channels, item.channel)
}
// getDialogs' top message must be augmented per viewer with mentioned/media_unread and
// reactions: TDesktop caches it first and it is not overwritten by the
// full version from later difference/getHistory, so missing flags keep the client from ever reporting contents-read, and the @ badge returns after restart.
if err := s.populateChannelDialogTopMessageReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages, false); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
// ListChannelDialogSnapshotHeaders builds the bounded owner-specific ordering
// index used by app pagination. It intentionally excludes channel metadata and
// top-message payloads; those are hydrated per page through versioned peer read
// models, so one owner snapshot remains lightweight and shared channel facts do
// not get duplicated for every online account.
func (s *ChannelStore) ListChannelDialogSnapshotHeaders(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
}
if filter.Folder != nil || (filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID) ||
filter.OffsetDate != 0 || filter.OffsetID != 0 || filter.HasOffsetPeer {
return domain.ChannelDialogList{}, errors.New("channel dialog snapshot headers require an offset-free built-in folder")
}
channelIDs, hasBroadcastAdmin, err := s.listActiveChannelDialogCandidateIDs(ctx, viewerUserID, false)
if err != nil {
return domain.ChannelDialogList{}, err
}
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
args := []any{viewerUserID}
where := []string{"i.user_id = $1", "i.status = 'active'", "NOT i.deleted", "m.status = 'active'"}
from := `FROM user_channel_member_index i
JOIN channel_members m ON m.user_id = i.user_id AND m.channel_id = i.channel_id
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = i.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id`
if (filter.HasFolderID && filter.FolderID == domain.DialogArchiveFolderID) || filter.PinnedOnly {
from = `FROM channel_dialogs d
JOIN user_channel_member_index i ON i.user_id = d.user_id AND i.channel_id = d.channel_id
JOIN channel_members m ON m.user_id = i.user_id AND m.channel_id = i.channel_id
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = d.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted`
where = []string{"d.user_id = $1", "i.status = 'active'", "NOT i.deleted", "m.status = 'active'"}
}
if filter.HasFolderID {
args = append(args, filter.FolderID)
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args)))
} else {
where = append(where, "COALESCE(d.folder_id, 0) = 0")
}
if filter.PinnedOnly {
where = append(where, "COALESCE(d.pinned, false)")
}
if filter.ExcludePinned {
where = append(where, "NOT COALESCE(d.pinned, false)")
}
args = append(args, channelDialogCandidateLimit)
rows, err := s.db.Query(ctx, `
WITH dependency_peers AS MATERIALIZED (
SELECT i.channel_id AS id
FROM user_channel_member_index i
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
UNION
SELECT parent.linked_monoforum_id
FROM user_channel_member_index i
JOIN channels parent ON parent.id = i.channel_id
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND parent.linked_monoforum_id <> 0
),
dependency AS MATERIALIZED (
SELECT COALESCE(bit_xor(v.hash), 0)::bigint AS hash
FROM read_model_versions v
JOIN dependency_peers peer ON peer.id = v.peer_id
WHERE v.peer_type = 'channel'
AND (
(v.model = 'channel_base' AND v.owner_user_id = 0)
OR
(v.model IN ('channel_member', 'dialog_light') AND v.owner_user_id = $1)
)
)
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
dependency.hash
`+from+`
CROSS JOIN dependency
WHERE `+strings.Join(where, " AND ")+`
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
`+visibleTopDate+` DESC,
`+visibleTopID+` DESC,
c.id DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list channel dialog snapshot headers: %w", err)
}
defer rows.Close()
dialogs := make([]domain.Dialog, 0, minInt(len(channelIDs), 1024))
seenChannels := make(map[int64]struct{}, len(channelIDs))
var dependencyHash int64
for rows.Next() {
var dialog domain.Dialog
var channelID int64
if err := rows.Scan(
&channelID,
&dialog.TopMessage,
&dialog.TopMessageDate,
&dialog.FolderID,
&dialog.Pinned,
&dialog.PinnedOrder,
&dependencyHash,
); err != nil {
return domain.ChannelDialogList{}, err
}
dialog.Peer = domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
dialogs = append(dialogs, dialog)
seenChannels[channelID] = struct{}{}
}
if err := rows.Err(); err != nil {
return domain.ChannelDialogList{}, err
}
if hasBroadcastAdmin {
items, err := s.listMonoforumAdminDialogItems(ctx, viewerUserID, channelIDs, filter, seenChannels)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, item := range items {
dialogs = append(dialogs, item.dialog)
}
}
if len(dialogs) > channelDialogCandidateLimit {
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
}
sort.SliceStable(dialogs, func(i, j int) bool {
if dialogs[i].Pinned != dialogs[j].Pinned {
return dialogs[i].Pinned
}
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
}
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
}
if dialogs[i].TopMessage != dialogs[j].TopMessage {
return dialogs[i].TopMessage > dialogs[j].TopMessage
}
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
})
return domain.ChannelDialogList{
Dialogs: dialogs,
Count: len(dialogs),
Hash: mixDialogListDependencyHash(dialogListHash(dialogs), dependencyHash),
}, nil
}
// ListAllBuiltinChannelDialogSnapshot loads every owner-varying dialog fact
// needed to derive main/archive/pinned pages in one bounded scan. Shared
// channel rows and top-message payloads remain channel-keyed response overlays.
func (s *ChannelStore) ListAllBuiltinChannelDialogSnapshot(ctx context.Context, viewerUserID int64) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
}
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 := s.db.Query(ctx, `
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
`+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
`+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date,
top_unread.message_id IS NOT NULL,
COALESCE(top_unread.unread, false),
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.read_inbox_max_id,
m.read_outbox_max_id,
m.unread_mark,
m.slowmode_last_send_date,
bool_or(i.broadcast AND i.role IN ('creator', 'admin')) OVER ()
FROM user_channel_member_index AS i
JOIN channel_members AS m
ON m.user_id = i.user_id
AND m.channel_id = i.channel_id
AND m.status = 'active'
JOIN channels AS c
ON c.id = i.channel_id
AND NOT c.deleted
LEFT JOIN channel_messages AS top_msg
ON top_msg.channel_id = i.channel_id
AND top_msg.id = c.top_message_id
AND NOT top_msg.deleted
LEFT JOIN channel_dialogs AS d
ON d.user_id = i.user_id
AND d.channel_id = i.channel_id
LEFT JOIN channel_unread_mentions AS top_unread
ON top_unread.user_id = i.user_id
AND top_unread.channel_id = i.channel_id
AND top_unread.message_id = `+visibleTopID+`
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND COALESCE(d.folder_id, 0) IN (0, 1)
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
`+visibleTopDate+` DESC,
`+visibleTopID+` DESC,
c.id DESC
LIMIT $2`, viewerUserID, channelDialogCandidateLimit+1)
if err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list all built-in channel dialog snapshot: %w", err)
}
defer rows.Close()
dialogs := make([]domain.Dialog, 0, 128)
parentChannelIDs := make([]int64, 0, 128)
seenChannels := make(map[int64]struct{}, 128)
hasBroadcastAdmin := false
for rows.Next() {
var values channelDialogProjectionValues
var rowHasBroadcastAdmin bool
destinations := append(values.scanDestinations(), &rowHasBroadcastAdmin)
if err := rows.Scan(destinations...); err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("scan all built-in channel dialog snapshot: %w", err)
}
channelID, dialog, defaultSendAs, _, _ := values.result()
dialog.DefaultSendAs = defaultSendAs
dialogs = append(dialogs, dialog)
parentChannelIDs = append(parentChannelIDs, channelID)
seenChannels[channelID] = struct{}{}
hasBroadcastAdmin = hasBroadcastAdmin || rowHasBroadcastAdmin
}
if err := rows.Err(); err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list all built-in channel dialog snapshot rows: %w", err)
}
if len(dialogs) > channelDialogCandidateLimit {
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
}
if hasBroadcastAdmin {
items, err := s.listMonoforumAdminDialogItems(
ctx, viewerUserID, parentChannelIDs, domain.DialogFilter{}, seenChannels,
)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, item := range items {
if item.dialog.FolderID == domain.DialogMainFolderID || item.dialog.FolderID == domain.DialogArchiveFolderID {
item.dialog.DefaultSendAs = item.defaultSendAs
dialogs = append(dialogs, item.dialog)
}
}
}
if len(dialogs) > channelDialogCandidateLimit {
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
}
sort.SliceStable(dialogs, func(i, j int) bool {
if dialogs[i].Pinned != dialogs[j].Pinned {
return dialogs[i].Pinned
}
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
}
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
}
if dialogs[i].TopMessage != dialogs[j].TopMessage {
return dialogs[i].TopMessage > dialogs[j].TopMessage
}
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
})
return domain.ChannelDialogList{Dialogs: dialogs, Count: len(dialogs)}, nil
}
func (s *ChannelStore) listMonoforumAdminDialogItems(ctx context.Context, viewerUserID int64, parentChannelIDs []int64, filter domain.DialogFilter, seen map[int64]struct{}) ([]channelDialogListItem, error) {
if viewerUserID == 0 || len(parentChannelIDs) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT `+channelColumns+`,
c.top_message_id,
COALESCE(top_msg.message_date, d.top_message_date, c.date),
COALESCE(d.folder_id, 0),
GREATEST(COALESCE(d.read_inbox_max_id, 0), c.top_message_id),
GREATEST(COALESCE(d.read_outbox_max_id, 0), c.top_message_id),
0,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, false),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
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
JOIN channels c ON c.id = parent.linked_monoforum_id AND c.monoforum AND NOT c.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
LEFT JOIN channel_dialogs d ON d.user_id = i.user_id AND d.channel_id = c.id
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.role IN ('creator','admin')
AND pm.status = 'active'
AND (pm.role = 'creator' OR (pm.role = 'admin' AND COALESCE((pm.admin_rights->>'ManageDirectMessages')::boolean, false)))
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
COALESCE(top_msg.message_date, d.top_message_date, c.date) DESC,
c.top_message_id DESC,
c.id DESC
LIMIT $3`, viewerUserID, parentChannelIDs, channelDialogQueryLimit)
if err != nil {
return nil, fmt.Errorf("list monoforum admin dialogs: %w", err)
}
defer rows.Close()
items := make([]channelDialogListItem, 0)
for rows.Next() {
ch, dialog, defaultSendAs, err := scanChannelDialogRow(rows, viewerUserID)
if err != nil {
return nil, err
}
if _, ok := seen[ch.ID]; ok {
continue
}
if !channelDialogMatchesFilter(dialog, ch, filter) {
continue
}
seen[ch.ID] = struct{}{}
items = append(items, channelDialogListItem{channel: ch, dialog: dialog, defaultSendAs: defaultSendAs})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan monoforum admin dialogs: %w", err)
}
return items, nil
}
type channelMessageLookupKey struct {
channelID int64
id int
}
func (s *ChannelStore) channelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, dialogs []domain.Dialog) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
seen := make(map[channelMessageLookupKey]struct{}, len(dialogs))
keys := make([]channelMessageLookupKey, 0, len(dialogs))
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 || dialog.TopMessage <= 0 {
continue
}
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
if len(keys) == 0 {
return nil, nil
}
load := func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
return loadChannelDialogTopMessages(ctx, db, missing)
}
var (
out map[channelMessageLookupKey]domain.ChannelMessage
err error
)
if s.topMessageCacheActive(db) {
out, err = s.topMsgCache.getOrLoadBatch(ctx, keys, load)
} else {
out, err = load(ctx, keys)
}
if err != nil {
return nil, err
}
// History-clear anchors are owner-local projections and must never enter
// the shared cache. Apply them to the cloned result after cache hydration.
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
}
func loadChannelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, keys []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
idsByChannel := make(map[int64][]int, len(keys))
for _, key := range keys {
if key.channelID == 0 || key.id <= 0 {
continue
}
idsByChannel[key.channelID] = append(idsByChannel[key.channelID], key.id)
}
if len(idsByChannel) == 0 {
return map[channelMessageLookupKey]domain.ChannelMessage{}, nil
}
channelIDs := make([]int64, 0, len(idsByChannel))
for channelID := range idsByChannel {
channelIDs = append(channelIDs, channelID)
}
sort.Slice(channelIDs, func(i, j int) bool { return channelIDs[i] < channelIDs[j] })
args := make([]any, 0, len(channelIDs)*2)
var where strings.Builder
for i, channelID := range channelIDs {
if i > 0 {
where.WriteString(" OR ")
}
args = append(args, channelID)
channelArg := len(args)
args = append(args, int32s(idsByChannel[channelID]))
idsArg := len(args)
fmt.Fprintf(&where, "(channel_id = $%d AND id = ANY($%d::int[]))", channelArg, idsArg)
}
rows, err := db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE `+where.String(), args...)
if err != nil {
return nil, fmt.Errorf("list channel dialog top messages: %w", err)
}
defer rows.Close()
out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(keys))
for rows.Next() {
msg, err := scanChannelMessage(rows)
if err != nil {
continue
}
out[channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID}] = msg
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
}
return out, nil
}
func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
if viewerUserID == 0 || len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
orderedIDs := make([]int64, 0, len(channelIDs))
seen := make(map[int64]struct{}, len(channelIDs))
for _, channelID := range channelIDs {
if channelID == 0 {
continue
}
if _, ok := seen[channelID]; ok {
continue
}
seen[channelID] = struct{}{}
orderedIDs = append(orderedIDs, channelID)
}
if len(orderedIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
itemsByID := make(map[int64]channelDialogListItem, len(orderedIDs))
loadedIDs := make([]int64, 0, len(orderedIDs))
misses := make([]int64, 0, len(orderedIDs))
dialogCacheActive := s.dialogCacheActive(s.db)
memberCacheActive := s.memberCacheActive(s.db)
var dialogCacheEpoch uint64
var memberCacheEpoch uint64
if dialogCacheActive {
dialogCacheEpoch = s.dialogCache.cacheEpoch()
}
if memberCacheActive {
memberCacheEpoch = s.memberCache.cacheEpoch()
}
for _, channelID := range orderedIDs {
if dialogCacheActive {
if cached, ok := s.dialogCache.getListProjection(viewerUserID, channelID); ok {
itemsByID[channelID] = channelDialogListItem{
dialog: channelDialogToDialog(cached.dialog, 0),
defaultSendAs: cached.dialog.DefaultSendAs,
topMentioned: cached.topMentioned,
topMediaUnread: cached.topMediaUnread,
topUnreadProjected: cached.topUnreadProjected,
listCacheable: true,
}
loadedIDs = append(loadedIDs, channelID)
continue
}
}
misses = append(misses, channelID)
}
if len(misses) > 0 {
loaded, err := s.loadChannelDialogListItems(ctx, viewerUserID, misses)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, channelID := range misses {
if item, ok := loaded[channelID]; ok {
itemsByID[channelID] = item
loadedIDs = append(loadedIDs, channelID)
}
}
}
channelsByID, err := s.channelsByIDs(ctx, s.db, loadedIDs)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, channelID := range loadedIDs {
item := itemsByID[channelID]
channel := channelsByID[channelID]
if channel.ID == 0 {
// The shared row was deleted after the owner-state snapshot. Treat
// it as absent instead of returning a half-hydrated dialog.
delete(itemsByID, channelID)
continue
}
item.channel = channel
item.dialog.Pts = channel.Pts
itemsByID[channelID] = item
}
out := domain.ChannelDialogList{}
// A requested monoforum preview can be absent from channel_members. Keep the
// rare admission path exact, but only pay its per-peer checks for IDs missed
// by the normal batch query.
for _, channelID := range orderedIDs {
if _, ok := itemsByID[channelID]; ok {
continue
}
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
synthetic := false
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) {
ch, chErr := s.channelByID(ctx, s.db, channelID)
if chErr != nil {
if errors.Is(chErr, domain.ErrChannelInvalid) {
continue
}
return domain.ChannelDialogList{}, chErr
}
syntheticMember, parentChannel, ok, previewErr := s.monoforumAdminPreview(ctx, s.db, viewerUserID, ch)
if previewErr != nil {
return domain.ChannelDialogList{}, previewErr
}
if !ok {
continue
}
channel = ch
member = syntheticMember
synthetic = true
// Send the parent broadcast channel in the same batch as the mono. TDesktop's applyMonoforumLinkedId
// only calls setMonoforumLink to derive MonoforumAdmin (needed for Direct-Messages container rendering) when the parent object is already loaded; a missing parent
// degrades the mono into an ordinary megagroup ("You created a group"). tgChannelsForDialogs dedups
// by id, so sending it twice is harmless; this branch is already gated on isChannelAdmin(parentMember) and does not leak to non-admins.
if parentChannel.ID != 0 {
out.Channels = append(out.Channels, parentChannel)
}
} else {
return domain.ChannelDialogList{}, err
}
}
var dialog domain.ChannelDialog
if synthetic {
dialog = previewChannelDialog(viewerUserID, channel, member)
} else {
dialog, err = s.getChannelDialog(ctx, s.db, viewerUserID, channel)
if err != nil {
return domain.ChannelDialogList{}, err
}
}
itemsByID[channelID] = channelDialogListItem{
channel: channel,
dialog: channelDialogToDialog(dialog, channel.Pts),
defaultSendAs: dialog.DefaultSendAs,
listCacheable: !synthetic,
}
}
dialogs := make([]domain.Dialog, 0, len(itemsByID))
for _, channelID := range orderedIDs {
if item, ok := itemsByID[channelID]; ok {
item.dialog.TopMessageMentioned = item.topMentioned
item.dialog.TopMessageMediaUnread = item.topMediaUnread
item.dialog.TopMessageUnreadProjected = item.topUnreadProjected
itemsByID[channelID] = item
dialogs = append(dialogs, item.dialog)
}
}
topMessages, err := s.channelDialogTopMessages(ctx, s.db, dialogs)
if err != nil {
return domain.ChannelDialogList{}, err
}
allTopUnreadProjected := true
for _, channelID := range orderedIDs {
item, ok := itemsByID[channelID]
if !ok {
continue
}
msg := topMessages[channelMessageLookupKey{channelID: channelID, id: item.dialog.TopMessage}]
if msg.ID != 0 {
if item.topUnreadProjected {
msg.Mentioned = item.topMentioned
msg.MediaUnread = item.topMediaUnread
} else {
allTopUnreadProjected = false
}
item.dialog.TopMessageDate = msg.Date
out.Messages = append(out.Messages, msg)
}
if dialogCacheActive && item.listCacheable {
cached := channelDialogFromDialog(viewerUserID, item.dialog)
cached.DefaultSendAs = item.defaultSendAs
s.dialogCache.putListProjectionIfEpoch(
cached,
item.topMentioned,
item.topMediaUnread,
item.topUnreadProjected,
dialogCacheEpoch,
)
}
if memberCacheActive && item.dialog.ChannelMember != nil {
s.memberCache.putIfEpoch(*item.dialog.ChannelMember, memberCacheEpoch)
}
out.Dialogs = append(out.Dialogs, item.dialog)
out.Channels = append(out.Channels, item.channel)
}
out.Count = len(out.Dialogs)
// Same reason as ListChannelDialogs: augment the top message per viewer with unread flags and reactions.
if err := s.populateChannelDialogTopMessageReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages, allTopUnreadProjected); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
// HydrateChannelDialogSnapshot attaches viewer-independent channel rows/top
// messages and the small viewer reaction overlay to already materialized
// owner dialog facts. It deliberately does not read channel_members or
// channel_dialogs: dialog_owner + channel_base generations protecting the
// caller's snapshot are the authority for those fields.
func (s *ChannelStore) HydrateChannelDialogSnapshot(
ctx context.Context,
viewerUserID int64,
dialogs []domain.Dialog,
) (domain.ChannelDialogList, error) {
if viewerUserID == 0 || len(dialogs) == 0 {
return domain.ChannelDialogList{}, nil
}
ordered := make([]domain.Dialog, 0, len(dialogs))
ids := make([]int64, 0, len(dialogs))
seen := make(map[int64]struct{}, len(dialogs))
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 {
continue
}
if _, duplicate := seen[dialog.Peer.ID]; duplicate {
continue
}
seen[dialog.Peer.ID] = struct{}{}
ordered = append(ordered, dialog)
ids = append(ids, dialog.Peer.ID)
}
if len(ordered) == 0 {
return domain.ChannelDialogList{}, nil
}
dialogCacheActive := s.dialogCacheActive(s.db)
memberCacheActive := s.memberCacheActive(s.db)
var dialogCacheEpoch uint64
var memberCacheEpoch uint64
if dialogCacheActive {
// Snapshot the epoch before any shared hydration. A concurrent
// dialog_light/channel_member/channel_base invalidation must prevent
// the owner projection from being written back after it became stale.
dialogCacheEpoch = s.dialogCache.cacheEpoch()
}
if memberCacheActive {
memberCacheEpoch = s.memberCache.cacheEpoch()
}
channelsByID, err := s.channelsByIDs(ctx, s.db, ids)
if err != nil {
return domain.ChannelDialogList{}, err
}
for index := range ordered {
channel := channelsByID[ordered[index].Peer.ID]
if channel.ID == 0 {
return domain.ChannelDialogList{}, fmt.Errorf("hydrate channel dialog snapshot %d: %w", ordered[index].Peer.ID, domain.ErrChannelInvalid)
}
ordered[index].Pts = channel.Pts
}
topMessages, err := s.channelDialogTopMessages(ctx, s.db, ordered)
if err != nil {
return domain.ChannelDialogList{}, err
}
out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, len(ordered))}
allTopUnreadProjected := true
for _, dialog := range ordered {
channel := channelsByID[dialog.Peer.ID]
message := topMessages[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}]
if message.ID != 0 {
if dialog.TopMessageUnreadProjected {
message.Mentioned = dialog.TopMessageMentioned
message.MediaUnread = dialog.TopMessageMediaUnread
} else {
allTopUnreadProjected = false
}
dialog.TopMessageDate = message.Date
out.Messages = append(out.Messages, message)
}
if dialogCacheActive {
cached := channelDialogFromDialog(viewerUserID, dialog)
cached.DefaultSendAs = clonePeer(dialog.DefaultSendAs)
s.dialogCache.putListProjectionIfEpoch(
cached,
dialog.TopMessageMentioned,
dialog.TopMessageMediaUnread,
dialog.TopMessageUnreadProjected,
dialogCacheEpoch,
)
}
if memberCacheActive && dialog.ChannelMember != nil {
s.memberCache.putIfEpoch(*dialog.ChannelMember, memberCacheEpoch)
}
out.Dialogs = append(out.Dialogs, dialog)
out.Channels = append(out.Channels, channel)
}
out.Count = len(out.Dialogs)
if err := s.populateChannelDialogTopMessageReactions(
ctx, s.db, viewerUserID, out.Channels, out.Messages, allTopUnreadProjected,
); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
func (s *ChannelStore) loadChannelDialogListItems(ctx context.Context, viewerUserID int64, channelIDs []int64) (map[int64]channelDialogListItem, error) {
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 := s.db.Query(ctx, `
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
`+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
`+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date,
top_unread.message_id IS NOT NULL,
COALESCE(top_unread.unread, false),
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.read_inbox_max_id,
m.read_outbox_max_id,
m.unread_mark,
m.slowmode_last_send_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
LEFT JOIN channel_unread_mentions top_unread
ON top_unread.user_id = m.user_id
AND top_unread.channel_id = m.channel_id
AND top_unread.message_id = `+visibleTopID+`
WHERE m.user_id = $1
AND m.channel_id = ANY($2::bigint[])
AND m.status = 'active'`, viewerUserID, channelIDs)
if err != nil {
return nil, fmt.Errorf("batch get channel dialogs: %w", err)
}
defer rows.Close()
itemsByID := make(map[int64]channelDialogListItem, len(channelIDs))
for rows.Next() {
channelID, dialog, defaultSendAs, topMentioned, topMediaUnread, err := scanChannelDialogProjectionRow(rows)
if err != nil {
return nil, err
}
itemsByID[channelID] = channelDialogListItem{
dialog: dialog,
defaultSendAs: defaultSendAs,
topMentioned: topMentioned,
topMediaUnread: topMediaUnread,
topUnreadProjected: true,
listCacheable: true,
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return itemsByID, 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
}
limit := req.Limit
if limit <= 0 || limit > domain.MaxCommonChannelsLimit {
limit = domain.MaxCommonChannelsLimit
}
var count int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM user_channel_member_index selfm
JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id
WHERE selfm.user_id = $1
AND targetm.user_id = $2
AND selfm.status = 'active'
AND targetm.status = 'active'
AND selfm.megagroup
AND NOT selfm.broadcast
AND NOT selfm.deleted
AND targetm.megagroup
AND NOT targetm.broadcast
AND NOT targetm.deleted`, req.UserID, req.TargetUserID).Scan(&count); err != nil {
return domain.CommonChannelsResult{}, fmt.Errorf("count common channels: %w", err)
}
out := domain.CommonChannelsResult{Count: count}
if req.CountOnly {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT selfm.channel_id
FROM user_channel_member_index selfm
JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id
WHERE selfm.user_id = $1
AND targetm.user_id = $2
AND selfm.status = 'active'
AND targetm.status = 'active'
AND selfm.megagroup
AND NOT selfm.broadcast
AND NOT selfm.deleted
AND targetm.megagroup
AND NOT targetm.broadcast
AND NOT targetm.deleted
AND ($3::bigint = 0 OR selfm.channel_id > $3)
ORDER BY selfm.channel_id ASC
LIMIT $4`, req.UserID, req.TargetUserID, req.MaxID, limit)
if err != nil {
return domain.CommonChannelsResult{}, fmt.Errorf("list common channels: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return domain.CommonChannelsResult{}, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return domain.CommonChannelsResult{}, err
}
channels, err := listChannelsByIDs(ctx, s.db, ids)
if err != nil {
return domain.CommonChannelsResult{}, err
}
out.Channels = channels
return out, nil
}
func (s *ChannelStore) ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) {
if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset {
return domain.LeftChannelsResult{}, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxLeftChannelsLimit {
limit = domain.MaxLeftChannelsLimit
}
var count int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM user_channel_member_index
WHERE user_id = $1
AND status = 'left'
AND (broadcast OR megagroup)
AND NOT deleted`, userID).Scan(&count); err != nil {
return domain.LeftChannelsResult{}, fmt.Errorf("count left channels: %w", err)
}
rows, err := s.db.Query(ctx, `
SELECT channel_id
FROM user_channel_member_index
WHERE user_id = $1
AND status = 'left'
AND (broadcast OR megagroup)
AND NOT deleted
ORDER BY left_at DESC, channel_id DESC
OFFSET $2
LIMIT $3`, userID, offset, limit)
if err != nil {
return domain.LeftChannelsResult{}, fmt.Errorf("list left channels: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var channelID int64
if err := rows.Scan(&channelID); err != nil {
return domain.LeftChannelsResult{}, err
}
ids = append(ids, channelID)
}
if err := rows.Err(); err != nil {
return domain.LeftChannelsResult{}, err
}
items, err := s.leftChannelsByIDs(ctx, userID, ids)
if err != nil {
return domain.LeftChannelsResult{}, err
}
return domain.LeftChannelsResult{Count: count, Channels: items}, nil
}
func (s *ChannelStore) leftChannelsByIDs(ctx context.Context, userID int64, ids []int64) ([]domain.LeftChannel, error) {
if len(ids) == 0 {
return nil, nil
}
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
AND channel_id = ANY($2::bigint[])
AND status = 'left'`, userID, ids)
if err != nil {
return nil, fmt.Errorf("list left channel details: %w", err)
}
defer rows.Close()
membersByID := make(map[int64]domain.ChannelMember, len(ids))
for rows.Next() {
member, err := scanChannelMember(rows)
if err != nil {
return nil, err
}
membersByID[member.ChannelID] = member
}
if err := rows.Err(); err != nil {
return nil, err
}
channels, err := listChannelsByIDsInOrder(ctx, s.db, ids)
if err != nil {
return nil, err
}
out := make([]domain.LeftChannel, 0, len(ids))
for _, channel := range channels {
if !channel.Broadcast && !channel.Megagroup {
continue
}
if member, ok := membersByID[channel.ID]; ok {
out = append(out, domain.LeftChannel{Channel: channel, Self: member})
}
}
return out, nil
}
func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) {
if userID == 0 {
return domain.ChannelDialogList{}, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxInactiveChannelsLimit {
limit = domain.MaxInactiveChannelsLimit
}
channelIDs, _, err := s.listActiveChannelDialogCandidateIDs(ctx, userID, true)
if err != nil {
return domain.ChannelDialogList{}, err
}
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
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, `
SELECT `+channelColumns+`,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0), `+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)), `+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
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
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
WHERE m.user_id = $1
AND m.channel_id = ANY($2::bigint[])
AND m.status = 'active'
AND (c.broadcast OR c.megagroup)
ORDER BY `+visibleTopDate+` ASC,
`+visibleTopID+` ASC,
c.id ASC
LIMIT $3`, userID, channelIDs, limit)
if err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list inactive channels: %w", err)
}
defer rows.Close()
out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, limit), Channels: make([]domain.Channel, 0, limit)}
for rows.Next() {
ch, dialog, _, err := scanChannelDialogRow(rows, userID)
if err != nil {
return domain.ChannelDialogList{}, err
}
out.Dialogs = append(out.Dialogs, dialog)
out.Channels = append(out.Channels, ch)
}
if err := rows.Err(); err != nil {
return domain.ChannelDialogList{}, err
}
out.Count = len(out.Dialogs)
return out, nil
}
// listActiveChannelDialogCandidateIDs returns candidate channel ids, along with hasBroadcastAdmin: whether the user is
// creator/admin in any broadcast channel. The latter is the page-independent superset test for the existence of a monoforum direct-messages dialog, letting the getDialogs hot path
// skip the extra monoforum admin dialog query (see ListChannelDialogs). It reads only the existing broadcast/role columns of the
// user_channel_member_index table, with zero extra round trips.
func (s *ChannelStore) listActiveChannelDialogCandidateIDs(ctx context.Context, userID int64, channelLikeOnly bool) ([]int64, bool, error) {
if userID == 0 {
return nil, false, nil
}
where := "user_id = $1 AND status = 'active' AND NOT deleted"
if channelLikeOnly {
where += " AND (broadcast OR megagroup)"
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, broadcast, role
FROM user_channel_member_index
WHERE `+where+`
ORDER BY channel_id ASC
LIMIT $2`, userID, channelDialogCandidateLimit)
if err != nil {
return nil, false, fmt.Errorf("list active channel dialog candidate ids: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, minInt(channelDialogCandidateLimit, 1024))
hasBroadcastAdmin := false
for rows.Next() {
var channelID int64
var broadcast bool
var role string
if err := rows.Scan(&channelID, &broadcast, &role); err != nil {
return nil, false, err
}
ids = append(ids, channelID)
if broadcast && (role == "creator" || role == "admin") {
hasBroadcastAdmin = true
}
}
if err := rows.Err(); err != nil {
return nil, false, err
}
return ids, hasBroadcastAdmin, nil
}
func (s *ChannelStore) SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, int, error) {
if userID == 0 || channelID == 0 {
return false, 0, nil
}
var changed bool
var folderID int32
if err := s.db.QueryRow(ctx, `
WITH target AS (
SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.available_min_id
FROM channels c
JOIN channel_members m ON m.channel_id = c.id
WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted
),
ensured AS (
-- A lazily created row must carry the member's real watermark and unread count: once a zero-value cached row exists it would
-- mask the true value (small groups read the cached column).
INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date,
read_inbox_max_id, read_outbox_max_id, unread_count)
SELECT $1, channel_id, top_message_id, top_message_date,
read_inbox_max_id, read_outbox_max_id, (
SELECT COUNT(*)::int
FROM channel_messages cm
WHERE cm.channel_id = target.channel_id
AND cm.id > GREATEST(target.read_inbox_max_id, target.available_min_id)
AND NOT cm.deleted
AND cm.sender_user_id <> $1
)
FROM target
ON CONFLICT (user_id, channel_id) DO NOTHING
),
-- The pin order spans the dialogs/channel_dialogs tables but is allocated only within the dialog's current folder,
-- sharing the same (user, folder) order space as private-chat SetDialogPinned.
self_folder AS (
SELECT COALESCE(d.folder_id, 0)::int AS folder_id
FROM channel_dialogs d
WHERE d.user_id = $1 AND d.channel_id = $2
),
next_order AS (
SELECT GREATEST(
COALESCE((
SELECT MAX(cd.pinned_order)
FROM channel_dialogs cd, self_folder f
WHERE cd.user_id = $1 AND cd.pinned AND COALESCE(cd.folder_id, 0) = f.folder_id
), 0),
COALESCE((
SELECT MAX(ud.pinned_order)
FROM dialogs ud, self_folder f
WHERE ud.user_id = $1 AND ud.pinned AND ud.folder_id = f.folder_id
), 0)
)::int + 1 AS value
),
updated AS (
UPDATE channel_dialogs d
SET pinned = $3,
pinned_order = CASE
WHEN $3::boolean THEN CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END
ELSE 0
END,
updated_at = now()
FROM next_order
WHERE d.user_id = $1 AND d.channel_id = $2
AND EXISTS (SELECT 1 FROM target)
AND (d.pinned IS DISTINCT FROM $3::boolean OR ($3::boolean AND d.pinned_order = 0))
RETURNING COALESCE(d.folder_id, 0)::int AS folder_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean,
COALESCE((SELECT folder_id FROM updated), (SELECT folder_id FROM self_folder), 0)::int`,
userID, channelID, pinned).Scan(&changed, &folderID); err != nil {
return false, 0, fmt.Errorf("set channel dialog pinned: %w", err)
}
return changed, int(folderID), nil
}
func (s *ChannelStore) ReorderChannelPinnedDialogs(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
if userID == 0 {
return false, nil
}
peerTypes, peerIDs := peerArrays(order)
changed := false
if force {
tag, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id
FROM generate_subscripts($3::bigint[], 1) AS g(i)
WHERE i <= cardinality($2::text[])
)
UPDATE channel_dialogs d
SET pinned = false, pinned_order = 0, updated_at = now()
WHERE d.user_id = $1
AND d.pinned
AND COALESCE(d.folder_id, 0) = $4::int
AND NOT EXISTS (
SELECT 1 FROM requested r
WHERE r.peer_type = 'channel' AND r.peer_id = d.channel_id
)`, userID, peerTypes, peerIDs, folderID)
if err != nil {
return false, fmt.Errorf("clear channel pinned dialogs not in order: %w", err)
}
if tag.RowsAffected() > 0 {
changed = true
}
}
if len(peerIDs) == 0 {
return changed, nil
}
tag, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id, i::int AS pos
FROM generate_subscripts($3::bigint[], 1) AS g(i)
WHERE i <= cardinality($2::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_id) peer_id, (cardinality($3::bigint[]) - pos + 1)::int AS ord
FROM requested
WHERE peer_type = 'channel'
ORDER BY peer_id, pos
)
UPDATE channel_dialogs d
SET pinned = true, pinned_order = deduped.ord, updated_at = now()
FROM deduped
WHERE d.user_id = $1 AND d.channel_id = deduped.peer_id
AND COALESCE(d.folder_id, 0) = $4::int
AND (NOT d.pinned OR d.pinned_order IS DISTINCT FROM deduped.ord)`, userID, peerTypes, peerIDs, folderID)
if err != nil {
return false, fmt.Errorf("reorder channel pinned dialogs: %w", err)
}
if tag.RowsAffected() > 0 {
changed = true
}
return changed, nil
}
func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
if userID == 0 || len(peers) == 0 {
return nil
}
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
folderIDs := make([]int32, 0, len(peers))
seen := make(map[int64]struct{}, len(peers))
for _, item := range peers {
if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 {
continue
}
if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID {
continue
}
if _, ok := seen[item.Peer.ID]; ok {
continue
}
seen[item.Peer.ID] = struct{}{}
peerTypes = append(peerTypes, string(item.Peer.Type))
peerIDs = append(peerIDs, item.Peer.ID)
folderIDs = append(folderIDs, int32(item.FolderID))
}
if len(peerIDs) == 0 {
return nil
}
// Archiving must ensure-INSERT: a channel never read/pinned has no dialog row yet, so
// UPDATE alone would silently lose the archive; the new row also carries the member's real watermark,
// avoiding a zero-watermark cached row masking unread/read state.
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
FROM generate_subscripts($3::bigint[], 1) AS g(i)
WHERE i <= cardinality($2::text[]) AND i <= cardinality($4::int[])
),
deduped AS (
SELECT DISTINCT ON (channel_id) channel_id, folder_id
FROM requested
WHERE peer_type = 'channel' AND folder_id IN (0, 1)
ORDER BY channel_id
)
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,
`+visibleTopID+`,
m.read_inbox_max_id, m.read_outbox_max_id,
(
SELECT COUNT(*)::int
FROM channel_messages cm
WHERE cm.channel_id = m.channel_id
AND cm.id > GREATEST(m.read_inbox_max_id, m.available_min_id)
AND NOT cm.deleted
AND cm.sender_user_id <> $1
),
now()
FROM deduped
JOIN channel_members m ON m.user_id = $1 AND m.channel_id = deduped.channel_id AND m.status = 'active'
JOIN channels c ON c.id = m.channel_id AND NOT c.deleted
ON CONFLICT (user_id, channel_id) DO UPDATE
-- Clear pinned when changing folder: consistent with private-chat EditDialogPeerFolders, TDesktop
-- unconditionally unpins locally on archive/restore, so keeping the old pin on the server would cause state drift.
SET folder_id = EXCLUDED.folder_id,
pinned = CASE WHEN COALESCE(channel_dialogs.folder_id, 0) <> EXCLUDED.folder_id THEN false ELSE channel_dialogs.pinned END,
pinned_order = CASE WHEN COALESCE(channel_dialogs.folder_id, 0) <> EXCLUDED.folder_id THEN 0 ELSE channel_dialogs.pinned_order END,
updated_at = now()`, userID, peerTypes, peerIDs, folderIDs); err != nil {
return fmt.Errorf("edit channel peer folders: %w", err)
}
return nil
}
func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int64) (int, int, error) {
if userID == 0 {
return 0, 0, nil
}
var peers, messages int32
// JOIN active member: leftover channel_dialogs rows from members who left are not counted in the archive badge.
// unread is derived dynamically at read time (H4a): no longer consuming the channel_dialogs.unread_count cached column; the archive set is
// bounded (an explicit archive creates the row), and the per-row dynamic COUNT is already clamped by the cap.
visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
if err := s.db.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE t.unread_count > 0 OR t.unread_mark)::int,
COALESCE(SUM(t.unread_count), 0)::int
FROM (
SELECT `+channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)+` AS unread_count,
COALESCE(d.unread_mark, m.unread_mark) AS unread_mark
FROM channel_dialogs d
JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active'
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
WHERE d.user_id = $1
AND COALESCE(d.folder_id, 0) = 1
) t`, userID).Scan(&peers, &messages); err != nil {
return 0, 0, fmt.Errorf("count channel archive unread: %w", err)
}
return int(peers), int(messages), nil
}
func (s *ChannelStore) getChannelDialog(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) {
if s.dialogCacheActive(db) {
return s.dialogCache.getOrLoad(ctx, userID, channel.ID, func() (domain.ChannelDialog, error) {
return s.getChannelDialogUncached(ctx, db, userID, channel)
})
}
return s.getChannelDialogUncached(ctx, db, userID, channel)
}
func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) {
dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID}
var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64
visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
// Single-channel TopMessageDate is taken directly via LEFT JOIN top_msg, replacing the previous extra
// round trip of a separate getChannelMessage per channel (chf-3, one of the measured pg_stat "channel message by id" sources). Strictly
// preserving the original semantics: take message_date only when the top is visible (top_message_id > available_min_id) and the message row exists
// (not filtering deleted, same as the original getChannelMessage), otherwise fall back to
// COALESCE(d.top_message_date, c.date). Note: this deliberately keeps the existing difference from the batch getChannelDialogs hidden state
// (ELSE 0); this change only removes the round trip and does not alter single-channel output.
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+`,
COALESCE(d.folder_id, 0),
`+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
`+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
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
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
WHERE c.id = $2`, userID, channel.ID).Scan(
&dialog.TopMessageID,
&dialog.TopMessageDate,
&dialog.FolderID,
&dialog.ReadInboxMaxID,
&dialog.ReadOutboxMaxID,
&dialog.UnreadCount,
&dialog.Pinned,
&dialog.PinnedOrder,
&dialog.UnreadMark,
&dialog.UnreadMentions,
&dialog.UnreadReactions,
&dialog.ViewForumAsMessages,
&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)
}
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64}
}
return dialog, nil
}
func (s *ChannelStore) getChannelDialogs(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64) (map[int64]domain.ChannelDialog, error) {
if userID == 0 || len(channelIDs) == 0 {
return nil, nil
}
if s.dialogCacheActive(db) {
out := make(map[int64]domain.ChannelDialog, len(channelIDs))
misses := make([]int64, 0, len(channelIDs))
seen := make(map[int64]struct{}, len(channelIDs))
for _, channelID := range channelIDs {
if channelID == 0 {
continue
}
if _, ok := seen[channelID]; ok {
continue
}
seen[channelID] = struct{}{}
if dialog, ok := s.dialogCache.get(userID, channelID); ok {
out[channelID] = dialog
continue
}
misses = append(misses, channelID)
}
if len(misses) == 0 {
return out, nil
}
// Snapshot the epoch before the warm write-back: reject the stale projection write-back if an invalidation arrived during the miss-load (same as ListChannelDialogs).
loadEpoch := s.dialogCache.cacheEpoch()
loaded, err := s.getChannelDialogsUncached(ctx, db, userID, misses)
if err != nil {
return nil, err
}
for channelID, dialog := range loaded {
s.dialogCache.putIfEpoch(dialog, loadEpoch)
out[channelID] = dialog
}
return out, nil
}
return s.getChannelDialogsUncached(ctx, db, userID, channelIDs)
}
func (s *ChannelStore) getChannelDialogsUncached(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64) (map[int64]domain.ChannelDialog, error) {
if userID == 0 || len(channelIDs) == 0 {
return nil, nil
}
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, `
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
`+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
`+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
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
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
WHERE c.id = ANY($2::bigint[])`, userID, channelIDs)
if err != nil {
return nil, fmt.Errorf("get channel dialogs: %w", err)
}
defer rows.Close()
out := make(map[int64]domain.ChannelDialog, len(channelIDs))
for rows.Next() {
var dialog domain.ChannelDialog
var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64
if err := rows.Scan(
&dialog.ChannelID,
&dialog.TopMessageID,
&dialog.TopMessageDate,
&dialog.FolderID,
&dialog.ReadInboxMaxID,
&dialog.ReadOutboxMaxID,
&dialog.UnreadCount,
&dialog.Pinned,
&dialog.PinnedOrder,
&dialog.UnreadMark,
&dialog.UnreadMentions,
&dialog.UnreadReactions,
&dialog.ViewForumAsMessages,
&dialog.HasScheduled,
&defaultSendAsType,
&defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
); err != nil {
return nil, err
}
dialog.UserID = userID
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64}
}
out[dialog.ChannelID] = dialog
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("get channel dialogs: %w", err)
}
return out, nil
}
// cappedChannelUnreadCount generates an unread COUNT subquery that clamps the scanned row count to MaxDialogUnreadCount,
// consistent with the read path's channelDialogDynamicUnreadCountSQL. The write fan-out path previously used a bare COUNT without
// LIMIT, so a member who has not read for a long time (read far below top) degraded to an O(unread backlog) full scan; the inner LIMIT subquery caps
// the per-member scan work, so the written cached column and the read path's badge are both min(actual, MaxDialogUnreadCount).
// whereBody is the full WHERE clause on the channel_messages alias msg.
func cappedChannelUnreadCount(whereBody string) string {
return fmt.Sprintf(`(
SELECT COUNT(*)::int
FROM (
SELECT 1
FROM channel_messages msg
%s
LIMIT %d
) msg_capped
)`, whereBody, domain.MaxDialogUnreadCount)
}
func upsertChannelDialogTx(ctx context.Context, tx pgx.Tx, userID int64, channel domain.Channel, top domain.ChannelMessage, readInboxMaxID, readOutboxMaxID int) error {
topDate := top.Date
if topDate == 0 {
topDate = channel.Date
}
unread, err := countChannelUnreadMessages(ctx, tx, userID, channel.ID, readInboxMaxID, channel.TopMessageID)
if err != nil {
return err
}
conflictUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = channel_dialogs.channel_id
AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id)
AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id)
AND NOT msg.deleted
AND msg.sender_user_id <> channel_dialogs.user_id`)
if _, err := tx.Exec(ctx, fmt.Sprintf(`
INSERT INTO channel_dialogs (
user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark
) VALUES ($1,$2,$3,$4,$5,$6,$7,false)
ON CONFLICT (user_id, channel_id) DO UPDATE SET
top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id),
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id),
unread_count = %s,
unread_mark = false,
updated_at = now()`, conflictUnread),
userID, channel.ID, channel.TopMessageID, topDate, readInboxMaxID, readOutboxMaxID, unread); err != nil {
return fmt.Errorf("upsert channel dialog: %w", err)
}
return nil
}
// upsertChannelDialogsForMessageTx handles the recipient-side read-boundary side effects of a message within the send transaction.
//
// Performance audit H4a: no longer upsert channel_dialogs for all members (the old gate was megagroup ≤ MaxSynchronousChannelDialogFanout)—
// the O(members) row writes + row locks in the send transaction were the blocker to opening up large groups (P1-u remnant).
// All channels (broadcast / megagroup regardless of size) uniformly derive at read time: unread is recomputed from visible incoming messages
// (channelDialogDynamicUnreadCountSQL, capped), top is provided by channels.top_message_id,
// the read-boundary truth lives in channel_members, and the dialog row carries only pin/folder/unread_mark/draft and other
// explicit user state, creating rows lazily as needed.
//
// The only per-recipient write kept: advancing the read_inbox boundary of skipDelivery (privacy bot) members—these
// members are excluded from delivery and their boundary must be raised in sync to keep their unread derivation from counting hidden messages as unread; the set is bounded
// (bot-only candidates).
func upsertChannelDialogsForMessageTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, selfReadUserID int64, skipDeliveryUserIDs ...[]int64) error {
if channel.ID == 0 || top.ID == 0 {
return nil
}
skipIDs := []int64{}
if len(skipDeliveryUserIDs) > 0 {
skipIDs = uniqueChannelUserIDs(skipDeliveryUserIDs[0], selfReadUserID)
}
if len(skipIDs) > 0 {
if _, err := tx.Exec(ctx, `
UPDATE channel_members
SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
unread_mark = false,
updated_at = now()
WHERE channel_id = $1
AND user_id = ANY($2::bigint[])
AND status = 'active'
AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false)
AND $3 > available_min_id`, channel.ID, skipIDs, top.ID); err != nil {
return fmt.Errorf("advance skipped channel delivery boundary: %w", err)
}
}
// A skipDelivery member may already have a channel_dialogs row (lazily created by pin/archive etc.), whose read_inbox
// is read via GREATEST(d, m)—advancing the member row is enough, the dialog row needs no sync.
return nil
}
func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.Dialog, *domain.Peer, error) {
var ch domain.Channel
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
dest := append(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper),
&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,
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 {
defaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64}
}
_ = userID
return ch, dialog, defaultSendAs, nil
}
type channelDialogProjectionValues struct {
channelID int64
topID, topDate, folderID, readInbox, readOutbox int
unreadCount, pinnedOrder, unreadMentions, unreadReactions int
historyClearAnchorID, historyClearAnchorDate int
pinned, unreadMark, viewForumAsMessages, hasScheduled bool
defaultSendAsType sql.NullString
defaultSendAsID sql.NullInt64
topMentioned, topMediaUnread bool
memberUserID, memberInviterUserID int64
memberRole, memberStatus string
memberJoinedAt, memberLeftAt int
memberAdminRights, memberBannedRights, memberRank string
memberAvailableMinID, memberAvailableMinPts int
memberReadInboxMaxID, memberReadOutboxMaxID int
memberUnreadMark bool
memberSlowmodeLastSendDate int
}
func (v *channelDialogProjectionValues) scanDestinations() []any {
return []any{
&v.channelID,
&v.topID, &v.topDate,
&v.folderID, &v.readInbox, &v.readOutbox, &v.unreadCount, &v.pinned, &v.pinnedOrder, &v.unreadMark, &v.unreadMentions, &v.unreadReactions, &v.viewForumAsMessages, &v.hasScheduled,
&v.defaultSendAsType, &v.defaultSendAsID,
&v.historyClearAnchorID, &v.historyClearAnchorDate,
&v.topMentioned, &v.topMediaUnread,
&v.memberUserID, &v.memberInviterUserID,
&v.memberRole, &v.memberStatus,
&v.memberJoinedAt, &v.memberLeftAt,
&v.memberAdminRights, &v.memberBannedRights, &v.memberRank,
&v.memberAvailableMinID, &v.memberAvailableMinPts,
&v.memberReadInboxMaxID, &v.memberReadOutboxMaxID,
&v.memberUnreadMark, &v.memberSlowmodeLastSendDate,
}
}
func (v *channelDialogProjectionValues) result() (int64, domain.Dialog, *domain.Peer, bool, bool) {
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: v.channelID},
FolderID: v.folderID,
TopMessage: v.topID,
TopMessageDate: v.topDate,
HistoryClearAnchorID: v.historyClearAnchorID,
HistoryClearAnchorDate: v.historyClearAnchorDate,
ReadInboxMaxID: v.readInbox,
ReadOutboxMaxID: v.readOutbox,
UnreadCount: v.unreadCount,
UnreadMentions: v.unreadMentions,
UnreadReactions: v.unreadReactions,
Pinned: v.pinned,
PinnedOrder: v.pinnedOrder,
UnreadMark: v.unreadMark,
ViewForumAsMessages: v.viewForumAsMessages,
HasScheduled: v.hasScheduled,
TopMessageMentioned: v.topMentioned,
TopMessageMediaUnread: v.topMediaUnread,
TopMessageUnreadProjected: true,
}
var defaultSendAs *domain.Peer
if v.defaultSendAsType.Valid && v.defaultSendAsID.Valid && v.defaultSendAsID.Int64 != 0 {
defaultSendAs = &domain.Peer{Type: domain.PeerType(v.defaultSendAsType.String), ID: v.defaultSendAsID.Int64}
}
member := domain.ChannelMember{
ChannelID: v.channelID,
UserID: v.memberUserID,
InviterUserID: v.memberInviterUserID,
Role: domain.ChannelMemberRole(v.memberRole),
Status: domain.ChannelMemberStatus(v.memberStatus),
JoinedAt: v.memberJoinedAt,
LeftAt: v.memberLeftAt,
Rank: v.memberRank,
AvailableMinID: v.memberAvailableMinID,
AvailableMinPts: v.memberAvailableMinPts,
HistoryClearAnchorID: v.historyClearAnchorID,
HistoryClearAnchorDate: v.historyClearAnchorDate,
ReadInboxMaxID: v.memberReadInboxMaxID,
ReadOutboxMaxID: v.memberReadOutboxMaxID,
UnreadMark: v.memberUnreadMark,
SlowmodeLastSendDate: v.memberSlowmodeLastSendDate,
}
_ = json.Unmarshal([]byte(v.memberAdminRights), &member.AdminRights)
_ = json.Unmarshal([]byte(v.memberBannedRights), &member.BannedRights)
dialog.ChannelMember = &member
return v.channelID, dialog, defaultSendAs, v.topMentioned, v.topMediaUnread
}
// scanChannelDialogProjectionRow scans only owner-varying channel dialog
// state. Shared channel metadata is hydrated separately through ChannelRowCache
// so a page containing the same channel for many online owners does not decode
// and transfer the wide channels row repeatedly.
func scanChannelDialogProjectionRow(row rowScanner) (int64, domain.Dialog, *domain.Peer, bool, bool, error) {
var values channelDialogProjectionValues
if err := row.Scan(values.scanDestinations()...); err != nil {
return 0, domain.Dialog{}, nil, false, false, err
}
channelID, dialog, defaultSendAs, topMentioned, topMediaUnread := values.result()
return channelID, dialog, defaultSendAs, topMentioned, topMediaUnread, nil
}
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,
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,
DefaultSendAs: clonePeer(dialog.DefaultSendAs),
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,
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,
DefaultSendAs: clonePeer(dialog.DefaultSendAs),
}
}
func clonePeer(peer *domain.Peer) *domain.Peer {
if peer == nil {
return nil
}
cloned := *peer
return &cloned
}
func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool {
if filter.HasFolderID {
if filter.FolderID < domain.DialogCustomFolderMinID {
if dialog.FolderID != filter.FolderID {
return false
}
} else if filter.Folder == nil {
return false
}
}
if filter.PinnedOnly && !dialog.Pinned {
return false
}
if filter.ExcludePinned && dialog.Pinned {
return false
}
if !channelDialogAfterOffset(dialog, filter) {
return false
}
if filter.Folder == nil {
return true
}
folder := filter.Folder
if peerInDialogFolder(dialog.Peer, folder.ExcludePeers) {
return false
}
if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark {
return false
}
if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID {
return false
}
if peerInDialogFolder(dialog.Peer, folder.IncludePeers) || peerInDialogFolder(dialog.Peer, folder.PinnedPeers) {
return true
}
if channel.Megagroup && folder.Groups {
return true
}
if channel.Broadcast && folder.Broadcasts {
return true
}
// Groups/channels can only enter a custom folder via the Groups/Broadcasts category switches or an explicit
// include/pinned; a folder that only checks Contacts/NonContacts/Bots (private-chat categories) contains no groups/channels.
return false
}
func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool {
if filter.OffsetDate <= 0 && filter.OffsetID <= 0 {
if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer {
return false
}
return true
}
if filter.OffsetDate > 0 {
if dialog.TopMessageDate != filter.OffsetDate {
return dialog.TopMessageDate < filter.OffsetDate
}
if filter.OffsetID <= 0 {
return false
}
if dialog.TopMessage != filter.OffsetID {
return dialog.TopMessage < filter.OffsetID
}
if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type {
return dialog.Peer.ID < filter.OffsetPeer.ID
}
return false
}
if dialog.TopMessage != filter.OffsetID {
return dialog.TopMessage < filter.OffsetID
}
if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type {
return dialog.Peer.ID < filter.OffsetPeer.ID
}
return false
}
func peerInDialogFolder(peer domain.Peer, items []domain.DialogFolderPeer) bool {
for _, item := range items {
if item.Peer == peer {
return true
}
}
return false
}
func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.DialogFolderPeer) []int64 {
total := len(primary)
for _, items := range rest {
total += len(items)
}
seen := make(map[int64]struct{}, minInt(total, domain.MaxDialogFolderPeers))
out := make([]int64, 0, minInt(total, domain.MaxDialogFolderPeers))
appendOne := func(items []domain.DialogFolderPeer) {
for _, item := range items {
if len(out) >= domain.MaxDialogFolderPeers {
return
}
if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 {
continue
}
if _, ok := seen[item.Peer.ID]; ok {
continue
}
seen[item.Peer.ID] = struct{}{}
out = append(out, item.Peer.ID)
}
}
appendOne(primary)
for _, items := range rest {
appendOne(items)
}
return out
}
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
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: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
}
}