owpengram-server/internal/store/memory/story.go
2026-09-01 12:06:31 +03:00

1767 lines
52 KiB
Go

package memory
import (
"context"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"telesrv/internal/domain"
)
// StoryStore is the in-memory implementation of store.StoryStore.
type StoryStore struct {
mu sync.RWMutex
stories map[storyKey]domain.Story
read map[storyReadKey]domain.StoryReadState
views map[storyViewKey]domain.StoryView
exposures map[storyViewKey]int
hidden map[storyHiddenKey]bool
profiles map[int64]domain.User
contacts map[int64]map[int64]domain.Contact
blocked map[int64]map[int64]bool
channels *ChannelStore
members map[int64]map[int64]bool
}
type storyKey struct {
peerType domain.PeerType
peerID int64
storyID int
}
type storyReadKey struct {
viewerID int64
peerType domain.PeerType
peerID int64
}
type storyViewKey struct {
peerType domain.PeerType
peerID int64
storyID int
viewerID int64
}
type storyHiddenKey struct {
viewerID int64
peerType domain.PeerType
peerID int64
}
// NewStoryStore creates an empty StoryStore.
func NewStoryStore(channels ...*ChannelStore) *StoryStore {
var channelStore *ChannelStore
if len(channels) > 0 {
channelStore = channels[0]
}
return &StoryStore{
stories: make(map[storyKey]domain.Story),
read: make(map[storyReadKey]domain.StoryReadState),
views: make(map[storyViewKey]domain.StoryView),
exposures: make(map[storyViewKey]int),
hidden: make(map[storyHiddenKey]bool),
profiles: make(map[int64]domain.User),
contacts: make(map[int64]map[int64]domain.Contact),
blocked: make(map[int64]map[int64]bool),
channels: channelStore,
members: make(map[int64]map[int64]bool),
}
}
// SetStoryViewerProfiles seeds owner-visible user metadata used by
// ListStoryViews q filtering in the memory store.
func (s *StoryStore) SetStoryViewerProfiles(users ...domain.User) {
s.mu.Lock()
defer s.mu.Unlock()
if s.profiles == nil {
s.profiles = make(map[int64]domain.User)
}
for _, user := range users {
if user.ID == 0 {
continue
}
s.profiles[user.ID] = user
}
}
// SetStoryViewerContacts seeds owner-scoped contact metadata used by
// ListStoryViews just_contacts and q filtering in the memory store.
func (s *StoryStore) SetStoryViewerContacts(ownerUserID int64, contacts ...domain.Contact) {
s.mu.Lock()
defer s.mu.Unlock()
if s.contacts == nil {
s.contacts = make(map[int64]map[int64]domain.Contact)
}
if _, ok := s.contacts[ownerUserID]; !ok {
s.contacts[ownerUserID] = make(map[int64]domain.Contact)
}
for _, contact := range contacts {
if contact.User.ID == 0 {
continue
}
s.contacts[ownerUserID][contact.User.ID] = contact
}
}
// SetStoryBlockedUsers seeds owner-scoped story blocklist membership used by
// memory-store story visibility tests.
func (s *StoryStore) SetStoryBlockedUsers(ownerUserID int64, viewerUserIDs ...int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.blocked == nil {
s.blocked = make(map[int64]map[int64]bool)
}
if len(viewerUserIDs) == 0 {
delete(s.blocked, ownerUserID)
return
}
blocked := make(map[int64]bool, len(viewerUserIDs))
for _, viewerUserID := range viewerUserIDs {
if viewerUserID == 0 || viewerUserID == ownerUserID {
continue
}
blocked[viewerUserID] = true
}
if len(blocked) == 0 {
delete(s.blocked, ownerUserID)
return
}
s.blocked[ownerUserID] = blocked
}
// SetStoryChannelMembers seeds active channel story viewers for memory-store
// story visibility tests when no ChannelStore is attached.
func (s *StoryStore) SetStoryChannelMembers(channelID int64, viewerUserIDs ...int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.members == nil {
s.members = make(map[int64]map[int64]bool)
}
members := make(map[int64]bool, len(viewerUserIDs))
for _, viewerUserID := range viewerUserIDs {
if viewerUserID == 0 {
continue
}
members[viewerUserID] = true
}
s.members[channelID] = members
}
func (s *StoryStore) CreateStory(_ context.Context, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) {
if err := validateStoryPeer(req.Owner); err != nil {
return domain.StoryCreateResult{}, err
}
if req.RandomID == 0 {
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
for _, existing := range s.stories {
if existing.Owner == req.Owner && existing.RandomID == req.RandomID {
return domain.StoryCreateResult{Story: cloneStory(existing), Duplicate: true}, nil
}
}
nextID := 1
for _, existing := range s.stories {
if existing.Owner == req.Owner && existing.ID >= nextID {
nextID = existing.ID + 1
}
}
if nextID <= 0 || nextID > domain.MaxStoryID {
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
}
story := domain.Story{
Owner: req.Owner,
ID: nextID,
RandomID: req.RandomID,
Date: req.Date,
ExpireDate: req.Date + req.Period,
Pinned: req.Pinned,
Public: req.Public,
CloseFriends: req.CloseFriends,
Contacts: req.Contacts,
SelectedContacts: req.SelectedContacts,
NoForwards: req.NoForwards,
Out: true,
PrivacyRules: cloneStoryPrivacyRules(req.PrivacyRules),
AllowUserIDs: append([]int64(nil), req.AllowUserIDs...),
DisallowUserIDs: append([]int64(nil), req.DisallowUserIDs...),
Caption: req.Caption,
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
MediaAreas: cloneStoryMediaAreas(req.MediaAreas),
Forward: cloneStoryForward(req.Forward),
}
s.stories[keyForStory(story.Owner, story.ID)] = story
if story.Owner.Type == domain.PeerTypeUser {
s.read[storyReadKey{viewerID: story.Owner.ID, peerType: story.Owner.Type, peerID: story.Owner.ID}] = domain.StoryReadState{
ViewerID: story.Owner.ID,
Peer: story.Owner,
MaxReadID: story.ID,
Date: story.Date,
}
}
return domain.StoryCreateResult{Story: cloneStory(story)}, nil
}
func (s *StoryStore) UpsertStory(_ context.Context, req domain.UpsertStoryRequest) (domain.Story, error) {
story := cloneStory(req.Story)
if err := validateStoryIdentity(story.Owner, story.ID); err != nil {
return domain.Story{}, err
}
s.mu.Lock()
s.stories[keyForStory(story.Owner, story.ID)] = story
s.mu.Unlock()
return cloneStory(story), nil
}
func (s *StoryStore) ListActiveStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) {
return s.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit)
}
func (s *StoryStore) ListActiveStoriesPage(_ context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) {
if limit <= 0 || limit > domain.MaxStoryListLimit {
limit = domain.MaxStoryListLimit
}
pageItems, reads := s.activeStoryPeerPagesForViewer(viewerUserID, hidden, now)
total := len(pageItems)
if cursor.Set {
pageItems = activeStoryPeerPagesAfter(pageItems, cursor)
}
hasMore := len(pageItems) > limit
if hasMore {
pageItems = pageItems[:limit]
}
peers := activeStoryPeerStories(pageItems, reads)
stories := make([]domain.Story, 0)
for _, item := range pageItems {
stories = append(stories, item.stories...)
}
s.recordStoryExposures(viewerUserID, stories)
return domain.StoryList{
Count: total,
HasMore: hasMore,
Stories: stories,
Peers: peers,
}, nil
}
func (s *StoryStore) ActiveStoriesDigest(_ context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) {
pageItems, reads := s.activeStoryPeerPagesForViewer(viewerUserID, hidden, now)
return domain.DigestStoryPeerList(activeStoryPeerStories(pageItems, reads)), nil
}
func (s *StoryStore) activeStoryPeerPagesForViewer(viewerUserID int64, hidden bool, now int) ([]activeStoryPeerPage, []domain.StoryReadState) {
s.mu.RLock()
byPeer := make(map[domain.Peer][]domain.Story)
for _, story := range s.stories {
if !story.Active(now) || !s.storyVisibleLocked(story, viewerUserID) {
continue
}
isHidden := s.hidden[storyHiddenKey{viewerID: viewerUserID, peerType: story.Owner.Type, peerID: story.Owner.ID}]
if hidden != isHidden {
continue
}
byPeer[story.Owner] = append(byPeer[story.Owner], cloneStory(story))
}
for peer := range byPeer {
s.populateStoryForwardCountsLocked(byPeer[peer], viewerUserID)
}
reads := cloneReadStatesLocked(s.read, viewerUserID)
s.mu.RUnlock()
pageItems := make([]activeStoryPeerPage, 0, len(byPeer))
for peer, stories := range byPeer {
sortStoriesOldestFirst(stories)
pageItems = append(pageItems, activeStoryPeerPage{
peer: peer,
maxDate: latestStoryDate(stories),
stories: stories,
})
}
sortActiveStoryPeerPages(pageItems)
return pageItems, reads
}
func (s *StoryStore) ListOwnerActiveStories(_ context.Context, owner domain.Peer, now, limit int) (domain.StoryList, error) {
if err := validateStoryPeer(owner); err != nil {
return domain.StoryList{}, err
}
if limit <= 0 || limit > domain.MaxStoryListLimit {
limit = domain.MaxStoryListLimit
}
s.mu.RLock()
stories := make([]domain.Story, 0, len(s.stories))
for _, story := range s.stories {
if story.Owner != owner || !story.Active(now) {
continue
}
stories = append(stories, fanoutStorySnapshot(story))
}
s.mu.RUnlock()
sortStoriesOldestFirst(stories)
if len(stories) > limit {
stories = stories[:limit]
}
return domain.StoryList{Count: len(stories), Stories: stories}, nil
}
func (s *StoryStore) GetPeerStories(_ context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) {
if err := validateStoryPeer(peer); err != nil {
return domain.PeerStories{}, err
}
s.mu.RLock()
var stories []domain.Story
for _, story := range s.stories {
if story.Owner != peer || !story.Active(now) || !s.storyVisibleLocked(story, viewerUserID) {
continue
}
stories = append(stories, cloneStory(story))
}
s.populateStoryForwardCountsLocked(stories, viewerUserID)
read := s.read[storyReadKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID}]
s.mu.RUnlock()
sortStoriesOldestFirst(stories)
s.recordStoryExposures(viewerUserID, stories)
return domain.PeerStories{Peer: peer, MaxReadID: read.MaxReadID, Stories: stories}, nil
}
func (s *StoryStore) GetStoriesByID(_ context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) {
if err := validateStoryPeer(peer); err != nil {
return domain.StoryList{}, err
}
ids, err := normalizeStoryIDListNonEmpty(ids)
if err != nil {
return domain.StoryList{}, err
}
out := make([]domain.Story, 0, len(ids))
s.mu.RLock()
for _, id := range ids {
story, ok := s.stories[keyForStory(peer, id)]
if !ok || story.Deleted || !s.storyVisibleLocked(story, viewerUserID) {
continue
}
// Exact lookups may return expired pinned/profile stories; non-pinned expired
// stories are still returned as snapshots for share/reply resolution.
_ = now
item := cloneStory(story)
item.Out = item.Owner.Type == domain.PeerTypeUser && item.Owner.ID == viewerUserID
out = append(out, item)
}
s.populateStoryForwardCountsLocked(out, viewerUserID)
s.mu.RUnlock()
s.recordStoryExposures(viewerUserID, out)
return domain.StoryList{Count: len(out), Stories: out}, nil
}
func (s *StoryStore) ListPinnedStories(_ context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
_ = now
if err := validateStoryPeer(peer); err != nil {
return domain.StoryList{}, err
}
if limit <= 0 || limit > domain.MaxStoryListLimit {
limit = domain.MaxStoryListLimit
}
s.mu.RLock()
out := make([]domain.Story, 0)
pinnedToTop := make([]domain.Story, 0)
total := 0
for _, story := range s.stories {
if story.Owner != peer || story.Deleted || !story.Pinned || !s.storyVisibleLocked(story, viewerUserID) {
continue
}
total++
if story.PinnedToTopOrder > 0 {
pinnedToTop = append(pinnedToTop, cloneStory(story))
}
if offsetID > 0 && story.ID >= offsetID {
continue
}
out = append(out, cloneStory(story))
}
s.populateStoryForwardCountsLocked(out, viewerUserID)
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].ID != out[j].ID {
return out[i].ID > out[j].ID
}
return out[i].Date > out[j].Date
})
sort.Slice(pinnedToTop, func(i, j int) bool {
if pinnedToTop[i].PinnedToTopOrder != pinnedToTop[j].PinnedToTopOrder {
return pinnedToTop[i].PinnedToTopOrder < pinnedToTop[j].PinnedToTopOrder
}
return pinnedToTop[i].ID > pinnedToTop[j].ID
})
topIDs := make([]int, 0, len(pinnedToTop))
for _, story := range pinnedToTop {
topIDs = append(topIDs, story.ID)
}
if len(out) > limit {
out = out[:limit]
}
s.recordStoryExposures(viewerUserID, out)
return domain.StoryList{Count: total, Stories: out, PinnedToTop: topIDs}, nil
}
func (s *StoryStore) HasPinnedStories(_ context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) {
_ = now
if err := validateStoryPeer(peer); err != nil {
return false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, story := range s.stories {
if story.Owner == peer && !story.Deleted && story.Pinned && s.storyVisibleLocked(story, viewerUserID) {
return true, nil
}
}
return false, nil
}
func (s *StoryStore) ListStoriesArchive(_ context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
if err := validateStoryPeer(peer); err != nil {
return domain.StoryList{}, err
}
if limit < 0 || limit > domain.MaxStoryListLimit {
limit = domain.MaxStoryListLimit
}
if offsetID < 0 {
offsetID = 0
}
s.mu.RLock()
total := 0
out := make([]domain.Story, 0)
for _, story := range s.stories {
if story.Owner != peer || story.Deleted || story.ExpireDate > now {
continue
}
total++
if limit == 0 || (offsetID > 0 && story.ID >= offsetID) {
continue
}
item := cloneStory(story)
item.Out = item.Owner.Type == domain.PeerTypeUser && item.Owner.ID == viewerUserID
out = append(out, item)
}
s.populateStoryForwardCountsLocked(out, viewerUserID)
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].ID != out[j].ID {
return out[i].ID > out[j].ID
}
return out[i].Date > out[j].Date
})
if limit > 0 && len(out) > limit {
out = out[:limit]
}
s.recordStoryExposures(viewerUserID, out)
return domain.StoryList{Count: total, Stories: out}, nil
}
func (s *StoryStore) ListReadStates(_ context.Context, viewerUserID int64) ([]domain.StoryReadState, error) {
s.mu.RLock()
out := cloneReadStatesLocked(s.read, viewerUserID)
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Peer.Type != out[j].Peer.Type {
return out[i].Peer.Type < out[j].Peer.Type
}
return out[i].Peer.ID < out[j].Peer.ID
})
return out, nil
}
func (s *StoryStore) GetPeerMaxIDs(_ context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
out := make([]domain.RecentStory, len(peers))
s.mu.RLock()
for i, peer := range peers {
if err := validateStoryPeer(peer); err != nil {
s.mu.RUnlock()
return nil, err
}
maxID := 0
for _, story := range s.stories {
if story.Owner == peer && story.Active(now) && s.storyVisibleLocked(story, viewerUserID) && story.ID > maxID {
maxID = story.ID
}
}
out[i] = domain.RecentStory{Peer: peer, MaxID: maxID}
}
s.mu.RUnlock()
return out, nil
}
func (s *StoryStore) GetPeerHiddenStates(_ context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
if viewerUserID == 0 {
return nil, domain.ErrStoryPeerInvalid
}
out := make(map[domain.Peer]bool, len(peers))
s.mu.RLock()
for _, peer := range peers {
if err := validateStoryPeer(peer); err != nil {
s.mu.RUnlock()
return nil, err
}
key := storyHiddenKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID}
out[peer] = s.hidden[key]
}
s.mu.RUnlock()
return out, nil
}
func (s *StoryStore) GetPeerStoryProjections(_ context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
if viewerUserID == 0 {
return nil, domain.ErrStoryPeerInvalid
}
out := make([]domain.PeerStoryProjection, len(peers))
s.mu.RLock()
for i, peer := range peers {
if err := validateStoryPeer(peer); err != nil {
s.mu.RUnlock()
return nil, err
}
maxID := 0
for _, story := range s.stories {
if story.Owner == peer && story.Active(now) && s.storyVisibleLocked(story, viewerUserID) && story.ID > maxID {
maxID = story.ID
}
}
key := storyHiddenKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID}
out[i] = domain.PeerStoryProjection{
Peer: peer,
Recent: domain.RecentStory{Peer: peer, MaxID: maxID},
Hidden: s.hidden[key],
}
}
s.mu.RUnlock()
return out, nil
}
func (s *StoryStore) ActiveStoryPeerExpirations(_ context.Context, peers []domain.Peer, now int) (map[domain.Peer]int, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
requested := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if err := validateStoryPeer(peer); err != nil {
return nil, err
}
requested[peer] = struct{}{}
}
out := make(map[domain.Peer]int, len(peers))
s.mu.RLock()
for _, story := range s.stories {
if _, ok := requested[story.Owner]; !ok || !story.Active(now) {
continue
}
if story.ExpireDate > out[story.Owner] {
out[story.Owner] = story.ExpireDate
}
}
s.mu.RUnlock()
return out, nil
}
func (s *StoryStore) ListHiddenStoryPeers(_ context.Context, viewerUserID int64) ([]domain.Peer, error) {
if viewerUserID == 0 {
return nil, domain.ErrStoryPeerInvalid
}
out := make([]domain.Peer, 0)
s.mu.RLock()
for key, hidden := range s.hidden {
if key.viewerID == viewerUserID && hidden {
out = append(out, domain.Peer{Type: key.peerType, ID: key.peerID})
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].ID < out[j].ID
})
return out, nil
}
func (s *StoryStore) MarkRead(_ context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
if viewerUserID == 0 {
return domain.StoryReadResult{}, domain.ErrStoryPeerInvalid
}
if err := validateStoryIdentity(peer, maxID); err != nil {
return domain.StoryReadResult{}, err
}
key := storyReadKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID}
s.mu.Lock()
state := s.read[key]
advanced := maxID > state.MaxReadID
if advanced {
state = domain.StoryReadState{ViewerID: viewerUserID, Peer: peer, MaxReadID: maxID, Date: date}
s.read[key] = state
}
s.mu.Unlock()
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, MaxReadID: state.MaxReadID, Advanced: advanced, Date: date}, nil
}
func (s *StoryStore) IncrementViews(_ context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) {
if viewerUserID == 0 {
return 0, domain.ErrStoryPeerInvalid
}
if err := validateStoryPeer(peer); err != nil {
return 0, err
}
ids, err := normalizeStoryIDListNonEmpty(ids)
if err != nil {
return 0, err
}
if peer.IsSelfUser(viewerUserID) {
return 0, nil
}
created := 0
s.mu.Lock()
for _, id := range ids {
story, ok := s.stories[keyForStory(peer, id)]
if !ok || !story.Interactable(date) || !s.storyVisibleLocked(story, viewerUserID) {
continue
}
key := storyViewKey{peerType: peer.Type, peerID: peer.ID, storyID: id, viewerID: viewerUserID}
if _, ok := s.views[key]; ok {
continue
}
s.views[key] = domain.StoryView{Owner: peer, StoryID: id, ViewerID: viewerUserID, Date: date}
story.Views.ViewsCount++
story.Views.HasViewers = true
story.Views.RecentViewers = prependRecentViewer(story.Views.RecentViewers, viewerUserID)
s.stories[keyForStory(peer, id)] = story
created++
}
s.mu.Unlock()
return created, nil
}
func (s *StoryStore) SetReaction(_ context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) {
if viewerUserID == 0 {
return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid
}
if err := validateStoryIdentity(peer, storyID); err != nil {
return domain.StoryReactionResult{}, err
}
if peer.IsSelfUser(viewerUserID) {
return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid
}
s.mu.Lock()
story, ok := s.stories[keyForStory(peer, storyID)]
if !ok || !story.Interactable(date) || !s.storyVisibleLocked(story, viewerUserID) {
s.mu.Unlock()
return domain.StoryReactionResult{}, domain.ErrStoryNotFound
}
key := storyViewKey{peerType: peer.Type, peerID: peer.ID, storyID: storyID, viewerID: viewerUserID}
view := s.views[key]
existingView := view.ViewerID != 0
if view.ViewerID == 0 {
view = domain.StoryView{Owner: peer, StoryID: storyID, ViewerID: viewerUserID, Date: date}
story.Views.ViewsCount++
story.Views.HasViewers = true
story.Views.RecentViewers = prependRecentViewer(story.Views.RecentViewers, viewerUserID)
}
changed := !sameReaction(view.Reaction, reaction)
if existingView && !changed {
story.SentReaction = cloneReactionPtr(reaction)
s.mu.Unlock()
return domain.StoryReactionResult{
ViewerID: viewerUserID,
Peer: peer,
StoryID: storyID,
Reaction: cloneReactionPtr(reaction),
Story: cloneStory(story),
Changed: false,
Date: view.Date,
}, nil
}
if changed {
story.Views = adjustStoryReactionCounts(story.Views, view.Reaction, reaction)
}
view.Reaction = cloneReactionPtr(reaction)
view.Date = date
s.views[key] = view
story.SentReaction = cloneReactionPtr(reaction)
s.stories[keyForStory(peer, storyID)] = story
s.mu.Unlock()
return domain.StoryReactionResult{
ViewerID: viewerUserID,
Peer: peer,
StoryID: storyID,
Reaction: cloneReactionPtr(reaction),
Story: cloneStory(story),
Changed: changed,
Date: date,
}, nil
}
func (s *StoryStore) ListStoryViews(_ context.Context, req domain.StoryViewListRequest) (domain.StoryViewList, error) {
if req.ViewerUserID == 0 {
return domain.StoryViewList{}, domain.ErrStoryPeerInvalid
}
if err := validateStoryIdentity(req.Owner, req.StoryID); err != nil {
return domain.StoryViewList{}, err
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryViewList{}, err
}
limit := clampStoryInteractionLimit(req.Limit)
cursor := parseStoryInteractionCursor(req.Offset)
s.mu.RLock()
story, ok := s.stories[keyForStory(req.Owner, req.StoryID)]
if !ok || story.Deleted {
s.mu.RUnlock()
return domain.StoryViewList{}, domain.ErrStoryNotFound
}
views := s.storyViewsLocked(req.Owner, req.StoryID)
for i := range views {
views[i].BlockedMyStoriesFrom = s.storyViewerBlockedLocked(req.Owner, views[i].ViewerID)
}
reposts := s.storyRepostViewsLocked(req.Owner, req.StoryID, req.ViewerUserID)
profiles := cloneStoryViewerProfiles(s.profiles)
contacts := cloneStoryViewerContacts(s.contacts[req.ViewerUserID])
s.mu.RUnlock()
filteredViews := filterStoryViewsForList(views, req, profiles, contacts)
display := filteredViews
if req.Query == "" && !req.JustContacts {
display = append(display, reposts...)
}
sortStoryViewsForList(display, req.ReactionsFirst, req.ForwardsFirst)
_, viewsCount, reactionsCount := storyViewCounts(views)
count := len(filteredViews)
if req.Query == "" && !req.JustContacts {
count += len(reposts)
}
if req.Query != "" || req.JustContacts {
allViews := s.storyViewsSnapshot(req.Owner, req.StoryID)
_, viewsCount, reactionsCount = storyViewCounts(allViews)
}
page, nextOffset := pageStoryViews(display, limit, cursor, req.ReactionsFirst, req.ForwardsFirst)
return domain.StoryViewList{
Count: count,
ViewsCount: viewsCount,
ForwardsCount: len(reposts),
ReactionsCount: reactionsCount,
Views: page,
NextOffset: nextOffset,
}, nil
}
func (s *StoryStore) storyViewsSnapshot(owner domain.Peer, storyID int) []domain.StoryView {
s.mu.RLock()
defer s.mu.RUnlock()
return s.storyViewsLocked(owner, storyID)
}
func (s *StoryStore) ListStoryReactions(_ context.Context, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) {
if req.ViewerUserID == 0 {
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
}
if err := validateStoryIdentity(req.Owner, req.StoryID); err != nil {
return domain.StoryReactionList{}, err
}
if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil {
return domain.StoryReactionList{}, err
}
limit := clampStoryInteractionLimit(req.Limit)
cursor := parseStoryInteractionCursor(req.Offset)
s.mu.RLock()
story, ok := s.stories[keyForStory(req.Owner, req.StoryID)]
if !ok || story.Deleted {
s.mu.RUnlock()
return domain.StoryReactionList{}, domain.ErrStoryNotFound
}
views := s.storyViewsLocked(req.Owner, req.StoryID)
reposts := []domain.StoryView(nil)
if req.Reaction == nil {
reposts = s.storyRepostViewsLocked(req.Owner, req.StoryID, req.ViewerUserID)
}
s.mu.RUnlock()
reactions := make([]domain.StoryView, 0, len(views)+len(reposts))
for _, view := range views {
if view.Reaction == nil {
continue
}
if req.Reaction != nil && !sameReaction(view.Reaction, req.Reaction) {
continue
}
reactions = append(reactions, view)
}
reactions = append(reactions, reposts...)
sortStoryViewsForList(reactions, false, req.ForwardsFirst)
page, nextOffset := pageStoryViews(reactions, limit, cursor, false, req.ForwardsFirst)
return domain.StoryReactionList{
Count: len(reactions),
Reactions: page,
NextOffset: nextOffset,
}, nil
}
func (s *StoryStore) ListStoryPublicForwards(_ context.Context, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) {
if req.ViewerUserID == 0 {
return domain.StoryPublicForwardList{}, domain.ErrStoryPeerInvalid
}
if err := validateStoryIdentity(req.Owner, req.StoryID); err != nil {
return domain.StoryPublicForwardList{}, err
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryPublicForwardList{}, err
}
limit := clampStoryInteractionLimit(req.Limit)
cursor := parseStoryInteractionCursor(req.Offset)
s.mu.RLock()
story, ok := s.stories[keyForStory(req.Owner, req.StoryID)]
if !ok || story.Deleted {
s.mu.RUnlock()
return domain.StoryPublicForwardList{}, domain.ErrStoryNotFound
}
reposts := s.storyRepostViewsLocked(req.Owner, req.StoryID, req.ViewerUserID)
s.mu.RUnlock()
sortStoryViewsForList(reposts, false, true)
page, nextOffset := pageStoryViews(reposts, limit, cursor, false, true)
return domain.StoryPublicForwardList{Count: len(reposts), Forwards: page, NextOffset: nextOffset}, nil
}
func (s *StoryStore) ListStoryViewerIDs(_ context.Context, owner domain.Peer, storyID, limit int) ([]int64, error) {
if err := validateStoryIdentity(owner, storyID); err != nil {
return nil, err
}
limit = clampStoryPrivacyFanoutLimit(limit)
s.mu.RLock()
if _, ok := s.stories[keyForStory(owner, storyID)]; !ok {
s.mu.RUnlock()
return nil, domain.ErrStoryNotFound
}
ids := make([]int64, 0)
seen := make(map[int64]struct{})
for key := range s.views {
if key.peerType != owner.Type || key.peerID != owner.ID || key.storyID != storyID || key.viewerID == 0 {
continue
}
if _, ok := seen[key.viewerID]; ok {
continue
}
seen[key.viewerID] = struct{}{}
ids = append(ids, key.viewerID)
}
for key := range s.exposures {
if key.peerType != owner.Type || key.peerID != owner.ID || key.storyID != storyID || key.viewerID == 0 {
continue
}
if _, ok := seen[key.viewerID]; ok {
continue
}
seen[key.viewerID] = struct{}{}
ids = append(ids, key.viewerID)
}
s.mu.RUnlock()
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) > limit {
ids = ids[:limit]
}
return ids, nil
}
func (s *StoryStore) EditStory(_ context.Context, req domain.StoryEditRequest) (domain.StoryEditResult, error) {
if err := validateStoryIdentity(req.Owner, req.ID); err != nil {
return domain.StoryEditResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
key := keyForStory(req.Owner, req.ID)
story, ok := s.stories[key]
if !ok || story.Deleted {
return domain.StoryEditResult{}, domain.ErrStoryNotFound
}
updated := cloneStory(story)
if req.UpdateMedia {
updated.Media = req.Media
}
if req.UpdateCaption {
updated.Caption = req.Caption
updated.Entities = append([]domain.MessageEntity(nil), req.Entities...)
}
if req.UpdatePrivacy {
updated.Public = req.Public
updated.CloseFriends = req.CloseFriends
updated.Contacts = req.Contacts
updated.SelectedContacts = req.SelectedContacts
updated.PrivacyRules = cloneStoryPrivacyRules(req.PrivacyRules)
updated.AllowUserIDs = append([]int64(nil), req.AllowUserIDs...)
updated.DisallowUserIDs = append([]int64(nil), req.DisallowUserIDs...)
}
if req.UpdateMediaAreas {
updated.MediaAreas = cloneStoryMediaAreas(req.MediaAreas)
}
if reflect.DeepEqual(story, updated) {
return domain.StoryEditResult{}, domain.ErrStoryNotModified
}
updated.Edited = true
s.stories[key] = updated
return domain.StoryEditResult{Story: cloneStory(updated), Previous: cloneStory(story)}, nil
}
func (s *StoryStore) DeleteStories(_ context.Context, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) {
_ = date
if err := validateStoryPeer(peer); err != nil {
return domain.StoryMutationResult{}, err
}
ids, err := normalizeStoryIDListNonEmpty(ids)
if err != nil {
return domain.StoryMutationResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}
for _, id := range ids {
key := keyForStory(peer, id)
story, ok := s.stories[key]
if !ok {
continue
}
if story.Deleted {
continue
}
out.Previous = append(out.Previous, cloneStory(story))
story.Deleted = true
story.Pinned = false
story.PinnedToTopOrder = 0
s.stories[key] = story
out.Stories = append(out.Stories, cloneStory(story))
}
return out, nil
}
func (s *StoryStore) TogglePinned(_ context.Context, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) {
_ = date
if err := validateStoryPeer(peer); err != nil {
return domain.StoryMutationResult{}, err
}
ids, err := normalizeStoryIDList(ids)
if err != nil {
return domain.StoryMutationResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}
for _, id := range ids {
key := keyForStory(peer, id)
story, ok := s.stories[key]
if !ok || story.Deleted {
continue
}
if story.Pinned == pinned {
continue
}
out.Previous = append(out.Previous, cloneStory(story))
story.Pinned = pinned
if !pinned {
story.PinnedToTopOrder = 0
}
s.stories[key] = story
out.Stories = append(out.Stories, cloneStory(story))
}
return out, nil
}
func (s *StoryStore) TogglePinnedToTop(_ context.Context, peer domain.Peer, ids []int) error {
if err := validateStoryPeer(peer); err != nil {
return err
}
ids, err := normalizeStoryPinnedToTopIDs(ids)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range ids {
story, ok := s.stories[keyForStory(peer, id)]
if !ok || story.Deleted || !story.Pinned {
return domain.ErrStoryIDInvalid
}
}
for key, story := range s.stories {
if story.Owner == peer && story.PinnedToTopOrder != 0 {
story.PinnedToTopOrder = 0
s.stories[key] = story
}
}
for i, id := range ids {
key := keyForStory(peer, id)
story := s.stories[key]
story.PinnedToTopOrder = i + 1
s.stories[key] = story
}
return nil
}
func (s *StoryStore) SetPeerHidden(_ context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error {
if viewerUserID == 0 {
return domain.ErrStoryPeerInvalid
}
if err := validateStoryPeer(peer); err != nil {
return err
}
key := storyHiddenKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID}
s.mu.Lock()
if hidden {
s.hidden[key] = true
} else {
delete(s.hidden, key)
}
s.mu.Unlock()
return nil
}
func validateStoryIdentity(peer domain.Peer, id int) error {
if err := validateStoryPeer(peer); err != nil {
return err
}
if id <= 0 || id > domain.MaxStoryID {
return domain.ErrStoryIDInvalid
}
return nil
}
func validateStoryPeer(peer domain.Peer) error {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
if peer.ID > 0 {
return nil
}
}
return domain.ErrStoryPeerInvalid
}
func validateStoryIDList(ids []int) error {
if len(ids) > domain.MaxStoryIDs {
return domain.ErrStoryIDInvalid
}
for _, id := range ids {
if id <= 0 || id > domain.MaxStoryID {
return domain.ErrStoryIDInvalid
}
}
return nil
}
func validateStoryIDListNonEmpty(ids []int) error {
if len(ids) == 0 {
return domain.ErrStoryIDInvalid
}
return validateStoryIDList(ids)
}
func normalizeStoryIDList(ids []int) ([]int, error) {
if err := validateStoryIDList(ids); err != nil {
return nil, err
}
return normalizeStoryIDListUnchecked(ids), nil
}
func normalizeStoryPinnedToTopIDs(ids []int) ([]int, error) {
ids, err := normalizeStoryIDList(ids)
if err != nil {
return nil, err
}
if len(ids) > domain.MaxStoryPinnedToTop {
return nil, domain.ErrStoryIDInvalid
}
return ids, nil
}
func normalizeStoryIDListNonEmpty(ids []int) ([]int, error) {
if err := validateStoryIDListNonEmpty(ids); err != nil {
return nil, err
}
return normalizeStoryIDListUnchecked(ids), nil
}
func normalizeStoryIDListUnchecked(ids []int) []int {
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (s *StoryStore) storyViewsLocked(peer domain.Peer, storyID int) []domain.StoryView {
out := make([]domain.StoryView, 0)
for key, view := range s.views {
if key.peerType != peer.Type || key.peerID != peer.ID || key.storyID != storyID {
continue
}
out = append(out, cloneStoryView(view))
}
return out
}
func (s *StoryStore) storyRepostViewsLocked(peer domain.Peer, storyID int, viewerUserID int64) []domain.StoryView {
out := make([]domain.StoryView, 0)
for _, repost := range s.stories {
if !s.storyCountsAsPublicRepostLocked(repost, peer, storyID, viewerUserID) {
continue
}
item := cloneStory(repost)
out = append(out, domain.StoryView{
Owner: peer,
StoryID: storyID,
ViewerID: storyPeerCursorKey(item.Owner),
Date: item.Date,
Repost: &item,
})
}
return out
}
func (s *StoryStore) populateStoryForwardCountsLocked(stories []domain.Story, viewerUserID int64) {
for i := range stories {
stories[i].Views.ForwardsCount = s.storyForwardCountLocked(stories[i].Owner, stories[i].ID, viewerUserID)
}
}
func (s *StoryStore) storyForwardCountLocked(peer domain.Peer, storyID int, viewerUserID int64) int {
count := 0
for _, repost := range s.stories {
if s.storyCountsAsPublicRepostLocked(repost, peer, storyID, viewerUserID) {
count++
}
}
return count
}
func (s *StoryStore) storyCountsAsPublicRepostLocked(repost domain.Story, peer domain.Peer, storyID int, viewerUserID int64) bool {
if repost.Forward == nil || repost.Deleted || !repost.Public {
return false
}
if storyForwardSourcePeer(repost.Forward) != peer || repost.Forward.StoryID != storyID {
return false
}
return s.storyBaseVisibleLocked(repost, viewerUserID)
}
func storyForwardSourcePeer(forward *domain.StoryForward) domain.Peer {
if forward == nil {
return domain.Peer{}
}
if forward.Source.ID != 0 {
return forward.Source
}
return forward.From
}
func (s *StoryStore) recordStoryExposures(viewerUserID int64, stories []domain.Story) {
if viewerUserID == 0 || len(stories) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.exposures == nil {
s.exposures = make(map[storyViewKey]int)
}
for _, story := range stories {
if story.ID <= 0 || story.Owner.ID == 0 {
continue
}
if story.Owner.Type == domain.PeerTypeUser && story.Owner.ID == viewerUserID {
continue
}
key := storyViewKey{
peerType: story.Owner.Type,
peerID: story.Owner.ID,
storyID: story.ID,
viewerID: viewerUserID,
}
if previous, ok := s.exposures[key]; !ok || story.Date > previous {
s.exposures[key] = story.Date
}
}
}
func (s *StoryStore) storyVisibleLocked(story domain.Story, viewerUserID int64) bool {
if story.Owner.Type == domain.PeerTypeChannel && !s.storyViewerChannelMemberLocked(story.Owner.ID, viewerUserID) {
return false
}
return s.storyBaseVisibleLocked(story, viewerUserID)
}
func (s *StoryStore) storyBaseVisibleLocked(story domain.Story, viewerUserID int64) bool {
contact, viewerIsContact := s.storyViewerContactLocked(story.Owner, viewerUserID)
return story.VisibleToWithStoryFacts(
viewerUserID,
viewerIsContact,
contact.CloseFriend || contact.User.CloseFriend,
s.storyViewerBlockedLocked(story.Owner, viewerUserID),
)
}
func (s *StoryStore) storyViewerChannelMemberLocked(channelID, viewerUserID int64) bool {
if channelID == 0 || viewerUserID == 0 {
return false
}
if s.channels != nil {
s.channels.mu.RLock()
defer s.channels.mu.RUnlock()
channel, ok := s.channels.channels[channelID]
if !ok || channel.Deleted {
return false
}
member, ok := s.channels.members[channelID][viewerUserID]
return ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages
}
if members, ok := s.members[channelID]; ok {
return members[viewerUserID]
}
return true
}
func (s *StoryStore) storyViewerIsContactLocked(owner domain.Peer, viewerUserID int64) bool {
_, ok := s.storyViewerContactLocked(owner, viewerUserID)
return ok
}
func (s *StoryStore) storyViewerContactLocked(owner domain.Peer, viewerUserID int64) (domain.Contact, bool) {
if owner.Type != domain.PeerTypeUser || owner.ID == 0 || viewerUserID == 0 {
return domain.Contact{}, false
}
contacts := s.contacts[owner.ID]
if contacts == nil {
return domain.Contact{}, false
}
contact, ok := contacts[viewerUserID]
return contact, ok
}
func (s *StoryStore) storyViewerBlockedLocked(owner domain.Peer, viewerUserID int64) bool {
if owner.Type != domain.PeerTypeUser || owner.ID == 0 || viewerUserID == 0 || owner.ID == viewerUserID {
return false
}
blocked := s.blocked[owner.ID]
return blocked != nil && blocked[viewerUserID]
}
func keyForStory(peer domain.Peer, id int) storyKey {
return storyKey{peerType: peer.Type, peerID: peer.ID, storyID: id}
}
func cloneReadStatesLocked(read map[storyReadKey]domain.StoryReadState, viewerUserID int64) []domain.StoryReadState {
out := make([]domain.StoryReadState, 0, len(read))
for _, state := range read {
if state.ViewerID == viewerUserID {
out = append(out, state)
}
}
return out
}
func groupPeerStories(stories []domain.Story, reads []domain.StoryReadState) []domain.PeerStories {
readByPeer := make(map[domain.Peer]int, len(reads))
for _, read := range reads {
readByPeer[read.Peer] = read.MaxReadID
}
index := make(map[domain.Peer]int)
out := make([]domain.PeerStories, 0)
for _, story := range stories {
i, ok := index[story.Owner]
if !ok {
i = len(out)
index[story.Owner] = i
out = append(out, domain.PeerStories{Peer: story.Owner, MaxReadID: readByPeer[story.Owner]})
}
out[i].Stories = append(out[i].Stories, cloneStory(story))
}
for i := range out {
sortStoriesOldestFirst(out[i].Stories)
}
return out
}
type activeStoryPeerPage struct {
peer domain.Peer
maxDate int
stories []domain.Story
}
func activeStoryPeerStories(items []activeStoryPeerPage, reads []domain.StoryReadState) []domain.PeerStories {
readByPeer := make(map[domain.Peer]int, len(reads))
for _, read := range reads {
readByPeer[read.Peer] = read.MaxReadID
}
out := make([]domain.PeerStories, 0, len(items))
for _, item := range items {
stories := make([]domain.Story, 0, len(item.stories))
for _, story := range item.stories {
stories = append(stories, cloneStory(story))
}
sortStoriesOldestFirst(stories)
out = append(out, domain.PeerStories{
Peer: item.peer,
MaxReadID: readByPeer[item.peer],
Stories: stories,
})
}
return out
}
func sortActiveStoryPeerPages(items []activeStoryPeerPage) {
sort.Slice(items, func(i, j int) bool {
if items[i].maxDate != items[j].maxDate {
return items[i].maxDate > items[j].maxDate
}
if items[i].peer.Type != items[j].peer.Type {
return items[i].peer.Type < items[j].peer.Type
}
return items[i].peer.ID < items[j].peer.ID
})
}
func activeStoryPeerPagesAfter(items []activeStoryPeerPage, cursor domain.StoryListCursor) []activeStoryPeerPage {
for i, item := range items {
if activeStoryPeerPageAfter(item, cursor) {
return items[i:]
}
}
return nil
}
func activeStoryPeerPageAfter(item activeStoryPeerPage, cursor domain.StoryListCursor) bool {
if !cursor.Set {
return true
}
if item.maxDate != cursor.Date {
return item.maxDate < cursor.Date
}
if item.peer.Type != cursor.Peer.Type {
return item.peer.Type > cursor.Peer.Type
}
return item.peer.ID > cursor.Peer.ID
}
func latestStoryDate(stories []domain.Story) int {
maxDate := 0
for _, story := range stories {
if story.Date > maxDate {
maxDate = story.Date
}
}
return maxDate
}
func sortStoriesNewestFirst(stories []domain.Story) {
sort.Slice(stories, func(i, j int) bool {
if stories[i].Date != stories[j].Date {
return stories[i].Date > stories[j].Date
}
if stories[i].Owner != stories[j].Owner {
if stories[i].Owner.Type != stories[j].Owner.Type {
return stories[i].Owner.Type < stories[j].Owner.Type
}
return stories[i].Owner.ID < stories[j].Owner.ID
}
return stories[i].ID > stories[j].ID
})
}
func sortStoriesOldestFirst(stories []domain.Story) {
sort.Slice(stories, func(i, j int) bool {
if stories[i].ID != stories[j].ID {
return stories[i].ID < stories[j].ID
}
return stories[i].Date < stories[j].Date
})
}
func cloneStory(story domain.Story) domain.Story {
story.PrivacyRules = cloneStoryPrivacyRules(story.PrivacyRules)
story.AllowUserIDs = append([]int64(nil), story.AllowUserIDs...)
story.DisallowUserIDs = append([]int64(nil), story.DisallowUserIDs...)
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
story.MediaAreas = cloneStoryMediaAreas(story.MediaAreas)
story.Forward = cloneStoryForward(story.Forward)
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)
story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...)
story.SentReaction = cloneReactionPtr(story.SentReaction)
return story
}
func cloneStoryForward(in *domain.StoryForward) *domain.StoryForward {
if in == nil {
return nil
}
out := *in
return &out
}
func cloneStoryMediaAreas(in []domain.StoryMediaArea) []domain.StoryMediaArea {
if len(in) == 0 {
return nil
}
out := make([]domain.StoryMediaArea, len(in))
for i, area := range in {
out[i] = area
out[i].Reaction = cloneReactionPtr(area.Reaction)
if area.Geo != nil {
geo := *area.Geo
out[i].Geo = &geo
}
if area.GeoAddress != nil {
address := *area.GeoAddress
out[i].GeoAddress = &address
}
if area.Venue != nil {
venue := *area.Venue
out[i].Venue = &venue
}
}
return out
}
func fanoutStorySnapshot(story domain.Story) domain.Story {
story = cloneStory(story)
story.Out = false
story.Views = domain.StoryViews{}
story.SentReaction = nil
return story
}
func cloneStoryPrivacyRules(in []domain.PrivacyRule) []domain.PrivacyRule {
if len(in) == 0 {
return nil
}
out := make([]domain.PrivacyRule, len(in))
for i, rule := range in {
out[i] = rule
out[i].UserIDs = append([]int64(nil), rule.UserIDs...)
out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...)
}
return out
}
func cloneReactionPtr(in *domain.MessageReaction) *domain.MessageReaction {
if in == nil {
return nil
}
out := *in
return &out
}
func cloneStoryView(view domain.StoryView) domain.StoryView {
view.Reaction = cloneReactionPtr(view.Reaction)
if view.Repost != nil {
repost := cloneStory(*view.Repost)
view.Repost = &repost
}
if view.PublicForward != nil {
forward := *view.PublicForward
forward.Message = cloneChannelMessage(forward.Message)
view.PublicForward = &forward
}
return view
}
func sameReaction(a, b *domain.MessageReaction) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
type storyInteractionCursor struct {
set bool
group int
date int
viewerID int64
messageID int
}
func parseStoryInteractionCursor(offset string) storyInteractionCursor {
if offset == "" {
return storyInteractionCursor{}
}
parts := strings.Split(offset, ":")
if len(parts) != 3 && len(parts) != 4 {
return storyInteractionCursor{}
}
group, err1 := strconv.Atoi(parts[0])
date, err2 := strconv.Atoi(parts[1])
viewerID, err3 := strconv.ParseInt(parts[2], 10, 64)
var messageID int
var err4 error
if len(parts) == 4 {
messageID, err4 = strconv.Atoi(parts[3])
}
if err1 != nil || err2 != nil || err3 != nil || err4 != nil || group < 0 || viewerID == 0 || messageID < 0 {
return storyInteractionCursor{}
}
return storyInteractionCursor{set: true, group: group, date: date, viewerID: viewerID, messageID: messageID}
}
func formatStoryInteractionCursor(view domain.StoryView, reactionsFirst, forwardsFirst bool) string {
group := storyViewSortGroup(view, reactionsFirst, forwardsFirst)
out := strconv.Itoa(group) + ":" + strconv.Itoa(view.Date) + ":" + strconv.FormatInt(storyViewCursorKey(view), 10)
if id := storyViewCursorMessageID(view); id > 0 {
out += ":" + strconv.Itoa(id)
}
return out
}
func storyViewSortGroup(view domain.StoryView, reactionsFirst, forwardsFirst bool) int {
if forwardsFirst {
if view.Repost != nil || view.PublicForward != nil {
return 0
}
return 1
}
if reactionsFirst && view.Reaction == nil && view.Repost == nil && view.PublicForward == nil {
return 1
}
return 0
}
func storyViewCursorKey(view domain.StoryView) int64 {
if view.PublicForward != nil {
return storyPeerCursorKey(domain.Peer{Type: domain.PeerTypeChannel, ID: view.PublicForward.Message.ChannelID})
}
if view.Repost != nil {
return storyPeerCursorKey(view.Repost.Owner)
}
return view.ViewerID
}
func storyViewCursorMessageID(view domain.StoryView) int {
if view.PublicForward != nil {
return view.PublicForward.Message.ID
}
return 0
}
func storyPeerCursorKey(peer domain.Peer) int64 {
if peer.Type == domain.PeerTypeChannel {
return -peer.ID
}
return peer.ID
}
func sortStoryViewsForList(views []domain.StoryView, reactionsFirst, forwardsFirst bool) {
sort.Slice(views, func(i, j int) bool {
gi := storyViewSortGroup(views[i], reactionsFirst, forwardsFirst)
gj := storyViewSortGroup(views[j], reactionsFirst, forwardsFirst)
if gi != gj {
return gi < gj
}
if views[i].Date != views[j].Date {
return views[i].Date > views[j].Date
}
if storyViewCursorKey(views[i]) != storyViewCursorKey(views[j]) {
return storyViewCursorKey(views[i]) > storyViewCursorKey(views[j])
}
return storyViewCursorMessageID(views[i]) > storyViewCursorMessageID(views[j])
})
}
func pageStoryViews(views []domain.StoryView, limit int, cursor storyInteractionCursor, reactionsFirst, forwardsFirst bool) ([]domain.StoryView, string) {
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
limit = domain.MaxStoryInteractionListLimit
}
out := make([]domain.StoryView, 0, min(limit, len(views)))
for _, view := range views {
if !storyViewAfterCursor(view, cursor, reactionsFirst, forwardsFirst) {
continue
}
if len(out) == limit {
return out, formatStoryInteractionCursor(out[len(out)-1], reactionsFirst, forwardsFirst)
}
out = append(out, cloneStoryView(view))
}
return out, ""
}
func storyViewAfterCursor(view domain.StoryView, cursor storyInteractionCursor, reactionsFirst, forwardsFirst bool) bool {
if !cursor.set {
return true
}
group := storyViewSortGroup(view, reactionsFirst, forwardsFirst)
if group != cursor.group {
return group > cursor.group
}
if view.Date != cursor.date {
return view.Date < cursor.date
}
key := storyViewCursorKey(view)
if key != cursor.viewerID {
return key < cursor.viewerID
}
return storyViewCursorMessageID(view) < cursor.messageID
}
func filterStoryViewsForList(views []domain.StoryView, req domain.StoryViewListRequest, profiles map[int64]domain.User, contacts map[int64]domain.Contact) []domain.StoryView {
query := strings.ToLower(strings.TrimSpace(req.Query))
if query == "" && !req.JustContacts {
return views
}
out := make([]domain.StoryView, 0, len(views))
for _, view := range views {
contact, isContact := contacts[view.ViewerID]
if req.JustContacts && !isContact {
continue
}
if query != "" && !storyViewerMatchesQuery(view.ViewerID, query, profiles[view.ViewerID], contact, isContact) {
continue
}
out = append(out, view)
}
return out
}
func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User, contact domain.Contact, isContact bool) bool {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return true
}
trimmedAt := strings.TrimPrefix(query, "@")
candidates := []string{
profile.FirstName,
profile.LastName,
strings.TrimSpace(profile.FirstName + " " + profile.LastName),
profile.Username,
strconv.FormatInt(viewerID, 10),
}
if isContact {
candidates = append(candidates,
contact.FirstName,
contact.LastName,
strings.TrimSpace(contact.FirstName+" "+contact.LastName),
contact.Phone,
contact.User.FirstName,
contact.User.LastName,
strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName),
contact.User.Username,
)
}
for _, candidate := range candidates {
value := strings.ToLower(strings.TrimSpace(candidate))
if value == "" {
continue
}
if strings.Contains(value, query) || (trimmedAt != query && strings.Contains(value, trimmedAt)) {
return true
}
}
return false
}
func cloneStoryViewerProfiles(in map[int64]domain.User) map[int64]domain.User {
if len(in) == 0 {
return nil
}
out := make(map[int64]domain.User, len(in))
for id, user := range in {
out[id] = user
}
return out
}
func cloneStoryViewerContacts(in map[int64]domain.Contact) map[int64]domain.Contact {
if len(in) == 0 {
return nil
}
out := make(map[int64]domain.Contact, len(in))
for id, contact := range in {
out[id] = cloneContact(contact)
}
return out
}
func storyViewCounts(views []domain.StoryView) (count, viewsCount, reactionsCount int) {
for _, view := range views {
if view.Repost != nil || view.ViewerID == 0 {
continue
}
count++
viewsCount++
if view.Reaction != nil {
reactionsCount++
}
}
return count, viewsCount, reactionsCount
}
func clampStoryInteractionLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
return domain.MaxStoryInteractionListLimit
}
return limit
}
func clampStoryPrivacyFanoutLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets {
return domain.MaxStoryPrivacyFanoutTargets
}
return limit
}
func adjustStoryReactionCounts(views domain.StoryViews, old, next *domain.MessageReaction) domain.StoryViews {
if old != nil {
for i := range views.Reactions {
if views.Reactions[i].Reaction == *old {
views.Reactions[i].Count--
if views.Reactions[i].Count <= 0 {
views.Reactions = append(views.Reactions[:i], views.Reactions[i+1:]...)
}
break
}
}
if views.ReactionsCount > 0 {
views.ReactionsCount--
}
}
if next != nil {
found := false
for i := range views.Reactions {
if views.Reactions[i].Reaction == *next {
views.Reactions[i].Count++
found = true
break
}
}
if !found {
views.Reactions = append(views.Reactions, domain.ChannelMessageReactionCount{Reaction: *next, Count: 1, ChosenOrder: len(views.Reactions)})
}
views.ReactionsCount++
}
return views
}
func prependRecentViewer(in []int64, viewerID int64) []int64 {
out := make([]int64, 0, min(3, len(in)+1))
out = append(out, viewerID)
for _, id := range in {
if id == viewerID {
continue
}
out = append(out, id)
if len(out) == 3 {
break
}
}
return out
}