Documentation
¶
Index ¶
- Constants
- Variables
- func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatpb.ChatType
- func EffectiveActivityScore(score, lastSentAt time.Time) time.Time
- func GatedMint(c *Chat) (*commonpb.PublicKey, bool)
- func HoldingsLess(a, b GroupHoldings) bool
- func IsDmChatType(chatType chatpb.ChatType) bool
- func IsGroupChatID(chatID *commonpb.ChatId) bool
- func MustDeriveDmChatID(chatType chatpb.ChatType, a, b *commonpb.UserId) *commonpb.ChatId
- func MustDeriveGroupChatID(creatorID *commonpb.UserId, key *chatpb.IdempotencyKey) *commonpb.ChatId
- func MustGenerateGroupChatID() *commonpb.ChatId
- func NewBlobEncryptedUploadGate(access *Access) blob.EncryptedUploadGate
- func NextActivityScore(prior, lastSentAt, sentAt time.Time) time.Time
- func SortRoster(members []GroupMember)
- func ValidateDescription(description string) error
- func ValidateFeaturedGroups(chatIDs []*commonpb.ChatId) error
- func ValidateGroupHoldings(h GroupHoldings) error
- type Access
- func (a *Access) CanDelete(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) CanListenWithRules(ctx context.Context, chatID *commonpb.ChatId, rules ChatRules, ...) (bool, error)
- func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) IsMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) IsMemberWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId) (bool, error)
- func (a *Access) ListenerStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, ...) (ListenerStanding, error)
- func (a *Access) ListenerStandingWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId, ...) (ListenerStanding, error)
- func (a *Access) ListenerStandingWithRules(ctx context.Context, chatID *commonpb.ChatId, rules ChatRules, ...) (ListenerStanding, error)
- func (a *Access) PublicListenerStanding(c *Chat) ListenerStanding
- func (a *Access) Rules() *RuleEvaluator
- func (a *Access) SpeakerStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (SpeakerStanding, error)
- type AccessOption
- type BanResult
- type BlocklistReader
- type Chat
- func (c *Chat) ChatRules() ChatRules
- func (c *Chat) Clone() *Chat
- func (c *Chat) HasMember(userID *commonpb.UserId) bool
- func (c *Chat) IsCreator(userID *commonpb.UserId) bool
- func (c *Chat) PermissionsFor(userID *commonpb.UserId, isMember bool) Permissions
- func (c *Chat) Rules() *chatpb.Rules
- func (c *Chat) ToProto() *chatpb.Metadata
- type ChatEventPublisher
- type ChatRules
- type CreationRules
- type DmFeedCursor
- type Encryption
- type FeaturedGroups
- type FeedExclusions
- type GroupEdit
- type GroupHoldings
- type GroupMember
- type GroupMembership
- type HoldingsRefresh
- type HoldingsWorker
- type HoldingsWorkerOption
- func WithHoldingsRefreshInterval(interval time.Duration) HoldingsWorkerOption
- func WithHoldingsRosterPageSize(n int) HoldingsWorkerOption
- func WithHoldingsWorkerBackoff(base, maxDelay time.Duration) HoldingsWorkerOption
- func WithHoldingsWorkerBatchSize(n int) HoldingsWorkerOption
- func WithHoldingsWorkerClaimLease(lease time.Duration) HoldingsWorkerOption
- func WithHoldingsWorkerMaxConcurrency(n int) HoldingsWorkerOption
- func WithHoldingsWorkerRefreshTimeout(timeout time.Duration) HoldingsWorkerOption
- type Hydrator
- type KeyEnvelope
- type ListenerStanding
- type LobbyEntry
- type LobbyLimits
- type LobbyPosition
- type Media
- type MembersPage
- type MessageRef
- type MessagingReader
- type MinimumBalance
- type ModerationPermissions
- type Mute
- type Permissions
- type PointerRef
- type ProfileReader
- type Reading
- type RecentSender
- type RestrictedUser
- type RestrictionPosition
- type RosterPosition
- type RosterSummary
- type RuleEvaluator
- func (e *RuleEvaluator) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) CanListenWithRules(ctx context.Context, rules RuleSet, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) CanSpeakWithRules(ctx context.Context, rules RuleSet, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) RulesOf(c *Chat) *chatpb.Rules
- type RuleSet
- type Server
- func (s *Server) AdmitLobbyMember(ctx context.Context, req *chatpb.AdmitLobbyMemberRequest) (*chatpb.AdmitLobbyMemberResponse, error)
- func (s *Server) BanUser(ctx context.Context, req *chatpb.BanUserRequest) (*chatpb.BanUserResponse, error)
- func (s *Server) DenyLobbyMember(ctx context.Context, req *chatpb.DenyLobbyMemberRequest) (*chatpb.DenyLobbyMemberResponse, error)
- func (s *Server) EditChat(ctx context.Context, req *chatpb.EditChatRequest) (*chatpb.EditChatResponse, error)
- func (s *Server) EnterLobby(ctx context.Context, req *chatpb.EnterLobbyRequest) (*chatpb.EnterLobbyResponse, error)
- func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chatpb.GetChatResponse, error)
- func (s *Server) GetDmChatFeed(ctx context.Context, req *chatpb.GetDmChatFeedRequest) (*chatpb.GetDmChatFeedResponse, error)
- func (s *Server) GetFeaturedGroups(ctx context.Context, req *chatpb.GetFeaturedGroupsRequest) (*chatpb.GetFeaturedGroupsResponse, error)
- func (s *Server) GetGroupChatFeed(ctx context.Context, req *chatpb.GetGroupChatFeedRequest) (*chatpb.GetGroupChatFeedResponse, error)
- func (s *Server) GetKeyEnvelope(ctx context.Context, req *chatpb.GetKeyEnvelopeRequest) (*chatpb.GetKeyEnvelopeResponse, error)
- func (s *Server) GetLobbyMembers(ctx context.Context, req *chatpb.GetLobbyMembersRequest) (*chatpb.GetLobbyMembersResponse, error)
- func (s *Server) GetMentionSuggestions(ctx context.Context, req *chatpb.GetMentionSuggestionsRequest) (*chatpb.GetMentionSuggestionsResponse, error)
- func (s *Server) GetRestrictedUsers(ctx context.Context, req *chatpb.GetRestrictedUsersRequest) (*chatpb.GetRestrictedUsersResponse, error)
- func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*chatpb.GetRosterResponse, error)
- func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*chatpb.JoinChatResponse, error)
- func (s *Server) LeaveChat(ctx context.Context, req *chatpb.LeaveChatRequest) (*chatpb.LeaveChatResponse, error)
- func (s *Server) LeaveLobby(ctx context.Context, req *chatpb.LeaveLobbyRequest) (*chatpb.LeaveLobbyResponse, error)
- func (s *Server) MuteChat(ctx context.Context, req *chatpb.MuteChatRequest) (*chatpb.MuteChatResponse, error)
- func (s *Server) SampleChatters(ctx context.Context, req *chatpb.SampleChattersRequest) (*chatpb.SampleChattersResponse, error)
- func (s *Server) SetFeaturedGroups(ctx context.Context, req *chatpb.SetFeaturedGroupsRequest) (*chatpb.SetFeaturedGroupsResponse, error)
- func (s *Server) SetKeyEnvelope(ctx context.Context, req *chatpb.SetKeyEnvelopeRequest) (*chatpb.SetKeyEnvelopeResponse, error)
- func (s *Server) SilenceUser(ctx context.Context, req *chatpb.SilenceUserRequest) (*chatpb.SilenceUserResponse, error)
- func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (*chatpb.StartChatResponse, error)
- func (s *Server) UnbanUser(ctx context.Context, req *chatpb.UnbanUserRequest) (*chatpb.UnbanUserResponse, error)
- func (s *Server) UnmuteChat(ctx context.Context, req *chatpb.UnmuteChatRequest) (*chatpb.UnmuteChatResponse, error)
- func (s *Server) UnsilenceUser(ctx context.Context, req *chatpb.UnsilenceUserRequest) (*chatpb.UnsilenceUserResponse, error)
- type ServerOption
- type Silence
- type SpeakerStanding
- type Store
- type SystemMessenger
- type UserEventPublisher
- type ViewerState
- func (v ViewerState) ActiveMute(now time.Time) *Mute
- func (v ViewerState) ActiveSilence(now time.Time) *Silence
- func (v ViewerState) Clone() ViewerState
- func (v ViewerState) IsBanned() bool
- func (v ViewerState) IsRestricted() bool
- func (v ViewerState) ToProto(permissions Permissions) *chatpb.ViewerState
Constants ¶
const ( // ActivityScoreHalfLife is how long a recorded send takes to count half // as much toward an activity score. ActivityScoreHalfLife = 72 * time.Hour // ActivityScoreFullWeightGap is the quiet before a send at which it // counts in full toward an activity score; a send after less counts in // proportion. ActivityScoreFullWeightGap = 24 * time.Hour // ActivityScoreMaxLead is the furthest an activity score may run ahead of // the send that set it. ActivityScoreMaxLead = 7 * 24 * time.Hour )
A group's activity score is a frequency-weighted ordering of its senders kept on each activity record beside the last send (see RecentSender): a record ranks above another when its recorded sends, each weighted and each decaying with ActivityScoreHalfLife, sum to more as of any one moment.
A send is weighted by the quiet before it: the time since the user's previous recorded send over ActivityScoreFullWeightGap, at most one, and one for their first. So the score counts the days a user shows up rather than the messages they send: a day of chatting weighs about what one message that day would, and an hour-long burst barely more than its first message, however many sends the throttle (ActivityRecordInterval) lets through.
It is stored as a time, not a weight: the moment at which a single send would rank equal to the record. A user with one recorded send scores their send time, and further sends move the score ahead of the last one, possibly past now. In that form the decay never moves a score: comparing two decayed sums at any moment compares their scores, because the moment cancels out of the comparison, so a store can keep the score as a sort key and rewrite it only on a recorded send. A record written before scores existed reads as scoring its last send, which is exactly its score had it had one send (see EffectiveActivityScore). A score is never below its last send, so a record never ranks below someone who sent once, after it.
A user who sends every day settles about 6.8 days ahead of their last send, which is the most any steady pattern reaches at a 3-day half-life and a 1-day full-weight gap; one who sends weekly, about a day. That lead is how long they rank above someone who sends once after them. ActivityScoreMaxLead bounds it whatever the history; at these values no steady pattern reaches it.
A group's chatter sample is ordered by it (see Store.GetActiveSenders).
const ( // ActivityRecordInterval is the least time between two recorded sends by // one user in one group. ActivityRecordInterval = time.Minute // ActivityRetention is how long a group's activity record is kept after // its last recorded send. Old records cost nothing to a read, which takes // only the most recent, and are exactly the suggestions a group revived // after a long quiet needs, so this is long; it is finite so the records // of users who stopped using the app, deleted accounts included, age out // on their own. ActivityRetention = 365 * 24 * time.Hour )
A group's activity record is what the chat remembers of each user's recent sends in it, one record per (group, user): today only when they last sent (see RecentSender), which orders a group's recent senders for mention suggestions, most recent first. It is a fact about the message log, not about membership: a record outlives its user's departure, and nothing reads the roster to answer it, because a user sees the senders in a chat's log without knowing who still belongs to it. Everyone it names was a member when they sent, since sending requires it.
It is derived and best effort. A send is recorded after it lands and outside its write, so a failed record costs a suggestion and never a message, and the next send repairs it. Records are throttled: a send within ActivityRecordInterval of the last one recorded is not recorded, so a burst costs one write and a recorded time may trail the latest send by up to the interval. Records expire ActivityRetention after the last recorded send; expiry is garbage collection, not semantics, so a reader may still see a record past it.
Beside recency, each record carries an activity score, a frequency-weighted ordering of the same sends (see NextActivityScore), which is why it is an activity record and not a send time alone. The score is maintained with every recorded send, and orders a group's chatter sample (see Store.GetActiveSenders).
const DefaultListenerAdmissionTTL = 30 * time.Second
DefaultListenerAdmissionTTL is how long Access remembers that a non-member satisfied a group's listener rules (see Access.CanListen). It bounds how long a reader whose balance has since dropped, or whose staff flag was since revoked, keeps reading before the rules are asked again.
const DefaultSpeakerAdmissionTTL = 30 * time.Second
DefaultSpeakerAdmissionTTL is how long Access remembers that a member of a public group satisfied its listener and speaker rules (see Access.SpeakerStanding). It bounds how long a member whose balance has since dropped, or whose staff flag was since revoked, keeps speaking before the rules are asked again.
const DmChatIDSize = 32
DmChatIDSize is the length, in bytes, of a DM chat ID: a SHA-256 digest over the DM's type and members (see MustDeriveDmChatID).
const GroupChatIDSize = 16
GroupChatIDSize is the length, in bytes, of a group chat ID: the leading bytes of a SHA-256 digest over the creator and the idempotency key of the request that created it (see MustDeriveGroupChatID). Group membership is mutable, so a group's ID cannot be member-derived; it is an opaque value fixed at creation, and nothing parses it as anything else.
The two sizes never overlap, so a chat ID's length is its type family's discriminator: 32 bytes is a DM, 16 bytes is a group. Every DM path must reject 16-byte IDs and every group path must reject 32-byte IDs — that enforcement is what keeps the discriminator sound (an ID can never be claimed as both a derived DM and a group).
const IdempotencyKeySize = 16
IdempotencyKeySize is the length, in bytes, of a client's IdempotencyKey: a UUID's worth of nonce, which is what a client typically mints for one.
const MaxDescriptionLength = 160
MaxDescriptionLength is the most characters a group's description may run to, matching the proto's limit on Metadata.description so a stored description is always one the wire accepts.
const MaxFeaturedGroups = 10
MaxFeaturedGroups is the most groups a user may feature. A profile shows them all, so it is small; it is also what keeps a replace within one store transaction.
const MaxGroupChatCreationMembers = 50
MaxGroupChatCreationMembers is the largest initial member set a group chat may be created with; a larger set is rejected with ErrTooManyMembers and the caller grows the group with AddGroupMembers instead.
Creation is all-or-nothing, so the bound is what a single atomic write can cover: a DynamoDB transaction caps at 100 items, and creation spends two on the group's own records (the canonical record and its member count). The value is held well under that ceiling so the membership records and the group's records always commit together — there is no partial-creation state for a caller to reconcile.
const MinGroupHoldingsHolders = 10
MinGroupHoldingsHolders is the fewest members holding the currency a group's snapshot must count to be listed, and so shown and ranked (see GroupHoldings.Listed): a group with fewer is not one a community lists, and its total would say what a handful of people hold. The proto leaves the minimum to the server.
Variables ¶
var ( // ErrChatNotFound indicates that no chat exists for the given chat ID. ErrChatNotFound = errors.New("chat not found") // ErrChatExists indicates that a chat with the given ID already exists. ErrChatExists = errors.New("chat already exists") // ErrTooManyMembers indicates that a group chat was created with more // initial members than MaxGroupChatCreationMembers allows. ErrTooManyMembers = errors.New("too many initial members") // ErrNoMembers indicates that a chat was created with an empty member set. // A chat nobody belongs to is unreachable: no one can read it, send to it, // or be added to it, since every such path gates on membership. ErrNoMembers = errors.New("chat must have at least one member") // ErrLobbyFull indicates that a private group's lobby holds as many users // as the limits allow (see LobbyLimits.LobbySize). ErrLobbyFull = errors.New("lobby full") // ErrTooManyLobbies indicates that a user is waiting in as many lobbies // as the limits allow (see LobbyLimits.LobbiesPerUser). ErrTooManyLobbies = errors.New("too many lobbies") // ErrNotInLobby indicates that a user is not waiting in the lobby they // were to be admitted from. ErrNotInLobby = errors.New("not in lobby") // ErrAlreadyMember indicates that a user is a member of the group whose // lobby they were to enter. ErrAlreadyMember = errors.New("already a member") // ErrKeyEnvelopeNotFound indicates that a user has no key envelope stored // for a chat. ErrKeyEnvelopeNotFound = errors.New("key envelope not found") // ErrBanned indicates that a user is banned from the group they were to // join, enter the lobby of, or be admitted to (see Store.BanUser). It is // judged in the write that would have let them in, against their // ViewerState as of that write, so a ban and a concurrent join can never // both land. ErrBanned = errors.New("banned") )
var DefaultLobbyLimits = LobbyLimits{LobbySize: 100, LobbiesPerUser: 100}
DefaultLobbyLimits are the caps in production, deliberately modest to start: a lobby of a hundred is as many as a creator admits in a sitting, and a user waiting in a hundred groups is not waiting to join them. Either is raised here when it binds.
var ErrGroupFeedTooLarge = errors.New("group feed exceeds the token window")
ErrGroupFeedTooLarge indicates that a user is in more group chats than maxGroupFeedChats, so their feed cannot be carried in a paging token.
var ErrHoldingsMintMismatch = errors.New("holdings mint differs from the group's")
ErrHoldingsMintMismatch is returned when a write names a mint other than the one a group's holdings already record (see Store.PutGroupHoldings and Store.EnqueueHoldingsRefresh). A group is gated on one mint, fixed at creation, so its queue entry and its snapshots are all of that mint; a write of another is a caller's error, refused rather than left to move the currency a refresh totals.
var ErrInvalidDescription = errors.New("invalid description")
ErrInvalidDescription is returned by ValidateDescription for a description a group may not carry.
var ErrInvalidGroupHoldings = errors.New("invalid group holdings")
ErrInvalidGroupHoldings is returned by ValidateGroupHoldings.
var ErrInvalidRules = errors.New("invalid chat rules")
ErrInvalidRules is returned by RulesFromProto for a rule set a group cannot carry (see RulesFromProto for what one can).
var ErrMuteUntilOutOfRange = errors.New("mute until is out of range")
ErrMuteUntilOutOfRange indicates that a timed mute ends outside the range a store can record (see Mute.Until).
var ErrNotGovernedByRules = errors.New("chat is not governed by rules")
ErrNotGovernedByRules is returned by a RuleEvaluator asked about a chat whose rules do not govern it: a private group (see RuleSet). It is an error, never an answer, so the caller that should have asked Access finds out rather than being told no one, or everyone, is admitted.
var ErrSilenceUntilOutOfRange = errors.New("silence until is out of range")
ErrSilenceUntilOutOfRange indicates that a timed silence ends outside the range a store can record (see Silence.Until).
var MaxMuteUntil = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
MaxMuteUntil bounds a timed mute: Mute.Until must be strictly before it. Stores encode an indefinite mute as this instant, so a timed mute reaching it would read back as indefinite; rejecting the boundary keeps the two distinguishable. No real mute ends in the year 9999, so nothing is lost.
Functions ¶
func DeriveDmChatType ¶ added in v1.23.0
DeriveDmChatType reports which DM type's canonical derivation over the members produces chatID, letting callers that already hold a chat's members recover its type without a store read. It returns UNKNOWN when no DM type matches — including any malformed input — so callers must treat UNKNOWN as "not a derivable DM", not an error.
This works because every DM's ID commits to its type via the derivation domain. A future chat type whose ID is not member-derived (e.g. group chats) will return UNKNOWN here and needs its own discriminator.
func EffectiveActivityScore ¶ added in v1.34.0
EffectiveActivityScore returns the score of an activity record whose last recorded send is lastSentAt and whose stored score is score, the zero time when it has none. A record with no score, written before scores existed, scores its last send. So does one whose score trails its last send, which only a writer that records sends without scoring them leaves behind, at the cost of the sends it did not score: a score is never below the last send.
func GatedMint ¶ added in v1.35.0
GatedMint returns the mint of the currency c is gated on, and false when it is gated on none. A group is gated on a currency when it is public and its minimum balance requirements, listener and speaker alike, all name the same one mint, and that mint is not the core mint (USDF, which every balance is valued in and no community is built around). The fiat currency a requirement is denominated in does not matter. A group with no minimum balance requirement is gated on nothing, as is one with a requirement that names no mint (and so counts every mint) or whose requirements name different mints, and a DM or a private group (which carries no rules) never is.
It reads the record alone. A group can only be created gated on a currency launched on Flipcash and available (its creator is valued in it before the group is written), so the mint it returns is always one, and a refresh computes its holdings without asking.
func HoldingsLess ¶ added in v1.35.0
func HoldingsLess(a, b GroupHoldings) bool
HoldingsLess orders snapshots as a currency's groups are ranked: by Quarks descending, then by chat ID descending, so the order is total for any set of distinct groups, and a position in it (quarks, chat ID) is one a paged read could resume strictly after.
func IsDmChatType ¶ added in v1.24.0
IsDmChatType reports whether chatType is a direct-message chat type — one with two participants and a canonical, member-derived ID — as opposed to a group or unknown chat.
func IsGroupChatID ¶ added in v1.26.0
IsGroupChatID reports whether chatID is a group chat ID, by length (see GroupChatIDSize).
func MustDeriveDmChatID ¶
MustDeriveDmChatID returns the deterministic chat ID for a DM of the given type between two users.
The ID is derived purely from the DM type and the participants, so it is stable across calls and independent of who initiates the chat: MustDeriveDmChatID(t, a, b) always equals MustDeriveDmChatID(t, b, a). This lets either user open the canonical DM without a prior lookup, and makes creation idempotent.
Derivation hashes the byte-sorted, de-duplicated set of user IDs (a DM with oneself collapses to a single member) under a domain-separation prefix that encodes the DM type. Contact DMs use the bare prefix because they predate typed derivation, and their chat IDs must not change; the domains cannot alias each other because member sets are fixed-width, so the two encodings never produce equal-length hash inputs. Since the input is a sorted set, member ordering and duplicates do not affect the result. The SHA-256 digest is DmChatIDSize bytes wide by construction.
It panics on an unspecified chat type, or if either user ID is not the expected fixed width, which would be a programming error: all user IDs in the system are UUIDs. Fixed-width members also make the sorted concatenation unambiguous without length prefixing.
func MustDeriveGroupChatID ¶ added in v1.31.0
MustDeriveGroupChatID returns the ID of the group chat that a StartChat from creatorID carrying key creates.
A group's ID is derived from its creator and the request's idempotency key rather than minted at random, so that the ID is itself the idempotency record: a retried request derives the same ID, and the store's uniqueness condition on creation turns the duplicate into a read of the original (see Server.StartChat). Nothing else is stored, and the mapping never expires.
The creator is part of the input so that no client can derive another user's chat ID: the key is a nonce the client chooses, and two users who choose the same one derive two distinct groups. A client can predict the ID of its own group, which is harmless — group IDs are not secrets (see Server.GetChat). Group chat IDs remain server-derived: a client-supplied ID is never trusted as a chat's identity.
The digest is truncated to GroupChatIDSize bytes, which is what makes the result a group ID by length, and then stamped as a version 8 UUID (RFC 9562 section 5.8, the custom version, which the spec offers precisely for a truncated hash like this one). Group IDs are opaque and nothing parses them, but every group ID minted before this derivation was a random UUID, and the stamp keeps that shape true of all of them, at a cost of six bits nobody will miss. It panics if either input is not its fixed width, which would be a programming error: all user IDs in the system are UUIDs, and a request's key is checked to width before it reaches here. Fixed-width inputs also make the concatenation unambiguous without length prefixing.
func MustGenerateGroupChatID ¶ added in v1.26.0
MustGenerateGroupChatID mints a random group chat ID. StartChat does not use it — a group created by a client request derives its ID from the request (see MustDeriveGroupChatID) — so it is for a group that has no request behind it, which today means tests. Group chat IDs are always produced server-side either way; a client-supplied ID is never trusted as a chat's identity.
func NewBlobEncryptedUploadGate ¶ added in v1.34.0
func NewBlobEncryptedUploadGate(access *Access) blob.EncryptedUploadGate
NewBlobEncryptedUploadGate returns a blob.EncryptedUploadGate backed by the given Access, for wiring the blob service. It should be the one Access the chat and messaging servers share, so the gate and the sends it stands before agree.
func NextActivityScore ¶ added in v1.34.0
NextActivityScore returns an activity record's score after a send at sentAt is recorded on it, given its score before (see EffectiveActivityScore) and its last recorded send before, or the zero time for both for a user with no record. The result is at millisecond precision, like the record's send time, never before sentAt, and never below prior, so a score only moves forward with the sends that set it.
func SortRoster ¶ added in v1.32.0
func SortRoster(members []GroupMember)
SortRoster sorts members into roster order (see RosterPosition).
func ValidateDescription ¶ added in v1.34.0
ValidateDescription reports whether description is one a group may carry. The empty description is valid: it is how a group has none. Otherwise it must have something visible in it, run to at most MaxDescriptionLength characters, and carry no control characters beyond a line break — a description is shown as a few lines of text, like a user's bio (see profile.ValidateBio, whose rules these are), and anything else in it would render as nothing or as noise. Returns ErrInvalidDescription otherwise.
Nothing is normalized: the description is stored exactly as written, line breaks and surrounding whitespace included.
func ValidateFeaturedGroups ¶ added in v1.34.0
ValidateFeaturedGroups returns an error unless chatIDs is a list a store accepts as a user's featured groups: at most MaxFeaturedGroups group chat IDs, none repeated. An empty list is valid and clears the featured groups. A repeat is refused rather than collapsed, since which of its positions to keep is the caller's choice. It does not check that the groups exist or are public: that needs their records, which are the setter's to read.
func ValidateGroupHoldings ¶ added in v1.35.0
func ValidateGroupHoldings(h GroupHoldings) error
ValidateGroupHoldings returns an error unless h is a snapshot a store accepts: a group chat ID, a mint the shape of a public key, a holder count that agrees with the total (no holders hold nothing, and holders hold something), and a time it was taken. Any number of holders is valid, including none: whether a snapshot is listed is GroupHoldings.Listed, not validity. It does not check that the group exists or is gated on Mint: those need the group's record, and are the caller's.
Types ¶
type Access ¶ added in v1.30.0
type Access struct {
// contains filtered or unexported fields
}
Access answers the questions every chat and messaging RPC asks before acting on behalf of a user, so the chat and messaging services — and any other domain that gates on a chat, such as a blob granted to a chat's audience — enforce one definition of who may do what in a chat:
- IsMember: is the user on the chat's roster. The gate on every write that is not a send but still belongs to a member alone — a pointer advance, a reaction — and on anything that would leave a record in the chat on the user's behalf.
- CanListen: may the user see the chat — its metadata, its messages, its pointers and reactions. A member always may. A non-member may read a public group, and only a public group, if they satisfy its listener rules right now, which every non-member does when it carries none.
- CanSpeak: may the user produce something other members see — a message, an edit, a typing notification. Members only, only as the chat's governance allows, and in a group only while its creator has not silenced them (see Silence).
- CanDelete: may the user delete their own message. CanSpeak, except that a silence does not take it away: a silenced member may still take back what they said.
Membership is always checked first: it is the cheaper check (a keyed read, cached for a DM), and it answers for a member without evaluating a rule — a member whose balance has since dipped under the group's requirement is still a member and still reads (see messaging/access.go for why membership stands for the rules on a member's reads). Past membership, each gate switches on how the chat is governed (see governance): a DM, a public group governed by its rules, or a private group governed by its creator and key. Rules are evaluated only where they decide the answer: for a non-member's read of a public group, and for a send in a DM or a public group.
Evaluating a group's listener rules on behalf of a non-member is what lets someone who satisfies them preview the group before joining. It is also a cost the RuleEvaluator was designed to avoid: a minimum balance is a valuation by the OCP server, and a non-member browsing a group would pay it on every page. So a non-member's admission is remembered for listenerAdmissionTTL, and only an admission — a refusal is never cached, so a user who tops up their balance is admitted on their very next read. The window is the same kind of lag a member already has indefinitely: for its duration a reader who no longer satisfies the rules keeps reading. A hit does not extend the window, so a reader who keeps reading is re-evaluated once per window, not never.
A member's speech in a public group is remembered the same way, for speakerAdmissionTTL, since every send, edit, deletion and typing notification asks the rules again and a balance rule makes each one a valuation. The cache stands in for the rules' verdict only, never for membership, which is read on every ask: a member who leaves is refused at once (leaving does not forget the admission, so one who rejoins within the window speaks on it, having passed JoinChat's own rules). Only an admission is remembered, so a member who tops up speaks on their next send, and a hit does not extend the window. Only a verdict that read something is remembered: a public group with no rules, or whose only rule is that its creator speaks, is decided off the rules alone, as cheaply as a remembered verdict is found, so nothing is kept for it (see RuleSet.speakingReadsState). A group with listener rules and no speaker rules is remembered, since a speak check evaluates its listener rules. DMs and private groups are not remembered: neither values a balance to speak (see dmSpeaker and privateGroupSpeaker).
A DM admits its two members and no one else (see governanceDm). A public group without listener rules is open: an empty set is satisfied by everyone, as the RuleEvaluator says of it, so every registered non-member reads it in full and anyone may join it (see Server.JoinChat), and its public view is shown (see PublicListenerStanding). Reads and joins agree on this, since gating a read that a join, open to all, would grant protects nothing. Every group created through StartChat carries a minimum listener balance (see RulesFromProto), but the store does not require one, so a group written before that rule existed, or by an operator, may carry none and is open. A group meant to be its members' alone carries a listener rule; one with speaker rules alone (see Chat.MinimumSpeakerBalance) is open to read and join, and gated only to speak.
A non-member of a group whose listener rules they do not satisfy is not nothing to the group: they may read it redacted (see ListenerStanding.CanPreview and redact.Message) — that the messages exist and their shape, never what they say. Every non-member of a public group may, whatever its rules. What a read is answered with — full, redacted, or nothing — is the viewer's standing combined with what the client asked for (see ListenerStanding.Reading and messagingpb.ViewMode): a client that wants a group blurred asks for REDACTED, and is answered from membership and the rules' existence without the rules being evaluated.
A private group (see Chat.IsPrivate) is its members' alone in every form: no non-member listens to it or previews it, whatever mode they ask under, and it has no public view. That is decided by its governance (governancePrivateGroup), on the flag itself, and never by its rules: it has no RuleSet, so a record written with rules cannot open it. Its record is still shown to any registered user (see Server.GetChat), which is how a user sees what they would ask to join. Its members speak in it on membership and the group's key alone (see privateGroupSpeaker).
A group's creator moderates it (see Server.SilenceUser and Server.BanUser), and both restrictions are answered here, read from the user's ViewerState in the group, strongly consistent, on every ask that turns on one — never remembered, so a restriction takes effect on the very next ask on every server:
- A silence takes speech from a member: CanSpeak is false while it is active (see ViewerState.ActiveSilence), whatever the chat's governance would say. It is read alongside membership on every speaker ask in a group, public or private, and judged after the governance's verdict, so a remembered admission to speak (see above) answers the rules alone and never hides a silence. Deletion of one's own messages is carved out (see CanDelete); everything else a silence leaves alone — reading, reacting, voting, paying a requested share, boosting — gates on membership or reading and never asks.
- A ban takes a full read from a non-member of a public group, whatever its listener rules say of them: a banned user previews it, redacted, as anyone may (see publicGroupNonMember). It is read only once the rules or a remembered admission would grant a full read, since that is all a ban takes away, and then on every such ask, so an admission from before the ban never outlives it. A REDACTED read reads no ban, since it is answered without the rules either way, and anyone may see a public group's public view. A banned user is never a member (see ErrBanned), so no member's gate reads one, and a private group gives a non-member nothing already.
A DM has no creator to moderate it, so no DM ask reads a restriction.
A chat that does not exist admits no one: IsMember, CanListen and CanSpeak all report false, not ErrChatNotFound, for a chat ID nothing is stored under. A caller that must tell NOT_FOUND from DENIED reads the canonical record itself and asks with CanListenWithRules or ListenerStandingWithRules.
func NewAccess ¶ added in v1.30.0
func NewAccess(chats Store, rules *RuleEvaluator, opts ...AccessOption) *Access
NewAccess constructs an Access over the chat store the servers read membership from and the RuleEvaluator they evaluate rules with. The store should be the caching store in production, so a DM's membership and a group's rules cost no read in steady state.
One Access is meant to serve every server that gates on a chat: the chat and messaging servers take it at construction rather than building their own, so a non-member's admission is evaluated and remembered once, in one window, whichever service they read through — and so the window is configured in one place.
func (*Access) CanDelete ¶ added in v1.36.0
func (a *Access) CanDelete(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
CanDelete reports whether userID may delete their own messages in chatID: they may speak there, or would but for a silence (see SpeakerStanding.Silenced). A silence stops a member adding to the conversation, never taking back what they added; every other refusal — not a member, a rule unmet, a keyless private group — refuses a deletion as it refuses a send. Whose message it is, is the caller's to check.
func (*Access) CanListen ¶ added in v1.30.0
func (a *Access) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
CanListen reports whether userID may read chatID in full: they are a member, or chatID is a group whose listener rules they satisfy (see Access). It is false, not an error, for a chat that does not exist.
func (*Access) CanListenWithRules ¶ added in v1.30.0
func (a *Access) CanListenWithRules(ctx context.Context, chatID *commonpb.ChatId, rules ChatRules, userID *commonpb.UserId) (bool, error)
CanListenWithRules is CanListen for a caller already holding the chat's rules (see ListenerStandingWithRules).
func (*Access) CanSpeak ¶ added in v1.30.0
func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
CanSpeak reports whether userID may send in chatID (see SpeakerStanding), for a caller that sends nothing the chat could judge: a typing notification, a mention suggestion.
func (*Access) IsMember ¶ added in v1.30.0
func (a *Access) IsMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
IsMember reports whether userID is on chatID's roster. It is false, not an error, for a chat that does not exist and for a group member since removed.
func (*Access) IsMemberWithChat ¶ added in v1.32.0
func (a *Access) IsMemberWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId) (bool, error)
IsMemberWithChat is IsMember for a caller already holding the chat's canonical record: a DM is answered off the record, a group from the store (see ListenerStandingWithChat).
func (*Access) ListenerStanding ¶ added in v1.34.0
func (a *Access) ListenerStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (ListenerStanding, error)
ListenerStanding is userID's standing in chatID (see ListenerStanding) as needed to answer a read under mode: membership, then for a non-member of a group only, the group's listener rules — unless mode is REDACTED, whether userID satisfies them, which everyone does when there are none. Under REDACTED the rules are not evaluated, since a placeholder is the answer either way, so a client that wants a group blurred never pays a valuation for it. The standing is zero, not an error, for a chat that does not exist.
func (*Access) ListenerStandingWithChat ¶ added in v1.34.0
func (a *Access) ListenerStandingWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId, mode messagingpb.ViewMode) (ListenerStanding, error)
ListenerStandingWithChat is ListenerStanding for a caller already holding the chat's canonical record, which decides all it can before the store is asked again: a DM's membership is read off the record's inline members (see Chat.HasMember), so a DM costs no membership read at all, and a group's rules come off the record as for ListenerStandingWithRules. A group's membership is not on its record and is read from the store, strongly consistent, as every gate reads it. The chat is taken as existing, as a caller holding its record has established.
func (*Access) ListenerStandingWithRules ¶ added in v1.34.0
func (a *Access) ListenerStandingWithRules(ctx context.Context, chatID *commonpb.ChatId, rules ChatRules, userID *commonpb.UserId, mode messagingpb.ViewMode) (ListenerStanding, error)
ListenerStandingWithRules is ListenerStanding for a caller already holding the chat's rules (read off a canonical record it loaded for its own purposes, see Chat.ChatRules), so they are not read a second time. The chat is taken as existing: a caller that has its rules has already told NOT_FOUND from everything else. A public group whose rules carry no listener rule admits every non-member, and a private group none whatever its rules (see Access). On error the standing is zero.
func (*Access) PublicListenerStanding ¶ added in v1.34.0
func (a *Access) PublicListenerStanding(c *Chat) ListenerStanding
PublicListenerStanding is the standing of a viewer who is no one — an unauthenticated read of a chat's public view (see chat.Server.GetChat) — towards the chat whose canonical record the caller holds. They are on no roster and are evaluated against no rule, so they stand exactly where a registered non-member reading under REDACTED does: a public group, with or without listener rules, may be previewed, and nothing else admits them in any form. Nothing is read: the governance comes off the record.
func (*Access) Rules ¶ added in v1.30.0
func (a *Access) Rules() *RuleEvaluator
Rules is the RuleEvaluator the Access evaluates rules with, for a caller that must evaluate rules outside of a standing — a join, which the rules admit to membership, or a creation, whose rules are not stored yet.
func (*Access) SpeakerStanding ¶ added in v1.34.0
func (a *Access) SpeakerStanding(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (SpeakerStanding, error)
SpeakerStanding is userID's standing to speak in chatID (see SpeakerStanding): a non-member never speaks, and a member speaks as the chat's governance decides (see governance), and in a group only while not silenced (see Access). Membership is checked first, so a chat's rules are never evaluated for a send on behalf of a non-member. In a group the user's ViewerState is read, strongly consistent, concurrently with membership, so a silence costs a send no extra round trip; it is judged after the governance's verdict, which a silenced member is still given (as Silenced) for CanDelete. A DM reads nothing more. It is the zero value, not an error, for a chat that does not exist.
type AccessOption ¶ added in v1.30.0
type AccessOption func(*Access)
AccessOption configures an Access at construction.
func WithListenerAdmissionTTL ¶ added in v1.30.0
func WithListenerAdmissionTTL(ttl time.Duration) AccessOption
WithListenerAdmissionTTL overrides DefaultListenerAdmissionTTL: how long a non-member's satisfied listener rules stand before they are evaluated again. A zero or negative TTL remembers nothing, so every non-member read evaluates the rules — for tests, or for a deployment that would rather pay the valuation than tolerate the window.
func WithSpeakerAdmissionTTL ¶ added in v1.35.0
func WithSpeakerAdmissionTTL(ttl time.Duration) AccessOption
WithSpeakerAdmissionTTL overrides DefaultSpeakerAdmissionTTL: how long a member's satisfied rules in a public group stand before they are evaluated again for a send. A zero or negative TTL remembers nothing, so every send evaluates the rules — for tests, or for a deployment that would rather pay the valuation than tolerate the window.
type BanResult ¶ added in v1.36.0
type BanResult struct {
State ViewerState
Changed bool
Departed bool
Roster RosterSummary
LeftLobby bool
}
BanResult is what Store.BanUser did: the user's ViewerState after the call; whether the ban was made (Changed false when it was already in force, with nothing written); whether it removed the user from the roster (Departed), with Roster the group's summary as of the write — after the departure when there was one, as read otherwise; and whether it removed the user from the group's lobby (LeftLobby).
type BlocklistReader ¶ added in v1.24.0
type BlocklistReader interface {
// GetBlocked returns which of candidateIDs the owner has blocked, as a set
// keyed by string(userID.Value). Candidates the owner has not blocked are
// absent from the map.
GetBlocked(ctx context.Context, ownerID *commonpb.UserId, candidateIDs []*commonpb.UserId) (map[string]bool, error)
}
BlocklistReader is the read slice of the blocklist domain the Chat service needs to compute per-viewer hidden state and to keep users the viewer blocked out of mention suggestions. Like the other readers it is declared here (consumer side) so the chat package need not import blocklist; the blocklist package supplies the concrete adapter.
type Chat ¶
type Chat struct {
ID *commonpb.ChatId
Type chatpb.ChatType
Members []*commonpb.UserId
RosterSummary RosterSummary
Title string
IsStaffOnly bool
MinimumListenerBalance *MinimumBalance
IsCreatorOnlySpeaker bool
MinimumSpeakerBalance *MinimumBalance
IsPrivate bool
CreatorID *commonpb.UserId
Description string
ProfilePictureBlobID *blobpb.BlobId
CoverPictureBlobID *blobpb.BlobId
LastActivity time.Time
LastMessageID *messagingpb.MessageId
}
Chat is the stored metadata for a chat.
It deliberately holds only the state owned by the chat domain: the chat's identity, type, membership, title, and the last-activity timestamp used to order a user's chat list. The richer fields of chatpb.Metadata — member profiles, per-member message pointers, and the last message — live in other domains (profile, messaging) and are hydrated by the server layer.
Members is the full, immutable member set for a DM, and is always empty for a group chat: group membership is mutable and lives in its own store records, which no path that reads the canonical record touches. A caller that needs a group's members reads them explicitly via Store.GetMembers. Title, IsStaffOnly, MinimumListenerBalance, IsCreatorOnlySpeaker, MinimumSpeakerBalance, IsPrivate, CreatorID, Description, ProfilePictureBlobID and CoverPictureBlobID are group-only and zero for DMs.
RosterSummary describes the member list without containing it. Like Members, it is complete for a DM on any read and left zero for a group by the canonical record's reads: a group's summary is maintained alongside its membership records (see Store.GetGroupRosterSummary), filled in by the caller alongside its Members. It is derived state, ignored on PutChat.
IsStaffOnly marks a group whose membership is restricted to staff users, and MinimumListenerBalance a group whose members must hold a balance (nil when the group asks for none). Both are stored state set at creation, surfaced to clients as listener rules (see Rules) and enforced by the messaging service through a RuleEvaluator on sends. Reads, of chat metadata and of messages alike, gate on membership alone, so a member a rule excludes can still see the chat and what it requires of them; enforcing rules on membership changes is the job of whatever path mutates membership.
IsCreatorOnlySpeaker marks a group in which only its creator may speak. It is stored state, surfaced to clients as a CreatorRequirement speaker rule (see Rules) and enforced on sends like any other rule. No RPC sets it — StartChat refuses every speaker rule but a minimum balance (see RulesFromProto) — so a group carries it only when its record was written with it. A group that carries it but has no recorded creator admits no one to speak.
MinimumSpeakerBalance is a balance a member must hold to speak, nil when the group asks for none. It is stored state, surfaced to clients as a MinimumBalanceRequirement speaker rule (see Rules) and enforced on sends like any other rule, on top of the listener rules. StartChat sets it when a public group asks for it, and only alongside a minimum listener balance in the same currency and mints and smaller by at least a minor unit (see RulesFromProto); like the other rules it is written with the group and never onto one that exists. It is meant to cover MinimumListenerBalance — the same currency and mints, and at least the amount — so that a speak check values the user's balance once (see RuleEvaluator.CanSpeakWithRules), which every group StartChat writes it on does. A record written otherwise may carry one that does not; it is still enforced, at the cost of a second valuation. A group with it and no listener rule, which only such a record can be, is open: anyone reads and joins it, and only speaking is gated (see Access).
IsPrivate marks a private group (see chatpb.Metadata.is_private): one whose creator admits each member, and whose messages are end-to-end encrypted with a chat key the server never holds. It is fixed at creation, like the rules, and read with them (see ChatRules). A private group carries no rules: StartChat writes none. No non-member is admitted to it in any form (see Access), no rule admits anyone to it (see RuleEvaluator), and JoinChat refuses everyone but its creator; each is decided on this flag, not on the absence of rules. Its title, description and pictures are plaintext on the record like any group's, and are shown to any registered user.
A private group's chat key reaches the server only as its members' key envelopes (see KeyEnvelope), and nothing happens in one until its creator has stored theirs: a keyless group's members are refused every send, and a keyed group's members send encrypted content under the group's scheme and nothing else (see Access.SpeakerStanding and messaging.Server.SendMessage). Its other members are admitted by its creator from its lobby (see LobbyEntry and Server.EnterLobby), with the chat key wrapped for each by the creator.
CreatorID is the user who created the group, or nil when unknown (a DM has none, and so does any group written before the field existed). It is fixed at creation and records provenance only: creating a group does not by itself make the creator a member, and whether they are is answered by the membership records, never by this field.
Description is the group's free-form description, written by its creator, or empty when it has none. It is stored exactly as written (see ValidateDescription for what may be written).
ProfilePictureBlobID is the blob holding the ORIGINAL rendition of the group's profile picture — its avatar — or nil when the group has none, and CoverPictureBlobID likewise for its cover picture, the banner behind its profile view. The chat domain stores only those handles: the full rendition sets (and their download URLs) are resolved from blob storage by the server layer on read, exactly as a user's pictures are. Read access is a blob-domain grant to the chat and to its public profile, made when a picture is set (see blob.Integration.SetAsChatMedia), so the record here carries no authorization of its own.
func (*Chat) ChatRules ¶ added in v1.35.0
ChatRules returns the chat's rules, creator and privacy as they are evaluated (see ChatRules).
func (*Chat) HasMember ¶
HasMember reports whether userID is among the record's inline Members. It is a DM's membership check — a DM's members are fixed at creation and carried on its canonical record, so a caller holding the record has the answer in hand — and says nothing about a group, whose record carries no members (see Store.GetChatByID): for a group it is always false.
func (*Chat) IsCreator ¶ added in v1.32.0
IsCreator reports whether userID created the chat (see CreatorID). It is false for a DM, and for a group whose creator was never recorded.
func (*Chat) PermissionsFor ¶ added in v1.32.0
func (c *Chat) PermissionsFor(userID *commonpb.UserId, isMember bool) Permissions
PermissionsFor returns what userID may do in the chat (see Permissions), given whether they are a member — which the caller has established, since a group's membership is not on its record. A non-member may do nothing. A member may edit and moderate the chat if and only if they created it: a DM has no creator, so a DM is never editable or moderated, and a group's creator who has left it edits and moderates nothing until they rejoin. Public and private groups alike: a private group's creator moderates it on the same footing as a public group's.
func (*Chat) Rules ¶ added in v1.30.0
Rules projects the chat's stored participation requirements onto a chatpb.Rules, or nil when the chat has none — which is every DM, and every group not created with a requirement. The proto is the single vocabulary for rules: what the client is shown (Metadata.rules) is exactly what the server evaluates (RuleEvaluator), so the two can never disagree about what a chat requires.
A group's listener rules are a StaffRequirement for a staff-only group, a MinimumBalanceRequirement for a group with a minimum listener balance, or both. Listener rules gate reading and joining, and a member must be able to listen before they can speak, so restricting the audience restricts the speakers too without repeating a rule in the speaker class. The rules are listed cheapest to evaluate first, since an evaluator stops at the first one a user fails: a staff check is a flag read, a balance check a valuation.
A group's speaker rules are a CreatorRequirement for a group only its creator may speak in (see Chat.IsCreatorOnlySpeaker), a MinimumBalanceRequirement for a group with a minimum speaker balance (see Chat.MinimumSpeakerBalance), or both, again cheapest first. The CreatorRequirement names no user: it is evaluated against the creator the group recorded, which is read with the rules (see ChatRules).
A DM's record carries none, but a DM with the Flipcash team account carries a Never speaker rule, which only the RuleEvaluator, knowing the team, can add (see RuleEvaluator.RulesOf): a chat's rules for display or evaluation come from there, not from here.
func (*Chat) ToProto ¶
ToProto projects the stored chat onto a chatpb.Metadata. Only the fields owned by the chat domain are populated: chat_id, type, title, description, last_activity, roster_summary, rules, is_private, creator (a group's, when recorded), a Member entry per member with just user_id set, and — for a group with a profile picture or cover picture — each picture carrying only its ORIGINAL rendition's blob id. The caller is responsible for hydrating member profiles, pointers, the last message, and each picture's resolved rendition set.
type ChatEventPublisher ¶ added in v1.30.0
ChatEventPublisher is the write slice of the event domain the Chat service needs to notify every stream subscribed to a group chat's topic — the chat-keyed event bus. See UserEventPublisher for why it is declared here.
type ChatRules ¶ added in v1.35.0
type ChatRules struct {
// Rules are the chat's rules, nil when it has none (see Chat.Rules).
Rules *chatpb.Rules
// CreatorID is the group's creator, nil when it has none recorded (see
// Chat.CreatorID), in which case a CreatorRequirement admits no one.
CreatorID *commonpb.UserId
// IsPrivate is whether the group is private (see Chat.IsPrivate). It is
// not a rule, and it overrides them: a private group is governed by its
// creator and its key, not by rules, so it has no RuleSet whatever rules
// its record carries (see RuleSet).
IsPrivate bool
}
ChatRules is what a chat's rules are evaluated against: the rules, the creator a CreatorRequirement names, since the rule itself names no one, and whether the group is private, which decides who speaks in it before any rule does (see Access.CanSpeak). All are fixed at creation and read together (see Store.GetGroupRules), so a store that caches one caches the others with it, and evaluating a creator-only group, or refusing a send in a private one, costs no read beyond its rules.
Everything beside Rules is metadata carried only so that evaluation is efficient: it is what a rule is relative to, read with the rules rather than looked up per evaluation. It is not part of what a client is shown (Metadata.rules is Rules alone), and a field belongs here only if it is fixed at creation like the rules, since it is cached with them forever.
Every chat has one, DMs included: a DM's is built rather than read (see RuleEvaluator.rulesFor, Chat.ChatRules), and carries no rules but the Never speaker rule of a DM with the Flipcash team account, no creator and no privacy, which are a group's alone. Only a group's is stored (see Store.GetGroupRules).
type CreationRules ¶ added in v1.35.0
type CreationRules struct {
IsStaffOnly bool
MinimumListenerBalance *MinimumBalance
// MinimumSpeakerBalance is nil when the group asks for none.
MinimumSpeakerBalance *MinimumBalance
}
CreationRules are the stored fields a new group's rules project onto (see RulesFromProto), which Chat.Rules projects back from.
func RulesFromProto ¶ added in v1.30.0
func RulesFromProto(rules *chatpb.Rules) (CreationRules, error)
RulesFromProto validates a rule set a client asked a new group to carry and projects it onto the stored fields Rules projects back from, so that a group created with rules shows exactly the rules it was asked for.
It accepts what a group can be created with today, and nothing more, so that a rule is never accepted and then silently dropped: listener rules of a staff requirement and a minimum balance, and a single speaker rule of a minimum balance (a creator-only group is made by writing its record, see Chat.IsCreatorOnlySpeaker); each kind at most once, since the record holds one of each; and a minimum balance, listener or speaker, of at least the currency's minimum transfer value — one unit at its last decimal place, a penny for USD, a yen for JPY, the smallest amount OCP lets anyone hold or move in that currency (see send.MinimumTransferValue). A requirement below it asks for a balance no one can distinguish from nothing, so the rule would admit everyone, or no one, on rounding alone. The currency is any ISO 4217 code: the proto bounds its shape, and whether OCP can value a balance in it is OCP's to say, which it does the first time the rule is evaluated — for a new group, against its creator, before anything is written (see Server.StartChat). The requirement's mints are taken as given: the proto bounds how many, and validation bounds their shape. Anything else is ErrInvalidRules — a rule the server cannot enforce is refused up front rather than stored and failed on every evaluation.
It also requires what every group must carry today: a minimum listener balance. A set without one — nil, empty, or staff-only — is ErrInvalidRules, so no group is created that a holder of nothing could join.
A minimum speaker balance is optional, and must raise the listener balance: the same currency, the same mints, and an amount larger by at least the currency's minimum transfer value (see exceedsByMinimumTransferValue). A speaker requirement in another currency or mint asks for a second holding beside the one that admits a listener, and one no larger than the listener's asks for nothing a member does not already hold. One larger by less than a minor unit asks for a difference no one can move, and that a valuation with half a minor unit of slack (see satisfiesMinimumBalance) cannot tell apart, for the same reason a requirement must meet the floor. A pair that passes is one the speaker requirement covers (see coversBalance), so every speak check in a group created this way values the user's balance once.
type DmFeedCursor ¶
DmFeedCursor marks a position within a DM feed snapshot read. The next page resumes at the chat immediately after (LastActivity, ChatID) in the feed's descending (last_activity, chat_id) order.
type Encryption ¶ added in v1.34.0
type Encryption uint8
Encryption is what a chat says of encrypted content in it (see SpeakerStanding): whether it takes any, and whether it takes anything else.
const ( // EncryptionNone: the chat takes plaintext alone. A public group. EncryptionNone Encryption = iota // EncryptionRequired: the chat takes encrypted content under // SpeakerStanding.Scheme and nothing else. A DM, whose pairwise key either // member's client derives on its own, or a private group with its key (see // Chat.IsPrivate), whose key is the group's definition. A DM from before // E2EE reached everyone may still hold plaintext messages, but no client // adds one. EncryptionRequired )
type FeaturedGroups ¶ added in v1.34.0
FeaturedGroups is a user's featured groups as stored: the groups in the user's order, and the list's version, which moves by exactly one with each write that changes the list and never otherwise (state, not delta, as a roster's). A user who has never set a list reads as no groups at version zero.
type FeedExclusions ¶ added in v1.33.0
type FeedExclusions struct {
// contains filtered or unexported fields
}
FeedExclusions are the users a Store creates every DM with them excluding from the feed (see Store.PutChat). Every Store implementation takes the users as a required constructor argument and applies them through FeedExclusions, so they mean the same thing in every backend.
It is for an account in a DM with every user whose feed nobody reads, today the Flipcash team account (see flipcashteam): a DM created with it in the feed puts every one of its DMs under one feed key ordered by time, which every creation and every message then writes at the same end of. Taking the users at construction is what keeps any path that creates a DM from forgetting to, and taking them as an argument rather than an option is what keeps a parent from forgetting to configure it: a store with no one to exclude says so with nil. The exclusion is recorded on each DM at creation, so a DM created by a store that forgot is excluding no one for good.
func NewFeedExclusions ¶ added in v1.33.0
func NewFeedExclusions(userIDs []*commonpb.UserId) FeedExclusions
NewFeedExclusions returns the exclusions of userIDs. Nil user IDs are ignored, so a parent with no team account configured may pass one, and a user named more than once is excluded once.
func (FeedExclusions) For ¶ added in v1.33.0
func (e FeedExclusions) For(c *Chat) []*commonpb.UserId
For returns the members a DM being created is to exclude from the feed: those of c's members e names, each once, in the order c lists them. It is empty when there are none, and for every group, which keeps no per-member feed copies to exclude anyone from.
type GroupEdit ¶ added in v1.32.0
type GroupEdit struct {
Title *string
Description *string
ProfilePictureBlobID *blobpb.BlobId
CoverPictureBlobID *blobpb.BlobId
}
GroupEdit is a change to a group's editable record fields (see Store.EditGroup): the title, the description, and the blobs holding the ORIGINALs of its profile picture and cover picture. A nil field is left as it is, so an edit names only what it changes and two edits of different fields never overwrite each other. The description is cleared by naming the empty one; the title and pictures cannot be cleared through an edit — a profile picture is removed with Store.SetGroupPicture — and an edit that names nothing is invalid.
type GroupHoldings ¶ added in v1.35.0
type GroupHoldings struct {
ChatID *commonpb.ChatId
Mint *commonpb.PublicKey
Quarks uint64
HolderCount uint64
AsOf time.Time
}
GroupHoldings is one group's holdings snapshot (see GatedMint): what its members held of Mint, in Mint's quarks, and how many of them held any, as of AsOf. Holdings are counted for whoever was a member when the snapshot was taken, whether or not each still met the group's requirements.
func (GroupHoldings) Clone ¶ added in v1.35.0
func (h GroupHoldings) Clone() GroupHoldings
Clone returns a deep copy of the snapshot.
func (GroupHoldings) Listed ¶ added in v1.35.0
func (h GroupHoldings) Listed() bool
Listed reports whether the snapshot is shown and ranked: a positive total held by at least MinGroupHoldingsHolders members. Every holder holds a positive amount, so the holder minimum implies the positive total; both are checked all the same. A snapshot that is not listed is stored but never ranked (see Store.GetTopGroupsByHoldings) nor hydrated as holdings_summary; any other reader of Store.GetGroupHoldings must apply it too.
func (GroupHoldings) ToProto ¶ added in v1.35.0
func (h GroupHoldings) ToProto() *chatpb.HoldingsSummary
ToProto projects the snapshot onto a chatpb.HoldingsSummary.
type GroupMember ¶ added in v1.32.0
GroupMember is one joined member of a group as its membership record holds them: who, when they most recently joined, and the roster version that placed them there — the version the group's RosterSummary moved to on their join, or zero for a member written at the group's creation, which no transition has touched (see GroupMembership for the same stamp read the other way round). A member who left and rejoined carries the rejoin's time and version. It is what a roster read hands a client per member, and what Member.joined_at and Member.version are projected from.
func (GroupMember) Position ¶ added in v1.32.0
func (m GroupMember) Position() RosterPosition
Position is the member's place in roster order (see RosterPosition).
type GroupMembership ¶ added in v1.30.0
GroupMembership is one of a user's group membership records: the chat, whether the record currently has them joined or departed, and the roster version the record was last moved at — the version of the user's own last transition on that group (see RosterSummary), or zero for a membership written at the group's creation, which no transition has touched. Only the user's own transitions move their record, so the version is a per-user watermark on the group: a transition of theirs at or below it is one the record already reflects, whichever way it went. A departed record carries the version of the departure for exactly that reason — without it, a stale copy of the join it superseded would be indistinguishable from news.
type HoldingsRefresh ¶ added in v1.35.0
type HoldingsRefresh struct {
ChatID *commonpb.ChatId
Mint *commonpb.PublicKey
DueAt time.Time
Attempts uint32
}
HoldingsRefresh is a gated group on the refresh queue: its mint, as the queue recorded it from the group's rules (which never change), when it is next due, and how many refreshes in a row have failed since the last that succeeded.
type HoldingsWorker ¶ added in v1.35.0
type HoldingsWorker struct {
// contains filtered or unexported fields
}
HoldingsWorker drains the holdings refresh queue (see HoldingsRefresh): it polls the chat store for gated groups whose refresh is due, totals what each one's members hold of the currency it is gated on (see refresh), and records the snapshot the community's ranking is read from.
It implements the OCP worker.Runtime interface, so the parent application registers it alongside its other background runtimes and controls the poll interval. It is safe to run on every server instance: workers claim a group before refreshing it, and a snapshot never replaces a newer one (see Store.PutGroupHoldings), so overlap costs duplicate reads at worst, never a stale ranking.
Construct with NewHoldingsWorker and either run it via Start (the OCP runtime entry point) or drive single ticks with Process (tests).
func NewHoldingsWorker ¶ added in v1.35.0
func NewHoldingsWorker(log *zap.Logger, chats Store, balances *balance.Client, opts ...HoldingsWorkerOption) *HoldingsWorker
NewHoldingsWorker returns a HoldingsWorker draining the chat store's holdings refresh queue, reading members' holdings through balances.
func (*HoldingsWorker) Process ¶ added in v1.35.0
func (w *HoldingsWorker) Process(runtimeCtx context.Context) (int, error)
Process runs one worker tick: it pulls the due groups and refreshes them with bounded concurrency, reporting how many this worker took on (claimed). Zero means the due queue is drained: groups other workers hold claims on are not counted.
func (*HoldingsWorker) Start ¶ added in v1.35.0
Start satisfies the OCP worker.Runtime interface: it polls the refresh queue every interval until ctx is cancelled, whose error it returns. The cadence is fixed-rate, not fixed-delay: each sleep is the interval minus the time the tick's processing took. A full batch polls again immediately, so a backlog drains at processing speed rather than one batch per interval.
type HoldingsWorkerOption ¶ added in v1.35.0
type HoldingsWorkerOption func(*HoldingsWorker)
HoldingsWorkerOption overrides one of the worker's tuning knobs.
func WithHoldingsRefreshInterval ¶ added in v1.35.0
func WithHoldingsRefreshInterval(interval time.Duration) HoldingsWorkerOption
WithHoldingsRefreshInterval overrides how long after a refresh a group is next due.
func WithHoldingsRosterPageSize ¶ added in v1.35.0
func WithHoldingsRosterPageSize(n int) HoldingsWorkerOption
WithHoldingsRosterPageSize overrides how many members a refresh reads per roster page, for tests that walk several pages without thousands of members.
func WithHoldingsWorkerBackoff ¶ added in v1.35.0
func WithHoldingsWorkerBackoff(base, maxDelay time.Duration) HoldingsWorkerOption
WithHoldingsWorkerBackoff overrides the retry backoff's base and maximum delay.
func WithHoldingsWorkerBatchSize ¶ added in v1.35.0
func WithHoldingsWorkerBatchSize(n int) HoldingsWorkerOption
WithHoldingsWorkerBatchSize overrides how many due groups one tick pulls from the queue.
func WithHoldingsWorkerClaimLease ¶ added in v1.35.0
func WithHoldingsWorkerClaimLease(lease time.Duration) HoldingsWorkerOption
WithHoldingsWorkerClaimLease overrides how far a claim pushes a group's due time out.
func WithHoldingsWorkerMaxConcurrency ¶ added in v1.35.0
func WithHoldingsWorkerMaxConcurrency(n int) HoldingsWorkerOption
WithHoldingsWorkerMaxConcurrency overrides how many groups are refreshed at once.
func WithHoldingsWorkerRefreshTimeout ¶ added in v1.35.0
func WithHoldingsWorkerRefreshTimeout(timeout time.Duration) HoldingsWorkerOption
WithHoldingsWorkerRefreshTimeout overrides the bound on a single refresh attempt.
type Hydrator ¶ added in v1.35.0
type Hydrator struct {
// contains filtered or unexported fields
}
Hydrator builds chats' proto metadata (see hydrate): the one projection of a chat's record, and of what is read beside it, that every carrier of chatpb.Metadata returns. It is the Chat service's, and is its own type so that another domain showing chats it has chosen (see PublicGroupsMetadata) shows them exactly as the Chat service would, without holding the service.
It holds no state of its own, only the readers a hydration asks, so the parent builds one and hands it to every server that shows chats.
func NewHydrator ¶ added in v1.35.0
func NewHydrator( log *zap.Logger, blocklist BlocklistReader, chats Store, media Media, messaging MessagingReader, profiles ProfileReader, rules *RuleEvaluator, teamUserID *commonpb.UserId, ) *Hydrator
func (*Hydrator) PublicGroupsMetadata ¶ added in v1.35.0
func (h *Hydrator) PublicGroupsMetadata(ctx context.Context, chatIDs []*commonpb.ChatId) ([]*chatpb.Metadata, error)
PublicGroupsMetadata returns the given groups, in the given order, as a public list shows them (see publicGroupsMetadata), reading their records first. A group with no record, or a private one, is left out. The read is eventually consistent (see Store.GetGroupChatsByID), so a group created a moment ago may be left out until it catches up. It returns an error if any ID is not a group chat ID.
It is how another domain shows a list of groups it has chosen: the projection is the chat domain's, so a list built elsewhere looks exactly like one built here.
type KeyEnvelope ¶ added in v1.34.0
type KeyEnvelope struct {
Scheme chatpb.KeyEnvelope_Scheme
Nonce []byte
Ciphertext []byte
WrappedBy *commonpb.UserId
}
KeyEnvelope is one user's key envelope for a private group (see chatpb.KeyEnvelope and Chat.IsPrivate): the group's chat key, encrypted so that only that user can open it. The server stores an envelope and hands it back to the user it is for. It cannot open one and never holds the chat key, so nothing in an envelope is checked beyond its shape, which is the boundary's job (proto validation), not the record's.
WrappedBy is who stored the envelope, as the server authenticated them: the user it is for, or the group's creator admitting them. It is the server's record, never a client's claim, and it is what a client is told to open the envelope against (GetKeyEnvelopeResponse.wrapped_by). It also decides whether the envelope can be replaced: one a user wrapped for themself stands (see Store.SetKeyEnvelope).
A private group has a key exactly when its creator has an envelope stored. Nothing else records it: the creator's envelope is the only one that can be the group's first, it is always one they wrapped themself, and it is never deleted, so its presence is one-way. Every other member's envelope is deleted with their departure (see Server.LeaveChat).
func KeyEnvelopeFromProto ¶ added in v1.34.0
func KeyEnvelopeFromProto(pb *chatpb.KeyEnvelope, wrappedBy *commonpb.UserId) KeyEnvelope
KeyEnvelopeFromProto returns the envelope pb describes, recorded as stored by wrappedBy.
func (KeyEnvelope) Clone ¶ added in v1.34.0
func (e KeyEnvelope) Clone() KeyEnvelope
Clone returns a deep copy of the envelope.
func (KeyEnvelope) Equal ¶ added in v1.34.0
func (e KeyEnvelope) Equal(other KeyEnvelope) bool
Equal reports whether the two envelopes are the same envelope: the same bytes under the same scheme, stored by the same user.
func (KeyEnvelope) IsWrappedBy ¶ added in v1.34.0
func (e KeyEnvelope) IsWrappedBy(userID *commonpb.UserId) bool
IsWrappedBy reports whether userID stored the envelope.
func (KeyEnvelope) ToProto ¶ added in v1.34.0
func (e KeyEnvelope) ToProto() *chatpb.KeyEnvelope
ToProto projects the envelope onto a chatpb.KeyEnvelope. Who wrapped it is not part of the proto envelope; a response carries it beside the envelope.
type ListenerStanding ¶ added in v1.34.0
ListenerStanding is a user's relation to a chat as Access sees it: whether they are on its roster, whether they may read it in full (see Access.CanListen), and whether they may read it redacted (see Access). A member may always read; a non-member with CanListen is a group's qualifying non-member, admitted by its listener rules (every non-member, when the group carries none); a non-member with CanPreview alone is a non-member of a public group whose listener rules they do not satisfy, or who asked under REDACTED, who may see that it has messages and their shape. CanListen implies CanPreview: whoever may read in full may read redacted (see Reading).
A standing found under ViewMode REDACTED (see Access.ListenerStanding) never evaluates the rules, so its CanListen is false for a non-member whether or not they satisfy them. Such a standing answers only the question it was asked; a caller that needs CanListen asks under a mode that evaluates it.
func (ListenerStanding) Reading ¶ added in v1.34.0
func (s ListenerStanding) Reading(mode messagingpb.ViewMode) Reading
Reading resolves the standing against what the client asked for (see messagingpb.ViewMode for the contract): FULL is full content or nothing, FULL_OR_REDACTED the most the standing allows, REDACTED a placeholder for anyone who may read the chat at all — including a member. A mode this version does not know denies, on the same footing as an unknown rule: what the client wants is not understood, so nothing is shown. The mode never widens the standing: a redacted reader is never answered in full.
type LobbyEntry ¶ added in v1.34.0
LobbyEntry is one user's place in a private group's lobby: who, and when they entered. It is what a lobby page carries per user and what LobbyMember.entered_at and Lobby.entered_at are projected from.
func (LobbyEntry) Position ¶ added in v1.34.0
func (e LobbyEntry) Position() LobbyPosition
Position is the entry's place in its lobby's order.
type LobbyLimits ¶ added in v1.34.0
type LobbyLimits struct {
// LobbySize is the most users one group's lobby holds.
LobbySize int
// LobbiesPerUser is the most lobbies one user waits in at once.
LobbiesPerUser int
}
LobbyLimits caps a lobby's size and how many lobbies one user may wait in, enforced by Store.EnterLobby in the write that records the entry. The server's are DefaultLobbyLimits; tests lower them.
type LobbyPosition ¶ added in v1.34.0
LobbyPosition is a place in a lobby's order: earliest entered first, ties broken by user ID ascending, so the order is total and a page can resume strictly after the last entry it carried. Ties in entry time are nanosecond coincidences within one lobby, so the tie-break exists for totality rather than for anything a client sees.
func (LobbyPosition) Compare ¶ added in v1.34.0
func (p LobbyPosition) Compare(o LobbyPosition) int
Compare orders two positions: negative when p precedes o.
type Media ¶ added in v1.30.0
type Media interface {
// ResolveRenditions returns each original's full rendition set — the
// ORIGINAL plus every derived rendition, each with a freshly minted,
// short-lived download URL — keyed by string(BlobId.Value). Originals that
// are unknown or not yet servable are absent from the map. It performs no
// authorization: the caller passes only ids it has already established the
// reader may see.
ResolveRenditions(ctx context.Context, ids []*blobpb.BlobId) (map[string][]*blobpb.Rendition, error)
// SetAsChatMedia attaches the blob holding a picture's ORIGINAL to chatID
// as its profile picture or cover picture — the two are one surface to the
// blob domain: it verifies that ownerID owns the blob and that it is a
// READY image original, then grants read access to it on the surfaces the
// picture is shown from. It is idempotent. It returns one of
// blob.ErrBlobNotFound, blob.ErrBlobNotReady, blob.ErrBlobRejected, or
// blob.ErrBlobInvalid when the blob cannot back a picture, having granted
// nothing; any other error is a failure to attach.
//
// It touches only blob-domain state, so it may be called for a chat that
// does not exist yet — which is how a group is created with its picture in
// place, rather than briefly without one.
SetAsChatMedia(ctx context.Context, ownerID *commonpb.UserId, chatID *commonpb.ChatId, blobID *blobpb.BlobId) error
}
Media is the slice of the blob domain the Chat service needs: hydrating group pictures on read, and attaching a picture to a group on write. Like the readers it is declared here (consumer side) so the service can be tested against a canned implementation; blob.Integration satisfies it directly.
type MembersPage ¶ added in v1.32.0
MembersPage is one page of a group's joined members in user-ID order (see Store.GetGroupMembersPage): the users, and the cursor to resume after, nil once the walk is complete. Its first and last user bound the key range a caller hands Store.GetMutedUsersPage for the same page.
type MessageRef ¶
type MessageRef struct {
ChatID *commonpb.ChatId
MessageID *messagingpb.MessageId
}
MessageRef identifies a chat's message to hydrate. The feed builds one ref per chat (its last message) to batch the lookup across the page.
type MessagingReader ¶
type MessagingReader interface {
// LastMessages returns the message for each ref that exists, keyed by
// string(chatID.Value). Refs without a message are absent from the map.
LastMessages(ctx context.Context, refs []MessageRef) (map[string]*messagingpb.Message, error)
// Pointers returns the delivered/read pointers for the members named in each
// ref, keyed by string(chatID.Value). Chats with no matching pointers are
// absent from the map.
Pointers(ctx context.Context, refs []PointerRef) (map[string][]*messagingpb.Pointer, error)
// LatestEventSequences returns the head event sequence of each given chat,
// keyed by string(chatID.Value). Chats at head 0 (no messages) are absent from
// the map, so a missing key means 0.
LatestEventSequences(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]uint64, error)
}
MessagingReader is the read slice of the messaging domain the Chat service needs to hydrate feed metadata. It is declared here (consumer side) so the chat package need not import messaging, keeping the messaging→chat dependency one-way; the messaging package supplies the concrete adapter.
type MinimumBalance ¶ added in v1.30.0
MinimumBalance is a balance a user must hold to satisfy a chat rule: at least NativeAmount of Currency (an ISO 4217 alpha-3 code, lowercase) worth of tokens, valued at the time the rule is evaluated. Mints restricts which mints the balance may be held in; empty means any mint counts. The proto it projects onto allows at most one mint today, and the model mirrors the proto's list so that lifting the cap is not a storage change.
It is stored as given: what the amount and mints mean is the proto's contract (see chatpb.MinimumBalanceRequirement), and validating a requirement is the job of the boundary that accepts one, not the record.
func (*MinimumBalance) Clone ¶ added in v1.30.0
func (m *MinimumBalance) Clone() *MinimumBalance
Clone returns a deep copy of the requirement.
func (*MinimumBalance) ToProto ¶ added in v1.30.0
func (m *MinimumBalance) ToProto() *chatpb.MinimumBalanceRequirement
ToProto projects the requirement onto a chatpb.MinimumBalanceRequirement.
type ModerationPermissions ¶ added in v1.36.0
ModerationPermissions is how a user may moderate a chat's users (see moderation.go): whether they may silence and unsilence them, and whether they may ban and unban them. Holding either lets them list who is restricted. Each gates its own RPCs, so the two can be granted apart; today both go to the same people (see Chat.PermissionsFor).
func (ModerationPermissions) Any ¶ added in v1.36.0
func (m ModerationPermissions) Any() bool
Any reports whether the user may moderate the chat at all, which is what listing its restricted users requires.
type Mute ¶ added in v1.32.0
Mute is a mute a viewer set on a chat: until an instant, or until they lift it themselves (Forever). While a mute is active the viewer still receives the chat's pushes, flagged so the client suppresses the notification (see push.v1.ChatMetadata.muted); it never affects message delivery.
Until is meaningful only when Forever is false, and is recorded at second precision: a store truncates it on write and returns the truncated value. It must lie in [Unix epoch, MaxMuteUntil), else the write is rejected with ErrMuteUntilOutOfRange. A timed mute past its Until has lapsed: it is still recorded, and returned as stored, until replaced or cleared — nothing sweeps it, and nothing is published when it lapses — so a reader decides with Active, never from presence alone.
func MuteFromProto ¶ added in v1.32.0
MuteFromProto is the inverse of Mute.ToProto. A MuteState carrying neither duration — which validation rejects before a request reaches here — reads as a timed mute at the zero time, which no store records (see Mute).
func (Mute) Normalize ¶ added in v1.32.0
Normalize returns the mute as a store records it — Until truncated to the second, in UTC, and zeroed when Forever — or ErrMuteUntilOutOfRange when it cannot be recorded. Every store applies it on write, so equality of two normalized mutes is what "the mute already recorded" means.
type Permissions ¶ added in v1.32.0
type Permissions struct {
CanEdit bool
Moderation ModerationPermissions
}
Permissions is what a user may do in a chat beyond what membership alone allows every member: whether they may edit its record (see Server.EditChat), and how they may moderate it (see ModerationPermissions). It is computed from the chat's record and the user's membership (see Chat.PermissionsFor), never stored, and shown to the user alone on their ViewerState — a client shows an affordance if and only if its flag is set, so the flags are the server's word on what an RPC will admit, and every gate must agree with them.
func (Permissions) ToProto ¶ added in v1.32.0
func (p Permissions) ToProto() *chatpb.ViewerState_Permissions
ToProto projects the permissions onto a chatpb.ViewerState_Permissions, leaving moderation unset when it grants nothing, as the proto describes.
type PointerRef ¶ added in v1.14.0
PointerRef names a chat and the members whose pointers to hydrate. The feed builds one ref per chat — a DM's members, or the viewer alone in a group (see hydrate) — to batch the pointer lookup across the page.
type ProfileReader ¶ added in v1.16.0
type ProfileReader interface {
// GetPhoneNumbers returns the linked phone number for each of the given
// users that has one, keyed by string(userID.Value). Users without a linked
// phone number are absent from the map.
GetPhoneNumbers(ctx context.Context, userIDs []*commonpb.UserId) (map[string]*commonpb.PhoneNumber, error)
// GetLimitedPublicProfilesForRow returns the row profile of each of the given
// users the profile domain knows, keyed by string(userID.Value). Unknown
// users are absent from the map. Every field a member row shows — display
// name, username, profile picture, join timestamp, Flipcard customization —
// comes back in this one call, and nothing a row does not show.
//
// A member who has set neither a name nor a picture still gets an entry,
// carrying just the join timestamp. Every chat member is a user the profile
// domain knows, so hydration treats a missing entry as an error rather than
// standing in a profile of its own.
//
// Profile pictures come back with their blob metadata already resolved —
// including a short-lived download URL — so a client can render member avatars
// without a follow-up call. The URL expires; to re-mint one the client calls
// GetBlobs with an AccessContext naming that user's profile, which authorizes
// it because a profile picture is public.
//
// There is one proto per user, so a caller that fills in per-member fields
// must copy before mutating: the same user can be a member of several chats.
GetLimitedPublicProfilesForRow(ctx context.Context, userIDs []*commonpb.UserId) (map[string]*profilepb.UserProfile, error)
// GetUserIDByUsername returns the user currently holding the given
// handle, and ok false when nobody holds it.
GetUserIDByUsername(ctx context.Context, username string) (userID *commonpb.UserId, ok bool, err error)
}
ProfileReader is the read slice of the profile domain the Chat service needs to hydrate member profiles and to find a user by handle. Like MessagingReader it is declared here (consumer side) so the chat package need not import profile; the profile package supplies the concrete adapter.
type Reading ¶ added in v1.31.0
type Reading uint8
Reading is what a read of a chat is answered with: nothing, the messages in full, or the messages redacted (see redact.Message). It is the viewer's standing combined with the client's ViewMode (see ListenerStanding.Reading).
type RecentSender ¶ added in v1.34.0
RecentSender is one user's activity record in a group, as the reads of a group's senders return it (see Store.GetRecentSenders and Store.GetActiveSenders): who, when their latest recorded send was, and the record's activity score (see EffectiveActivityScore), both at millisecond precision.
type RestrictedUser ¶ added in v1.36.0
type RestrictedUser struct {
UserID *commonpb.UserId
State ViewerState
}
RestrictedUser is one user's restrictions in a group as the creator's listing carries them (see Store.GetRestrictedUsersPage): who, and their whole ViewerState in the group, whose RestrictedAt is always set.
func (RestrictedUser) Position ¶ added in v1.36.0
func (r RestrictedUser) Position() RestrictionPosition
Position is the user's place in the listing's order.
type RestrictionPosition ¶ added in v1.36.0
RestrictionPosition is a place in a group's listing of restricted users: most recently restricted first, ties broken by user ID descending, so the order is total and a page can resume strictly after the last user it carried. Ties are nanosecond coincidences within one group, so the tie-break exists for totality rather than for anything a client sees.
func (RestrictionPosition) Compare ¶ added in v1.36.0
func (p RestrictionPosition) Compare(o RestrictionPosition) int
Compare orders positions in listing order: negative when p comes before o (restricted later, or the same instant with the greater user ID), positive when after, zero when they name the same place.
type RosterPosition ¶ added in v1.32.0
RosterPosition is a place in a group's roster order: most recently joined first, ties broken by user ID descending, so the order is total and a page can resume strictly after the last member it carried. Store.GetGroupRosterPage walks in this order; a caller that reads a roster whole sorts it with SortRoster. Ties in join time are nanosecond coincidences within one group, so the tie-break exists for totality rather than for anything a client sees.
A DM's participants have no join time (see Member.joined_at): a DM's position is the zero time and the user ID alone, so a DM's roster is in user-ID order under the same rule.
func (RosterPosition) Compare ¶ added in v1.32.0
func (p RosterPosition) Compare(o RosterPosition) int
Compare orders positions in roster order: negative when p comes before o (joined later, or the same instant with the greater user ID), positive when after, zero when they name the same member.
type RosterSummary ¶ added in v1.30.0
RosterSummary summarizes a chat's roster — its member list — without enumerating it: how many members there are, and a version that moves whenever the membership records do.
A DM's roster is fixed at creation, so its summary is the inline member count at version zero, forever. A group's is maintained by the store: every membership transition — a join, a departure, and in future any change to what a membership record holds about its member — moves Version by exactly one, and MemberCount by the transition's effect on the joined set. Idempotent no-ops (re-adding a joined member, removing a departed one) move neither.
Version is state, not a sequence of deltas: a client compares it against the value it last saw and refetches members when they differ, and on a stream applies the greater value and drops the rest, so delivery order does not matter. It says nothing about member profiles, which live in their own domain and are hydrated afresh onto every response that carries them.
func (RosterSummary) ToProto ¶ added in v1.30.0
func (r RosterSummary) ToProto() *chatpb.RosterSummary
ToProto projects the summary onto a chatpb.RosterSummary.
type RuleEvaluator ¶ added in v1.30.0
type RuleEvaluator struct {
// contains filtered or unexported fields
}
RuleEvaluator decides whether a user satisfies a chat's participation rules (see Chat.Rules). It evaluates rules only: membership is a separate, cheaper check the caller makes first, so a chat's rules are evaluated on behalf of a non-member only where the rules are what admits them — a join, or a qualifying non-member's read of a group (see Access). Only a group stores rules; a DM's evaluation never touches the store.
A DM with the Flipcash team account carries one rule no record stores: a Never speaker rule, since nobody sends in one (see teamDmRules). The team writes through the messaging Sender, which no rule gates, and nobody reads as it, so a message to it would be read by no one. The rule is decided per read off the team account the evaluator is built with, like use_e2ee, so that the rule a client is shown (RulesOf, through Metadata.rules) and the rule a send is refused by (CanSpeak) are one decision.
A private group (see Chat.IsPrivate) is not governed by rules, so it has no RuleSet and is never evaluated: the methods that take one cannot be given one for it, and those that read a chat's rules themselves return ErrNotGovernedByRules for it. Who reads and speaks in a private group is decided on membership and its key, by Access.
Rules are read through Store.GetGroupRules — in production the caching store, which holds every group's rules after its first read. A StaffRequirement is answered by the account store's staff flag, a MinimumBalanceRequirement by the balance client's valuation of the user's holdings (see satisfiesMinimumBalance), and a CreatorRequirement by the creator read and cached with the rules (see ChatRules), so it costs no read of its own.
Rules are evaluated against the current state of their subject, not the state at join time: a member who no longer satisfies a listener rule (a staff member whose flag was revoked, a holder whose balance dropped) keeps their membership record but is denied on every path that evaluates the rules until they satisfy it again. Not every path does: a member's read is gated on membership alone, so that it never pays for an evaluation; rules are evaluated on sends, and on a non-member's read of a group, each verdict remembered for a short window when it admits (see Access). The intended design is for membership itself to track the listener rules — a member who stops satisfying one is removed — at which point the membership record is the rules' answer everywhere. Enforcing rules on membership changes, a join gated by listener rules included, is the job of whatever path mutates membership.
func NewRuleEvaluator ¶ added in v1.30.0
func NewRuleEvaluator(accounts account.Store, balances *balance.Client, chats Store, teamUserID *commonpb.UserId) *RuleEvaluator
NewRuleEvaluator returns a RuleEvaluator. teamUserID is the Flipcash team account (see flipcashteam), nil when there is none; it is an argument rather than an option so that a parent cannot build an evaluator without deciding it, since an evaluator that does not know the team lets users message it.
func (*RuleEvaluator) CanListen ¶ added in v1.30.0
func (e *RuleEvaluator) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
CanListen reports whether userID satisfies every listener rule of chatID — the requirements to read (and join) the chat. A chat with no listener rules admits everyone. It returns ErrChatNotFound if a group chat does not exist, and ErrNotGovernedByRules for a private group.
func (*RuleEvaluator) CanListenWithRules ¶ added in v1.30.0
func (e *RuleEvaluator) CanListenWithRules(ctx context.Context, rules RuleSet, userID *commonpb.UserId) (bool, error)
CanListenWithRules is CanListen for a caller that already holds the chat's rule set — read off a canonical record it loaded for its own purposes, or taken from a request for a chat that does not exist yet — so the rules are not read a second time. A set with no listener rules admits everyone.
func (*RuleEvaluator) CanSpeak ¶ added in v1.30.0
func (e *RuleEvaluator) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
CanSpeak reports whether userID satisfies every listener and speaker rule of chatID — the requirements to send messages in the chat. Speaker rules apply on top of listener rules: a user who cannot listen cannot speak, whatever the speaker rules say. It returns ErrChatNotFound if a group chat does not exist, and ErrNotGovernedByRules for a private group.
func (*RuleEvaluator) CanSpeakWithRules ¶ added in v1.30.0
func (e *RuleEvaluator) CanSpeakWithRules(ctx context.Context, rules RuleSet, userID *commonpb.UserId) (bool, error)
CanSpeakWithRules is CanSpeak for a caller that already holds the chat's rule set (see CanListenWithRules). A set with no rules admits everyone.
A listener minimum balance that a speaker minimum balance covers (see coversBalance) is not evaluated: whoever meets the speaker's meets it, and whoever does not is refused by the speaker's anyway, so a speak check values the user's balance once rather than twice. The covering speaker rule is evaluated after any cheaper speaker rule, so a non-creator in a creator-only group is refused before any valuation. A listener balance that is not covered is evaluated as a listen check would.
func (*RuleEvaluator) RulesOf ¶ added in v1.33.0
func (e *RuleEvaluator) RulesOf(c *Chat) *chatpb.Rules
RulesOf returns c's rules as the evaluator evaluates them, nil when it has none: a group's stored rules (see Chat.Rules), and for a DM, the Never speaker rule when the team account is one of its members (see teamDmRules). It is what a client is shown as Metadata.rules.
type RuleSet ¶ added in v1.35.0
type RuleSet struct {
// contains filtered or unexported fields
}
RuleSet is the rules of a chat that is governed by its rules, which is what the RuleEvaluator evaluates. A public group is governed by its stored rules, and a DM by the rules derived for it (see RuleEvaluator.RulesOf). A private group is not governed by rules at all: its creator admits its members and its key decides whether they speak (see Access), so ChatRules.RuleSet returns none for one, and nothing that holds a private group's ChatRules can ask the evaluator about it. Without that, a private group, which carries no rules, would read as an empty rule set, and an empty rule set admits everyone.
A RuleSet carries the creator a CreatorRequirement is answered by, since the rule itself names no one.
func NewRuleSet ¶ added in v1.35.0
NewRuleSet returns the rule set of rules no record holds yet: those a public group is being created with, with the caller as the creator they will record. Every other RuleSet comes from ChatRules.RuleSet.
type Server ¶
type Server struct {
chatpb.UnimplementedChatServer
// contains filtered or unexported fields
}
func NewServer ¶
func NewServer( log *zap.Logger, authz auth.Authorizer, accounts account.Store, blocklist BlocklistReader, chats Store, media Media, messaging MessagingReader, moderator moderation.Client, profiles ProfileReader, systemMessages SystemMessenger, pusher push.Pusher, access *Access, hydrator *Hydrator, userEventBus UserEventPublisher, chatEventBus ChatEventPublisher, disablePublicGroupRoster bool, opts ...ServerOption, ) *Server
func (*Server) AdmitLobbyMember ¶ added in v1.34.0
func (s *Server) AdmitLobbyMember(ctx context.Context, req *chatpb.AdmitLobbyMemberRequest) (*chatpb.AdmitLobbyMemberResponse, error)
func (*Server) BanUser ¶ added in v1.36.0
func (s *Server) BanUser(ctx context.Context, req *chatpb.BanUserRequest) (*chatpb.BanUserResponse, error)
BanUser bans a user from a group (see above): one store write records the ban, removes them from the roster if they are a member — taking their key envelope in a private group, as their own departure would (see Server.LeaveChat; the creator, whose envelope is kept, cannot be banned) — and removes their lobby entry if they are waiting, and the store reports which it did, so each is announced exactly once, by the call that did it.
The banned user's mute is cleared on every OK ban, whether or not this call removed them, as LeaveChat clears one on every OK leave: a repeated ban is the retry that repairs a clear the first one failed, and a user banned while not a member has no mute in the common case, a no-op that costs one read.
func (*Server) DenyLobbyMember ¶ added in v1.34.0
func (s *Server) DenyLobbyMember(ctx context.Context, req *chatpb.DenyLobbyMemberRequest) (*chatpb.DenyLobbyMemberResponse, error)
func (*Server) EditChat ¶ added in v1.32.0
func (s *Server) EditChat(ctx context.Context, req *chatpb.EditChatRequest) (*chatpb.EditChatResponse, error)
EditChat changes any of a group's title, description, profile picture and cover picture, atomically.
Only a group can be edited, and only by its creator while they are a member (see Chat.PermissionsFor): a DM has no editable record and no creator, a group written before creators were recorded has no one who may edit it, and a creator who left their group edits nothing until they rejoin. Anyone else is DENIED — the same answer ViewerState.Permissions.can_edit gives them, so a client never shows an affordance this refuses. Creatorship is decided off the record before membership is read, so the store is asked nothing on behalf of a caller who could never edit.
A field set to the value the record already holds is dropped before anything is checked, so a retried request, or a client that sends every field, costs no moderation and no attach — and a request left with nothing to change is answered OK from the record as it stands, with nothing written and nothing published. What remains is checked in StartChat's order, for its reasons: the title through moderation (TITLE_MODERATED), then the description (DESCRIPTION_MODERATED; an empty one, which clears it, has nothing to judge), then the profile picture attached (PROFILE_PICTURE_BLOB_NOT_ACCEPTED), then the cover picture (COVER_PICTURE_BLOB_NOT_ACCEPTED), and only once every part has passed is the record written, as one update naming exactly those fields. The atomicity the proto promises is that: a refusal of any part leaves the record as it was, though a picture attached ahead of a write that then fails or a later part's refusal stays granted — harmless, as in StartChat. A description a group may not carry at all (see ValidateDescription) is refused as malformed, with InvalidArgument, before anything is read.
A real change is announced once, on the chat topic, to every member's devices including the editor's, as one MetadataUpdate per field changed; a picture's is the resolved rendition set the response carries. The response is the record after the edit as the editor sees it. A group whose record vanished between the read and the write is NOT_FOUND.
func (*Server) EnterLobby ¶ added in v1.34.0
func (s *Server) EnterLobby(ctx context.Context, req *chatpb.EnterLobbyRequest) (*chatpb.EnterLobbyResponse, error)
func (*Server) GetChat ¶
func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chatpb.GetChatResponse, error)
GetChat returns one chat's metadata as the caller may see it.
A DM is its two members' alone: anyone else is DENIED. A group's record is returned to every registered user, member or not — its title, picture, rules and roster summary are what a user weighs before joining, and what a client renders for a group it was pointed at (see Access for the rules). What the caller's standing decides, combined with the view mode they asked for (see ListenerStanding.Reading), is how much of the group comes with it:
- A member sees everything, as before: the record, themselves as the hydrated member with their pointers, and the group's messaging state — its last message and head event sequence.
- A non-member who satisfies the group's listener rules sees the record and its messaging state, so a group they may read previews like one they are in. They are not on the roster, so no member is hydrated: an empty Members is how the metadata says the viewer is not a member.
- A non-member who does not satisfy the rules sees the record alone under FULL, the mode a client that does not know redaction asks for. Under FULL_OR_REDACTED they see the messaging state redacted: the last message as a placeholder (see redact.Message) and the head event sequence, so a client can show that the group is alive and how much, without what was said. The rules are carried in every case so the client can show what would admit them.
- Under REDACTED anyone who may read the group at all — a member too — sees its last message redacted, and the rules are not evaluated for a non-member (see Access.ListenerStanding).
A private group (see Chat.IsPrivate) falls out of the same rules. Its record is returned to any registered user, with is_private set, so that one who is not a member can see what they would ask to join. It carries no rules, so a non-member's standing is none under every mode and they see the record alone: its messaging state is its members'. What a non-member does get is in_lobby, whether they are waiting in its lobby (see lobby.go). Its public view is that same record, for a caller with no auth at all (see getPublicChat). A private group whose key has not been stored is returned like any other.
The group's pictures and description are returned in every case: they are part of the record, as the title is, and are what identify a group — a group's pictures are readable by anyone. Its download URLs are resolved here without a blob ACL check on that basis. The blob domain itself still resolves a chat-scoped grant against membership, so a non-member's GetBlobs on the same picture, or on media in a message they previewed, is denied; that is accepted, since a non-member's read access is short-lived by nature and the URLs hydrated here and on the messages are what a previewing client renders from.
Auth is optional. A request without it asks for the chat's public view (see getPublicChat).
func (*Server) GetDmChatFeed ¶
func (s *Server) GetDmChatFeed(ctx context.Context, req *chatpb.GetDmChatFeedRequest) (*chatpb.GetDmChatFeedResponse, error)
func (*Server) GetFeaturedGroups ¶ added in v1.34.0
func (s *Server) GetFeaturedGroups(ctx context.Context, req *chatpb.GetFeaturedGroupsRequest) (*chatpb.GetFeaturedGroupsResponse, error)
GetFeaturedGroups returns the featured groups of the user holding the requested handle. The list is the same for every caller: auth, when set, is verified as everywhere else, but changes nothing.
func (*Server) GetGroupChatFeed ¶ added in v1.30.0
func (s *Server) GetGroupChatFeed(ctx context.Context, req *chatpb.GetGroupChatFeedRequest) (*chatpb.GetGroupChatFeedResponse, error)
func (*Server) GetKeyEnvelope ¶ added in v1.34.0
func (s *Server) GetKeyEnvelope(ctx context.Context, req *chatpb.GetKeyEnvelopeRequest) (*chatpb.GetKeyEnvelopeResponse, error)
func (*Server) GetLobbyMembers ¶ added in v1.34.0
func (s *Server) GetLobbyMembers(ctx context.Context, req *chatpb.GetLobbyMembersRequest) (*chatpb.GetLobbyMembersResponse, error)
func (*Server) GetMentionSuggestions ¶ added in v1.34.0
func (s *Server) GetMentionSuggestions(ctx context.Context, req *chatpb.GetMentionSuggestionsRequest) (*chatpb.GetMentionSuggestionsResponse, error)
func (*Server) GetRestrictedUsers ¶ added in v1.36.0
func (s *Server) GetRestrictedUsers(ctx context.Context, req *chatpb.GetRestrictedUsersRequest) (*chatpb.GetRestrictedUsersResponse, error)
GetRestrictedUsers pages the users the group's creator has restricted, most recently restricted first (see RestrictionPosition), each with their public profile, their silence while it is in force, and when they were banned. A user whose only restriction is a lapsed silence is left out, as the proto promises: the store lists it (see Store.GetRestrictedUsersPage), so the page reads on past such users until it fills, at most maxRestrictedUsersReads reads, each one more than the page still needs for an exact has_more, as GetLobbyMembers reads. A page that runs out of reads first ends with has_more set and a token after the last user it read, shown or not, so the next page never reads them again; it may then carry fewer users than asked for, none at all in the extreme, which the proto's paging contract allows. Which silences have lapsed is judged once, at the page's start. The token is bound to the chat, as every paging token here is, and refused elsewhere.
func (*Server) GetRoster ¶ added in v1.32.0
func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*chatpb.GetRosterResponse, error)
func (*Server) JoinChat ¶ added in v1.30.0
func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*chatpb.JoinChatResponse, error)
func (*Server) LeaveChat ¶ added in v1.30.0
func (s *Server) LeaveChat(ctx context.Context, req *chatpb.LeaveChatRequest) (*chatpb.LeaveChatResponse, error)
func (*Server) LeaveLobby ¶ added in v1.34.0
func (s *Server) LeaveLobby(ctx context.Context, req *chatpb.LeaveLobbyRequest) (*chatpb.LeaveLobbyResponse, error)
func (*Server) MuteChat ¶ added in v1.32.0
func (s *Server) MuteChat(ctx context.Context, req *chatpb.MuteChatRequest) (*chatpb.MuteChatResponse, error)
MuteChat records the caller's mute on a chat, until a time or indefinitely.
Only a member may mute: the gate is membership alone, never the rules, so a member a rule has since excluded can still quiet the chat they are in. A DM member is always a member, so DMs are muted like groups. A timed mute must end in the future as it will be recorded (see Mute.Normalize), which is checked here rather than by request validation because MuteState also appears in responses, where a mute that lapsed a moment before the read must not fail a client's validation.
The write is the store's own no-op-aware update: the mute already recorded is answered with the current state and publishes nothing, and any real change — a first mute, a different end, indefinite where timed was — reaches the caller's other devices as a MetadataUpdate.ViewerStateChanged on their user topic (see publishViewerStateChanged). The response carries the same state, so the calling device applies it at its version without waiting for the stream. A mute lasts as long as the membership it was set under: leaving the chat clears it, best effort (see clearMuteOnLeave), and a departed member cannot set or clear one until they rejoin.
func (*Server) SampleChatters ¶ added in v1.34.0
func (s *Server) SampleChatters(ctx context.Context, req *chatpb.SampleChattersRequest) (*chatpb.SampleChattersResponse, error)
func (*Server) SetFeaturedGroups ¶ added in v1.34.0
func (s *Server) SetFeaturedGroups(ctx context.Context, req *chatpb.SetFeaturedGroupsRequest) (*chatpb.SetFeaturedGroupsResponse, error)
SetFeaturedGroups replaces the caller's featured groups. The groups' records are read first: a group that does not exist is NOT_FOUND, then a private one DENIED, each with nothing written. The read is eventually consistent (see Store.GetGroupChatsByID), so a group created a moment ago may be NOT_FOUND until the read catches up, which a retry corrects; a privacy check cannot be fooled by it, since IsPrivate is written with the record and never changes. The response is the list as written, projected from the records the checks read, so it costs no read beyond them.
func (*Server) SetKeyEnvelope ¶ added in v1.34.0
func (s *Server) SetKeyEnvelope(ctx context.Context, req *chatpb.SetKeyEnvelopeRequest) (*chatpb.SetKeyEnvelopeResponse, error)
func (*Server) SilenceUser ¶ added in v1.36.0
func (s *Server) SilenceUser(ctx context.Context, req *chatpb.SilenceUserRequest) (*chatpb.SilenceUserResponse, error)
SilenceUser records a silence on a user in a group (see above), until a time or indefinitely, replacing any silence already set. A timed silence must end in the future as it will be recorded (see Silence.Normalize), as MuteChat requires of a mute, and is refused as malformed, with InvalidArgument, before anything is read.
func (*Server) StartChat ¶ added in v1.30.0
func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (*chatpb.StartChatResponse, error)
StartChat creates a group chat with the caller as its first and only member.
The request is checked in the order a client can act on: the rules it asks for must be ones a group can carry — which today means they must include a minimum listener balance, in a currency OCP can value, and may add a larger minimum speaker balance in the same currency and mints (see RulesFromProto) — and ones the caller satisfies, speaker rules included (RULES_NOT_SATISFIED); the title must pass moderation (TITLE_MODERATED), and then the description, if any (DESCRIPTION_MODERATED); and the profile picture and cover picture, if any, must each be a READY image the caller owns (PROFILE_PICTURE_BLOB_NOT_ACCEPTED, COVER_PICTURE_BLOB_NOT_ACCEPTED). Only then is anything written. Rule checks come first because they are local reads, moderation next because it is a call out, and the pictures last because attaching one grants read access — against a chat ID minted for the purpose — and that grant, though harmless against a chat that is never created, is best made only once everything else has passed.
A description a group may not carry at all (see ValidateDescription) is refused as malformed, with InvalidArgument, before any of that.
The pictures are attached before the record is written, so the group never exists without its pictures readable: a client that read a blob id from the metadata but could not fetch the blob would render a broken image. The creation itself is a single PutChat; it does not by itself make the creator anything other than a member (see Chat.CreatorID).
Like JoinChat, a creation is a join, and is announced to the creator's other devices as one — a MemberJoined on their user topic carrying the metadata — so they insert the new chat without a refetch. There is no one else to tell.
A private group (see Chat.IsPrivate) is created the same way, except that it carries no rules, so there are none to validate or satisfy. What is created is a group without a key: the creator's client stores the chat key's envelope as a second step (see SetKeyEnvelope), since the envelope is bound to the chat ID this RPC returns, and until it has, nothing happens in the group (see Chat.IsPrivate).
The RPC is retry-safe. The group's ID is derived from the caller and the request's idempotency key (see MustDeriveGroupChatID), so a retry names the same group, and one that already exists is answered from its record before any check runs: a title the moderator has since learned to flag, a balance that has since fallen below the minimum, or a staff flag since revoked, are facts about a new group, not this one. Which parameters the retry carries does not matter either, the kind of group included; the key is the request's identity. A retry that loses a race with its twin — both pass the read, one write lands — is caught by the store's uniqueness condition and answered the same way. Nothing is published for a retry: the creation was announced when it happened.
func (*Server) UnbanUser ¶ added in v1.36.0
func (s *Server) UnbanUser(ctx context.Context, req *chatpb.UnbanUserRequest) (*chatpb.UnbanUserResponse, error)
UnbanUser lifts a user's ban from a group. It is gated as BanUser is, returns no one to the group or its lobby, and publishes nothing; a user who is not banned, the creator among them, is the no-op.
func (*Server) UnmuteChat ¶ added in v1.32.0
func (s *Server) UnmuteChat(ctx context.Context, req *chatpb.UnmuteChatRequest) (*chatpb.UnmuteChatResponse, error)
UnmuteChat clears the caller's mute on a chat, lapsed or not. It is gated and published exactly as MuteChat is; clearing a chat that is not muted is the no-op that returns the current state and publishes nothing.
func (*Server) UnsilenceUser ¶ added in v1.36.0
func (s *Server) UnsilenceUser(ctx context.Context, req *chatpb.UnsilenceUserRequest) (*chatpb.UnsilenceUserResponse, error)
UnsilenceUser clears a user's silence in a group, lapsed or not. It is gated and published as SilenceUser is; a user who is not silenced, the creator among them, is the no-op that publishes nothing.
type ServerOption ¶ added in v1.34.0
type ServerOption func(*Server)
ServerOption configures a Server at construction beyond its required dependencies.
func WithLobbyLimits ¶ added in v1.34.0
func WithLobbyLimits(limits LobbyLimits) ServerOption
WithLobbyLimits overrides DefaultLobbyLimits (see LobbyLimits).
type Silence ¶ added in v1.36.0
Silence is a silence a group's creator set on a user: until an instant, or until the creator lifts it (Forever). While a silence is active the user may still read the group, react and vote, but not speak in it (send, edit, show typing); they may still delete their own messages. It is the creator's restriction on the user, not the user's own setting, and it knows nothing of membership: it may be set on a user who has never joined, and it survives their leaving and rejoining.
It has exactly a Mute's shape and storage, and every rule of one holds of it, which its methods delegate to: Until is recorded at second precision and must lie in [Unix epoch, MaxMuteUntil), else the write is rejected with ErrSilenceUntilOutOfRange; a store encodes an indefinite silence as MaxMuteUntil, as it does a mute; and a timed silence past its Until has lapsed but stays recorded, as stored, until replaced or cleared — nothing sweeps it and nothing is published when it lapses — so a reader decides with Active, never from presence alone.
func SilenceFromProto ¶ added in v1.36.0
func SilenceFromProto(s *chatpb.SilenceState) Silence
SilenceFromProto is the inverse of Silence.ToProto. A SilenceState carrying neither duration — which validation rejects before a request reaches here — reads as a timed silence at the zero time, which no store records.
func (Silence) Normalize ¶ added in v1.36.0
Normalize returns the silence as a store records it, by Mute.Normalize's rules, or ErrSilenceUntilOutOfRange when it cannot be recorded. Every store applies it on write, so equality of two normalized silences is what "the silence already recorded" means.
func (Silence) ToProto ¶ added in v1.36.0
func (s Silence) ToProto() *chatpb.SilenceState
ToProto projects the silence onto a chatpb.SilenceState.
type SpeakerStanding ¶ added in v1.34.0
type SpeakerStanding struct {
CanSpeak bool
// Silenced reports that the user was refused only because the group's
// creator has silenced them (see Silence): a member whom the chat's
// governance would otherwise let speak. It is set only with CanSpeak
// false, and is what lets a deletion be carved out of the silence (see
// Access.CanDelete) without a second gate.
Silenced bool
// Encryption is how the chat takes encrypted content (see Encryption).
Encryption Encryption
// Scheme is the scheme encrypted content in the chat must carry, the one
// thing in it the server reads. Unset when Encryption is EncryptionNone.
Scheme messagingpb.EncryptedContent_Scheme
}
SpeakerStanding is a user's standing to speak in a chat, the way ListenerStanding is their standing to read it: whether they may send in it at all (see CanSpeak), and if so what the chat takes from them as far as encryption goes, which is the chat's and the same for every speaker in it. Who may speak and what a chat takes are decided together so that nothing outside this package has to know what makes a chat take encrypted content — a DM's pairwise key, a private group's stored chat key — only that it does. A refused speaker is the zero value: CanSpeak false and nothing about the chat — except for Silenced.
type Store ¶
type Store interface {
// PutChat persists a new chat and its membership. It returns ErrChatExists
// if a chat with the same ID already exists, ErrNoMembers if the member set
// is empty, and an error when the chat ID's length does not match the chat's
// type family.
//
// For a group chat (16-byte ID, type GROUP), Members become group membership
// records, equivalent to AddGroupMembers, and are written atomically with the
// canonical record: creation either fully succeeds or leaves nothing behind.
// Duplicate members collapse. A set larger than MaxGroupChatCreationMembers
// is rejected with ErrTooManyMembers rather than written non-atomically; a
// caller wanting a larger group creates it at the cap and grows it with
// AddGroupMembers. RosterSummary is derived from Members and ignored.
// A group gated on a currency (see GatedMint) is enqueued for a holdings
// refresh due at once (see HoldingsRefresh), in the same write.
//
// A store built with users excluded from the feed (see FeedExclusions)
// creates every DM with one of them excluding them from it: their copy of the chat records their membership
// (so IsMember answers for them as for anyone) and nothing else, with no
// activity to order a feed by, which AdvanceLastMessage never moves and
// GetDmFeedPage never returns. The exclusion is decided at creation and
// kept with the chat, internal to the store and not on the Chat it
// returns: a DM keeps it however the store is later configured, which is
// what makes an advance of it safe from any process.
PutChat(ctx context.Context, chat *Chat) error
// AddGroupMembers adds users as joined members of a group chat. It is
// idempotent: adding an already-joined member is a no-op that preserves
// their original join time, and re-adding a departed member rejoins them
// fresh. Each member that actually joins is one membership transition,
// moving the group's RosterSummary atomically with their record. It
// reports whether any member actually joined, and the summary as of the
// last write — which may already reflect a concurrent transition by
// another writer, and so is what a caller should publish as current. It
// returns ErrChatNotFound if the chat does not exist, and an error if chatID
// is not a group chat ID.
//
// A banned user (see BanUser) is refused with ErrBanned, judged in the
// write that would join them, after whether they are joined already: a
// banned user never is, so a member is the no-op whatever their record
// says. Each user is their own write, so the users before a banned one in
// userIDs are joined and the rest are not tried; the server joins one user
// at a time (see Server.JoinChat), and a creation's members, written by
// PutChat, are not checked, since a group that does not exist yet has
// banned no one.
AddGroupMembers(ctx context.Context, chatID *commonpb.ChatId, userIDs []*commonpb.UserId) (changed bool, roster RosterSummary, err error)
// RemoveGroupMember ends a user's membership in a group chat. Departure is
// a tombstone, not a deletion: the user stops being a member (IsMember
// false, excluded from GetMembers) but the record of their former
// membership is kept, and they can be re-added later. Removing a non-member
// or unknown user is a no-op. A departure that actually happens is one
// membership transition, moving the group's RosterSummary atomically with
// the record; changed and roster are as for AddGroupMembers. It returns
// ErrChatNotFound if the chat does not exist, and an error if chatID is not
// a group chat ID.
//
// With discardKeyEnvelope, a departure that actually happens also removes
// the user's key envelope for the chat (see KeyEnvelope), atomically with
// it: the user stops being a member and stops holding an envelope
// together, or neither. A no-op removes nothing, the envelope included.
// Whether a departure takes the envelope is the caller's to say, since
// the store knows neither that a group is private nor who created it (see
// Server.LeaveChat).
RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, discardKeyEnvelope bool) (changed bool, roster RosterSummary, err error)
// SetGroupPicture sets a group chat's profile picture to the blob holding its
// ORIGINAL rendition, replacing any picture already set; a nil blobID clears
// it. It touches only the canonical record and performs no validation of the
// blob or granting of read access — that is the blob domain's job, done
// before this is called (see blob.Integration.SetAsChatMedia). It returns
// ErrChatNotFound if the chat does not exist, and an error if chatID is not
// a group chat ID.
SetGroupPicture(ctx context.Context, chatID *commonpb.ChatId, blobID *blobpb.BlobId) error
// EditGroup applies edit to a group chat's canonical record (see
// GroupEdit): each field the edit names is set (an empty description
// clears it), and every other field is left as it is, in one write. Like
// SetGroupPicture it touches only the canonical record and validates
// nothing — the title and description are validated and moderated, and the
// pictures attached, before this is called (see Server.EditChat). It returns
// ErrChatNotFound if the chat does not exist, and an error if chatID is
// not a group chat ID or the edit names nothing.
EditGroup(ctx context.Context, chatID *commonpb.ChatId, edit GroupEdit) error
// GetChatByID returns the canonical record for the chat with the given ID,
// or ErrChatNotFound. It reads only that record: Members carries a DM's
// inline participants and is always empty for a group chat, whose mutable
// membership lives in its own records. A caller that needs a group's members
// reads them explicitly via GetMembers, so the cost of enumerating a large
// group is never paid implicitly by a metadata read. Likewise RosterSummary
// is a DM's inline summary and zero for a group, whose summary is its own
// read (GetGroupRosterSummary).
GetChatByID(ctx context.Context, chatID *commonpb.ChatId) (*Chat, error)
// GetDmFeedPage returns one page of userID's DM feed for a single chat type,
// pinned to a snapshot: the DMs of chatType userID is a member of (never
// one that excludes them from the feed, see PutChat) whose last_activity
// is at or before snapshot, ordered by (last_activity, chat_id) descending
// (most recent first), at most limit chats (limit <= 0 means unbounded).
// When cursor is nil the page starts at the most recent chat in the
// snapshot; otherwise it resumes strictly after cursor. An empty result
// (no error) is returned when no chats remain.
//
// Pinning to a fixed watermark makes a multi-page read internally consistent.
// last_activity only ever advances to a wall-clock send time, so any chat that
// becomes active after the snapshot moves strictly above the watermark and
// leaves the window — it can be neither duplicated onto nor skipped within a
// later page. Those freshly-active chats are surfaced through the live
// MetadataUpdate event stream instead (see the Chat service's GetDmChatFeed).
//
// It is scoped to a single DM type because each type is its own feed (see
// GetDmChatFeedRequest.dm_chat_type). Group chats will have a parallel
// accessor, and the server merges the descending streams into one feed.
GetDmFeedPage(ctx context.Context, userID *commonpb.UserId, chatType chatpb.ChatType, snapshot time.Time, cursor *DmFeedCursor, limit int) ([]*Chat, error)
// GetMembers returns the member user IDs of a chat, or ErrChatNotFound. For
// a DM this is the canonical inline member list; for a group chat it is the
// currently joined members.
GetMembers(ctx context.Context, chatID *commonpb.ChatId) ([]*commonpb.UserId, error)
// GetGroupMembersPage is GetMembers as a walk over a group's roster: the
// currently joined members in ascending user-ID (byte) order, strictly
// after the after cursor (nil starts at the beginning), at most limit of
// them (limit <= 0 means unbounded). The order is the one
// GetMutedUsersPage returns a chat's mutes in, so a fan-out can read the
// mutes between a roster page's first and last user and never hold either
// whole; that is what bounds a push to a large group by its page size
// rather than its membership. A page's cursor is the last user it
// carries whenever the limit was reached, whether or not any member
// follows, so the walk may end on an empty, final page.
//
// It reads the membership records alone, never the canonical record: a
// group that does not exist is an empty page, not ErrChatNotFound, and a
// caller that needs the distinction reads GetChatByID first — which the
// walkers do anyway, for the metadata a push carries. Like GetMembers, it
// may lag a transition by a moment. It returns an error if chatID is not a
// group chat ID.
GetGroupMembersPage(ctx context.Context, chatID *commonpb.ChatId, after *commonpb.UserId, limit int) (MembersPage, error)
// GetGroupRoster returns a group's RosterSummary and every joined member
// (see GroupMember), in no particular order, from one strongly consistent
// read: the members are exactly the roster at the summary's version, and
// the summary's count is their number — an implementation whose read is
// not a snapshot verifies that against the rows and re-reads, a bounded
// number of times, when a transition landed mid-read, so the promise holds
// short of a roster churning faster than it can be read. It is the read
// behind a small group's roster page, which promises a client exactly
// that (see Server.GetRoster); it costs the whole roster, so a caller
// sizes the group first (GetGroupRosterSummary) and pages a large one with
// GetGroupRosterPage instead. It returns ErrChatNotFound if the chat does
// not exist, and an error if chatID is not a group chat ID.
GetGroupRoster(ctx context.Context, chatID *commonpb.ChatId) (RosterSummary, []GroupMember, error)
// GetGroupRosterPage is one page of a group's joined members in roster
// order (see RosterPosition): most recently joined first, strictly after
// the after position (nil starts at the newest member), at most limit of
// them (limit <= 0 means unbounded). A position need not name a current
// member: a cursor whose member has since left resumes just as well. The
// read may lag a transition by a moment — it is served from an index of
// the joined members in join order rather than the records themselves, so
// a member who just joined may be missing and one who just left may still
// appear — which is what a large group's page tolerates (see
// Server.GetRoster). It reads the membership records alone, never the
// canonical record, so a group that does not exist is an empty page. It
// returns an error if chatID is not a group chat ID.
GetGroupRosterPage(ctx context.Context, chatID *commonpb.ChatId, after *RosterPosition, limit int) ([]GroupMember, error)
// GetGroupMemberRecords returns userID's joined membership record (see
// GroupMember) on each of chatIDs they are currently a member of, keyed by
// string(chatID.Value); a chat they are not joined to — never, or no
// longer — or that does not exist is absent rather than reported, and
// duplicate IDs collapse. The read is strongly consistent, as IsMember is
// and for the same reason: it is asked about a chat the user just joined
// or created, to announce the record the join wrote (see
// RosterUpdate.MemberJoined). It returns an error if any ID is not a group
// chat ID, and an empty map (no error) when chatIDs is empty.
GetGroupMemberRecords(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]GroupMember, error)
// GetGroupMembersByID returns the joined membership record of each of
// userIDs in the group chatID, keyed by string(userID.Value): the
// transpose of GetGroupMemberRecords, one group and many users. A user
// who is not joined — never, or no longer — is absent rather than
// reported, a group that does not exist reads as no members, and
// duplicate IDs collapse. The read is eventually consistent, at half the
// cost of a strong one: it may trail a join or departure by a moment, so
// a user who has just left can still be returned, which its one reader
// tolerates (see Server.SampleChatters). It is not for a gate. It returns
// an error if chatID is not a group chat ID, and an empty map (no error)
// when userIDs is empty.
//
// TODO: revisit the consistency if a reader needs a strongly consistent
// answer, e.g. by having the caller choose it.
GetGroupMembersByID(ctx context.Context, chatID *commonpb.ChatId, userIDs []*commonpb.UserId) (map[string]GroupMember, error)
// GetGroupRosterSummary returns a group chat's RosterSummary, maintained
// alongside its membership records rather than computed by enumerating
// them — so a group's size and version are known without paying for its
// member list, and stay known once GetMembers returns only a subset.
//
// A caller that hands both the summary and a member list to a client reads
// the summary first: any transition that lands during the enumeration is
// then above the version handed out, so the client sees it as stale and
// refetches. Read the other way round, a client could hold a version newer
// than its list. It returns ErrChatNotFound if the chat does not exist, and
// an error if chatID is not a group chat ID.
GetGroupRosterSummary(ctx context.Context, chatID *commonpb.ChatId) (RosterSummary, error)
// GetGroupRosterSummaries is the cross-chat batch counterpart to
// GetGroupRosterSummary: the summary of each given group chat, keyed by
// string(chatID.Value), read as a batch rather than one read per chat. A
// chat that does not exist is absent from the map rather than reported —
// which callers passing chats they hold never hit. Duplicate IDs collapse.
// It returns an error if any ID is not a group chat ID, and an empty map
// (no error) when chatIDs is empty.
GetGroupRosterSummaries(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]RosterSummary, error)
// GetGroupRules returns a group chat's participation rules, its recorded
// creator and whether it is private (see ChatRules), with a nil Rules
// when it has none. It reads only what they are projected from, never the
// full canonical record — and all are fixed at creation, so an
// implementation is free to cache them indefinitely. It returns ErrChatNotFound if the chat does not exist,
// and an error if chatID is not a group chat ID.
GetGroupRules(ctx context.Context, chatID *commonpb.ChatId) (ChatRules, error)
// IsMember reports whether userID is a member of chatID. It returns false
// (no error) when the chat does not exist, or when a group member has been
// removed. The read is strongly consistent: it reflects every join, leave
// and creation that completed before it. It is the authority behind every
// access gate (see Access), and the first thing a user does after joining
// or creating a chat is act in it, so a read that could lag the write
// would deny exactly that action.
IsMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
// GetGroupMembershipsForUser returns every group chat userID has a
// membership record on — joined or departed — in no particular order, each
// with its state and the version of the user's last transition there (see
// GroupMembership). A user with no records gets an empty result, not an
// error. It is the inverse of GetMembers over the membership records alone
// — no canonical chat metadata is read or returned; a caller that needs it
// follows up with GetChatByID, and one that wants only current memberships
// filters on Joined (see GetGroupChatsForUser).
GetGroupMembershipsForUser(ctx context.Context, userID *commonpb.UserId) ([]GroupMembership, error)
// GetGroupChatsForUser returns the canonical record of every group chat
// userID is currently a joined member of, in no particular order. It is
// GetGroupMembershipsForUser, less the departed records, followed by the
// canonical read of each ID, and returns records exactly as GetChatByID
// does: Members empty and RosterSummary zero. A user with no group
// memberships gets an empty result, not an error.
//
// It is the group feed's source: with no per-member activity index, a user's
// groups are ordered by reading every one of them and sorting — so this is
// paid once per feed snapshot, and the order is carried forward from there
// (see Server.GetGroupChatFeed).
GetGroupChatsForUser(ctx context.Context, userID *commonpb.UserId) ([]*Chat, error)
// GetGroupChatsForUserByIDs is GetGroupChatsForUser restricted to chatIDs:
// the canonical record of each given group chat that exists and that userID
// is currently a joined member of, in no particular order. IDs the user is
// not a member of (never, or no longer) and IDs of chats that do not exist
// are omitted rather than reported; duplicate IDs collapse. It returns an
// error if any ID is not a group chat ID, and an empty result (no error) when
// chatIDs is empty.
//
// Membership is checked here, per ID, against the membership records — the
// IDs are a caller's hint of what to read, never its authority to read it.
// The group feed resumes from IDs a client echoed back in a paging token,
// and this is what keeps a tampered token, or one that outlived the caller's
// membership, from surfacing a chat they cannot see.
GetGroupChatsForUserByIDs(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) ([]*Chat, error)
// GetGroupChatsByID returns the canonical record of each given group chat
// that exists, keyed by string(chatID.Value), whoever is asking: it checks
// no membership, so a caller passes only IDs whose records it may show.
// IDs of chats that do not exist are absent; duplicate IDs collapse. The
// read is eventually consistent, at half the cost of a strong one: a group
// created moments ago may be absent, and a record may trail an edit by a
// moment, which its readers tolerate (see Server.SetFeaturedGroups and
// Server.GetFeaturedGroups). Like GetChatByID it reads only the records: a
// group's Members and RosterSummary are empty. It returns an error if any
// ID is not a group chat ID, and an empty result (no error) when chatIDs is
// empty.
//
// TODO: revisit the consistency if another reader comes to use it,
// especially one that needs a strongly consistent answer, e.g. by having
// the caller choose it.
GetGroupChatsByID(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]*Chat, error)
// AdvanceLastMessage records messageID as the chat's most recent message,
// moving last_activity forward to ts and last_message_id to messageID, and
// reports whether it advanced. The two fields are two views of the same event
// (the newest message) and are updated together. If the stored last_activity
// is already at or after ts, it is a no-op and reports advanced=false. It
// returns ErrChatNotFound if the chat does not exist.
//
// For a DM it also returns the chat's members — the set the new activity is
// fanned out to, which rides on the canonical record it must load regardless.
// A caller that goes on to broadcast the same activity can reuse this set
// instead of issuing a separate GetMembers. Members are returned on both the
// advanced and no-op paths; they are nil on error (including
// ErrChatNotFound). A group chat's membership lives in its own records, so
// members is empty and the caller reads GetMembers itself.
//
// A DM keeps a copy of its activity per member, which is what orders that
// member's DM feed (see GetDmFeedPage); an advance moves every such copy
// with the canonical record. A member the DM excludes from the feed (see
// PutChat) has no copy to move and is left out.
AdvanceLastMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId, ts time.Time) (advanced bool, members []*commonpb.UserId, err error)
// SetMute records mute as userID's mute on chatID, replacing any mute
// already set, and returns the state after the write. changed reports
// whether anything moved: recording the mute already recorded (after
// Mute.Normalize) is a no-op that returns the current state at its
// current version. It returns ErrMuteUntilOutOfRange for a timed mute
// outside the recordable range (see Mute). It touches nothing of the
// user's restrictions.
SetMute(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mute Mute) (state ViewerState, changed bool, err error)
// ClearMute removes userID's mute on chatID, lapsed or not, and returns
// the state after the write. Clearing when no mute is recorded is a no-op
// that returns the current state — the zero state when the user has no
// record, which the no-op does not create. It touches nothing of the
// user's restrictions, which is what lets a departure clear a mute (see
// Server.LeaveChat) and leave a silence or a ban standing.
ClearMute(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (state ViewerState, changed bool, err error)
// GetViewerStates returns userID's state on each of chatIDs that they have
// a record on, keyed by string(chatID.Value); a chat with no record is
// absent rather than reported, and duplicate IDs collapse. The read is
// strongly consistent: it reflects every write that completed before it,
// so the user who just muted sees the mute. An empty chatIDs is an empty
// result, not an error.
GetViewerStates(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]ViewerState, error)
// GetViewerStatesForUsers returns the state of each of userIDs on chatID
// that has a record, keyed by string(userID.Value): the transpose of
// GetViewerStates, one chat and many users. A user with no record is
// absent rather than reported, and duplicate IDs collapse. The read is
// eventually consistent, at half the cost of a strong one: it may trail a
// write by a moment, which its readers tolerate, since they rank and show
// people (e.g. dropping banned users from mention suggestions) rather than
// gate an action — a gate reads the user's own state with GetViewerStates.
// An empty userIDs is an empty result, not an error.
GetViewerStatesForUsers(ctx context.Context, chatID *commonpb.ChatId, userIDs []*commonpb.UserId) (map[string]ViewerState, error)
// SetSilence records silence as userID's silence in the group chatID as of
// now, replacing any silence already set, and returns the state after the
// write. changed reports whether anything moved: recording the silence
// already recorded (after Silence.Normalize) is a no-op that returns the
// current state at its current version, and leaves RestrictedAt where it
// was; a real change moves RestrictedAt to now. It touches nothing of the
// user's mute, and nothing of the roster or the lobby: a silence may be
// set on a user who is not a member. It returns ErrSilenceUntilOutOfRange
// for a timed silence outside the recordable range (see Silence), and an
// error if chatID is not a group chat ID or now is before the Unix epoch.
SetSilence(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, silence Silence, now time.Time) (state ViewerState, changed bool, err error)
// ClearSilence removes userID's silence in the group chatID, lapsed or
// not, and returns the state after the write; RestrictedAt is removed with
// it unless the user is banned. Clearing when no silence is recorded is a
// no-op that returns the current state — the zero state when the user has
// no record, which the no-op does not create. It returns an error if
// chatID is not a group chat ID.
ClearSilence(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (state ViewerState, changed bool, err error)
// BanUser bans userID from the group chatID as of now (see BanResult). In
// one write it records the ban on the user's ViewerState, moving
// RestrictedAt to now; when the user is a joined member, removes them
// from the roster exactly as RemoveGroupMember does (one membership
// transition, a departure like any other), discarding their key envelope
// when discardKeyEnvelope is set, under the same rule; and when they are
// waiting in the group's lobby, removes their entry with its counts as
// LeaveLobby does. What the user holds is judged as of the write, so a
// join, an admission or an entry racing the ban either lands first and is
// undone by it, or lands after and is refused (see ErrBanned): a banned
// user never holds a membership or an entry. A ban leaves a silence, and
// a mute, as they are.
//
// Banning a user already banned is a no-op that writes nothing, returning
// Changed false with the state at its current version and the roster as
// read. Like RemoveGroupMember, whether a departure takes the envelope is
// the caller's to say (see Server.LeaveChat). It returns ErrChatNotFound
// if the chat does not exist, and an error if chatID is not a group chat
// ID or now is before the Unix epoch. It does not check who the user is:
// that the creator cannot be banned is the caller's gate.
BanUser(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, now time.Time, discardKeyEnvelope bool) (BanResult, error)
// UnbanUser lifts userID's ban from the group chatID and returns the state
// after the write; RestrictedAt is removed with it unless the user is
// silenced. It does not return the user to the group or its lobby: they
// join or enter again like anyone. Unbanning a user who is not banned is
// a no-op that returns the current state, the zero state when the user
// has no record, which the no-op does not create. It returns an error if
// chatID is not a group chat ID.
UnbanUser(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (state ViewerState, changed bool, err error)
// GetRestrictedUsersPage returns one page of the users with a restriction
// recorded in the group chatID (see ViewerState.IsRestricted), in listing
// order (see RestrictionPosition): most recently restricted first,
// strictly after the after position (nil starts at the most recent), at
// most limit of them (limit <= 0 means unbounded), each with their whole
// ViewerState. A position need not name a user still restricted, or
// restricted at that time. A lapsed silence is still a restriction
// recorded, and listed; leaving it out is the caller's call against its
// clock. The read may lag a write by a moment — it is served from an
// index of the restricted users rather than their records — so a user
// just restricted may be missing and one just released may still appear,
// which the creator's listing tolerates. A group with no restrictions, or
// that does not exist, is an empty page. It returns an error if chatID is
// not a group chat ID.
GetRestrictedUsersPage(ctx context.Context, chatID *commonpb.ChatId, after *RestrictionPosition, limit int) ([]RestrictedUser, error)
// GetMutedUsers returns the users whose mute on chatID is active at now
// (see Mute.Active), at most limit of them when limit is positive; which
// users are omitted when the limit truncates is unspecified, and the
// order is arbitrary. It reads only the recorded mutes, so it is the
// cheaper read for a chat few have muted; see GetMutedCount for choosing.
// The read may lag a write by a moment — it is the fan-out's read, where
// a mute that lands during a send is tolerated, and the flag it feeds is
// best-effort by contract — and a caller that needs a user's own current
// state reads GetViewerStates instead.
GetMutedUsers(ctx context.Context, chatID *commonpb.ChatId, now time.Time, limit int) ([]*commonpb.UserId, error)
// GetMutedUsersPage is GetMutedUsers bounded to a key range: the users
// whose mute on chatID is active at now and whose ID lies in [lo, hi]
// (byte order, inclusive; a nil bound is open on that side), in ascending
// user-ID order. The order is the one GetGroupMembersPage walks a group's
// roster in, so a fan-out that holds one page of the roster asks for the
// mutes between that page's first and last user and never holds either
// whole; that is the read for a chat most have muted. It reads every
// record the chat's users hold in the range, muted or not — including
// records of users who have since left, which a caller intersects with
// the roster — and consistency is as for GetMutedUsers.
GetMutedUsersPage(ctx context.Context, chatID *commonpb.ChatId, now time.Time, lo, hi *commonpb.UserId) ([]*commonpb.UserId, error)
// GetMutedCount returns how many users have a mute recorded on chatID:
// moved with every mute first recorded or cleared, never by a replaced
// mute or a timed one lapsing, so it bounds the active mutes from above.
// It is what a fan-out compares against the roster size to choose
// between GetMutedUsers and GetMutedUsersPage, and is read for that
// alone: like them, it may lag a write by a moment, which a choice of
// shape tolerates. A chat nobody has muted reads as zero.
GetMutedCount(ctx context.Context, chatID *commonpb.ChatId) (uint64, error)
// RecordSend records that userID sent in the group chatID at sentAt (see
// the activity record, RecentSender), moving its activity score with it
// (see NextActivityScore), and reports whether it did. Each recorded send
// moves the score exactly once, however many writers record sends for the
// same user at once. It does not record when the record already holds a
// send later than sentAt − ActivityRecordInterval, which is what
// throttles a burst and keeps a delayed or retried send from moving the
// record backwards, so any number of retries is safe. It records against
// the chat ID alone, reading neither the canonical record nor the roster:
// the caller is the send path, which has already gated both. A caching
// decorator may answer a throttled send itself, with no store request
// (see cache.Cache). It returns an error if chatID is not a group chat
// ID, or if sentAt is not after the Unix epoch.
RecordSend(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, sentAt time.Time) (recorded bool, err error)
// GetRecentSenders returns the group's activity records, with their
// activity scores, most recently sent first, at most limit of them
// (limit <= 0 means unbounded), from an eventually consistent read, so a
// send recorded a moment ago may be missing or at its previous time: its
// readers rank and show people, which a refetch corrects. Ties come back
// in no particular order. Records of users who have since left are
// included (see the activity record), and a record past
// ActivityRetention may be. A group with no records, or that does not
// exist, is an empty result. It returns an error if chatID is not a group
// chat ID.
GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]RecentSender, error)
// GetActiveSenders returns the group's activity records most active
// first, by activity score (see NextActivityScore), at most limit of them
// (limit <= 0 means unbounded), from an eventually consistent read, as
// GetRecentSenders. Ties come back in no particular order. A record
// written before scores existed and never since backfilled is not
// returned. Records of users who have since left are included, and a
// record past ActivityRetention may be. A group with no records, or that
// does not exist, is an empty result. It returns an error if chatID is
// not a group chat ID.
GetActiveSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]RecentSender, error)
// GetLastSentAt returns userID's activity record in the group chatID:
// when their latest recorded send was, and whether they have a record at
// all. It is the point read of what GetRecentSenders ranges over, for a
// user it may not reach, and eventually consistent like it. A record past
// ActivityRetention may be returned. A user with no record, or a group
// that does not exist, is ok == false. It returns an error if chatID is
// not a group chat ID.
GetLastSentAt(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (lastSentAt time.Time, ok bool, err error)
// SetKeyEnvelope stores envelope as userID's key envelope for the group
// chatID, and returns the envelope that stands after the call. An
// envelope the user wrapped themself (KeyEnvelope.IsWrappedBy userID) is
// never replaced: when one is stored, nothing is written and it is
// returned, whatever the call carried. Otherwise the call's envelope is
// stored, over none or over one someone else wrapped for the user, and
// returned. So the first envelope a user stores for themself stands, a
// repeat of the stored envelope is the no-op it looks like, and a caller
// learns whether its envelope is the one stored by comparing it with the
// one returned (KeyEnvelope.Equal). The check and the write are one
// conditional write, so two concurrent calls agree on which envelope
// stands. It returns an error if chatID is not a group chat ID, or if
// envelope.WrappedBy is nil.
SetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope KeyEnvelope) (KeyEnvelope, error)
// GetKeyEnvelope returns userID's key envelope for chatID, or
// ErrKeyEnvelopeNotFound when they have none. The read is strongly
// consistent: it reflects every write that completed before it, so a
// user who just stored an envelope, or was just given one, reads it.
GetKeyEnvelope(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (KeyEnvelope, error)
// EnterLobby records userID as waiting in the group chatID's lobby, as
// of now, and returns their entry. A member of the group is refused as
// ErrAlreadyMember, judged in the write that would record the entry
// against the membership record as of that write, so an entry can never
// land for a user admitted meanwhile (see AdmitFromLobby, which removes
// the entry in the write that joins them): the lobby and the roster
// never hold the same user. A user already waiting is a no-op that
// returns the entry as it stands, with changed false; the entry's time
// is the first entry's. Both limits are enforced in the same write: a
// lobby at LobbySize is ErrLobbyFull and a user in LobbiesPerUser
// lobbies ErrTooManyLobbies, judged against the counts as of the write,
// with nothing recorded. Membership is judged before an existing entry,
// and the lobby's cap before the user's. It returns an error if chatID
// is not a group chat ID. It does not check that the chat exists or that
// it is a private group: those are the caller's.
//
// A banned user (see BanUser) is refused with ErrBanned, judged in the same
// write against their ViewerState, after membership (a banned user is
// never a member) and before an existing entry (a banned user never holds
// one), so a ban and a concurrent entry never both land.
EnterLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, limits LobbyLimits) (entry LobbyEntry, changed bool, err error)
// LeaveLobby removes userID from chatID's lobby, reporting whether they
// were in it. A user not waiting is a no-op. It returns an error if
// chatID is not a group chat ID.
LeaveLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (changed bool, err error)
// GetLobbyEntries returns userID's entry in each of the given lobbies
// they are waiting in, keyed by string(chatID.Value); lobbies they are
// not in are absent. The read is strongly consistent, like a key
// envelope's: it is what a user is told of their own waiting.
GetLobbyEntries(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]LobbyEntry, error)
// GetLobbyPage returns up to limit of chatID's lobby in lobby order (see
// LobbyPosition), strictly after the given position, or from the start
// when it is nil. A limit of zero or less means no limit. The read is
// eventually consistent: an entry just made may be absent and one just
// removed present, which the proto allows of this read alone. A group
// with no lobby, or that does not exist, is an empty result. It returns
// an error if chatID is not a group chat ID.
GetLobbyPage(ctx context.Context, chatID *commonpb.ChatId, after *LobbyPosition, limit int) ([]LobbyEntry, error)
// AdmitFromLobby admits userID, waiting in chatID's lobby, to the group:
// in one write it stores envelope as their key envelope (replacing any
// they hold, since the one the creator wraps now is the one that opens
// the chat), joins them as a member (one membership transition, as
// AddGroupMembers makes it) and removes their lobby entry with its
// counts. It returns the roster summary as of the write, as
// AddGroupMembers does, and changed false when the user was a member
// already (nothing is written, their entry included). It returns
// ErrNotInLobby when the user is not waiting, with nothing written;
// ErrBanned when the user is banned (see BanUser), judged in the same
// write against their ViewerState, after the membership no-op and before
// the lobby (a banned user holds no entry, so a ban landing first is
// reported as the ban it is rather than as ErrNotInLobby); ErrChatNotFound
// if the chat does not exist; and an error if chatID is not a group chat
// ID or envelope.WrappedBy is nil.
AdmitFromLobby(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, envelope KeyEnvelope) (changed bool, roster RosterSummary, err error)
// SetFeaturedGroups replaces userID's featured groups with chatIDs, in
// that order, and returns the list that stands after the call. A list
// equal to the stored one is a no-op that writes nothing and returns it
// with changed false; otherwise the whole list is replaced in one write
// that moves the version by one, so concurrent replaces each land whole,
// in some order, and a reader never sees a mix. An empty list clears the
// featured groups. It returns an error unless chatIDs passes
// ValidateFeaturedGroups. It does not check that the groups exist or are
// public: those need the groups' records, and are the caller's.
SetFeaturedGroups(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (featured FeaturedGroups, changed bool, err error)
// GetFeaturedGroups returns userID's featured groups in their order. The
// read is eventually consistent, at half the cost of a strong one: it may
// trail a replace by a moment, returning the list as it was before, but
// never a mix of two lists. Its readers display the list, which a refetch
// corrects; SetFeaturedGroups decides against a strongly consistent read
// of its own. A user who has never set any reads as no groups at version
// zero.
GetFeaturedGroups(ctx context.Context, userID *commonpb.UserId) (FeaturedGroups, error)
// PutGroupHoldings records h as its group's holdings snapshot (see
// GroupHoldings), replacing the one stored, and reports whether it did.
// Every snapshot is stored, listed or not, but only a listed one (see
// GroupHoldings.Listed) is ranked: one that is not replaces the stored
// snapshot and takes the group out of GetTopGroupsByHoldings. A snapshot
// is never replaced by an older one: one taken at or before the stored
// snapshot's AsOf is a no-op that writes nothing and returns changed
// false, so refreshes that race, or a retried one, leave the latest
// standing. It returns an error unless h passes ValidateGroupHoldings.
//
// A group's holdings are of one mint: its queue entry and every snapshot
// share it, and the first of them to be written fixes it. A snapshot of
// any other mint is ErrHoldingsMintMismatch, with nothing written, and
// is judged before its age: a mismatched snapshot is refused even when it
// is also older than the stored one. It does not check that the group
// exists or is gated on h.Mint: that needs the group's record, and is the
// caller's; the worker writes the mint the queue entry holds, which was
// read from the record when the group was created.
PutGroupHoldings(ctx context.Context, h GroupHoldings) (changed bool, err error)
// GetGroupHoldings returns the holdings snapshot of each given group that
// has one, listed or not, keyed by string(chatID.Value); groups with none
// are absent and duplicate IDs collapse. A caller showing them applies
// GroupHoldings.Listed, as hydration does. The read is eventually consistent, at half the
// cost of a strong one: a snapshot is a display of derived data that a
// moment's lag cannot make wrong. It returns an error if any ID is not a
// group chat ID, and an empty result (no error) when chatIDs is empty.
GetGroupHoldings(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]GroupHoldings, error)
// GetTopGroupsByHoldings returns up to limit of the groups whose listed
// snapshot (see GroupHoldings.Listed) names mint, in HoldingsLess order: most quarks first, ties to
// the greater chat ID. A limit of zero or less means no limit. The read
// is eventually consistent, and not a snapshot of the ranking: a group
// refreshed while it runs may appear where it stood before or where it
// stands after, and the result never names a group twice. A mint no
// snapshot names is an empty result.
GetTopGroupsByHoldings(ctx context.Context, mint *commonpb.PublicKey, limit int) ([]GroupHoldings, error)
// EnqueueHoldingsRefresh puts chatID on the holdings refresh queue for
// mint, due at dueAt, with no failed attempts, and reports whether it did.
// A group already queued is a no-op that leaves its entry as it is, so a
// backfill over every gated group, PutChat's enqueue included, can run
// any number of times. A group whose holdings already record another
// mint is ErrHoldingsMintMismatch, as for PutGroupHoldings, judged before
// whether it is queued. It returns an error if chatID is not a group chat
// ID or mint is not the shape of a public key. It does not check that the
// group exists or is gated on mint: that needs the group's record, and is
// the caller's.
EnqueueHoldingsRefresh(ctx context.Context, chatID *commonpb.ChatId, mint *commonpb.PublicKey, dueAt time.Time) (changed bool, err error)
// GetDueHoldingsRefreshes returns up to limit queued groups whose due
// time is at or before asOf, soonest first. A limit of zero or less means
// no limit. The queue is eventually consistent: a group just claimed or
// rescheduled may still be listed, which ClaimHoldingsRefresh settles.
GetDueHoldingsRefreshes(ctx context.Context, asOf time.Time, limit int) ([]HoldingsRefresh, error)
// ClaimHoldingsRefresh pushes a queued group's due time out to until,
// provided it is still due as of asOf, and reports whether it did: false
// means another worker claimed or rescheduled it first, or it is not
// queued. It is what lets every worker list the same due groups and only
// one refresh each.
ClaimHoldingsRefresh(ctx context.Context, chatID *commonpb.ChatId, asOf, until time.Time) (bool, error)
// RescheduleHoldingsRefresh sets a queued group's next due time and its
// count of failed attempts: zero after a refresh that succeeded, one more
// than the claim read after one that failed. A group that is not queued
// is a no-op.
RescheduleHoldingsRefresh(ctx context.Context, chatID *commonpb.ChatId, dueAt time.Time, attempts uint32) error
}
Store persists chats, their membership, and each user's own state on a chat.
DM membership is fixed at creation time (the two participants) and is never mutated afterward. Group chat membership is mutable via AddGroupMembers and RemoveGroupMember. last_activity is advanced as new activity (typically messages) occurs and is the sort key for a user's chat list.
A chat ID's length discriminates its family (see DmChatIDSize and GroupChatIDSize): implementations dispatch on it, and each method rejects or misses IDs of the wrong family for its semantics — except the viewer-state methods, which accept both and do not care which.
Viewer state (see ViewerState) is one record per (user, chat), written by that user's own actions (a mute) and by the group creator's moderation of them (a silence, a ban), and read back to them — plus the chat-scoped reads GetMutedUsers, GetMutedUsersPage and GetMutedCount, which serve the push fan-out, GetRestrictedUsersPage, the creator's listing, and GetViewerStatesForUsers. Records are sparse: nothing is written when a chat is created or joined, a record appears on the first write about the user, and nothing removes it — a cleared mute or a lifted restriction leaves the record and its version behind. The user's own settings and the creator's restrictions are independent within it: a mute's writes never touch a restriction, and a restriction's never touch a mute or the muted count. The record knows nothing of membership: a departure clears a mute only because the leave handler calls ClearMute (see Server.LeaveChat), which leaves a silence and a ban as they are, and whether the caller may act on the chat, or on the user, is likewise the caller's gate. The one tie to the roster is a ban, which removes the user from it, and from the lobby, in the same write (see BanUser), and which the writes that join a user check (see ErrBanned). Every write is conditional — a single update, or one transaction where a store keeps an aggregate or the roster alongside the record — and moves Version by exactly one on a real change and not at all on a no-op, so a retried or duplicated request is harmless.
A key envelope (see KeyEnvelope) is likewise one record per (user, chat), for a private group: the group's chat key as that user can open it. It is written against the IDs alone, like viewer state, and knows nothing of the group or its roster: that the chat is a private group, and that the user is a member, are the caller's gates. The one tie to the roster is a departure, which removes the envelope in the same write when the caller asks it to (see RemoveGroupMember). A store of one is one conditional write of the one record, so a retried or duplicated request is harmless.
A lobby entry (see LobbyEntry) is one record per (user, private group) too, kept with two counts, the lobby's size and the user's lobbies, that move in the same write as the entry (see EnterLobby). Like the rest, it is written against the IDs alone; that the chat is a private group with its key, that the user is not a member, and who may admit them are the caller's gates. An admission (AdmitFromLobby) is where the lobby meets the roster: the entry leaves, the key envelope lands and the membership transitions in one write, so a user is never admitted without a key or left waiting after.
type SystemMessenger ¶ added in v1.35.0
type SystemMessenger interface {
// SendUserJoined writes the system message telling a group's members that
// userID joined it: admitted by admittedBy, or on their own when it is nil.
// rosterVersion is the roster version after the join, which names it: a
// repeat of one join writes nothing.
SendUserJoined(ctx context.Context, chatID *commonpb.ChatId, userID, admittedBy *commonpb.UserId, rosterVersion uint64) error
}
SystemMessenger is the write slice of the messaging domain the Chat service needs to put its own messages in a chat's log. Like MessagingReader it is declared here (consumer side) because messaging imports chat; messaging.Sender satisfies it directly.
type UserEventPublisher ¶ added in v1.30.0
UserEventPublisher is the write slice of the event domain the Chat service needs to notify one user's streams — the user-keyed event bus. Like the readers it is declared here (consumer side) because the event package imports chat for stream registration, so chat cannot import it back; the event package's Bus satisfies it directly.
type ViewerState ¶ added in v1.32.0
type ViewerState struct {
Mute *Mute
Silence *Silence
BannedAt *time.Time
RestrictedAt *time.Time
Version uint64
}
ViewerState is what a chat holds about one user, independent of whether they are a member: state the user set for themselves (today, a mute), restrictions the group's creator set on them (a silence, a ban; see Store.SetSilence and Store.BanUser), and a version over all of it. It is private to that user and never shared with other members, and shown to the user only while they are a member (see Server.hydrate) — except that the restrictions are also the creator's to list (see Store.GetRestrictedUsersPage). The record outlives membership — the version never resets — but what it holds may not: a mute is cleared, best effort, when the user leaves the chat (see Server.LeaveChat), so a user who returns to a group starts unmuted unless that clear failed. Restrictions are not: they are the creator's, and survive any number of departures and returns, which is the point of them.
Mute is the recorded mute, or nil when none is set; see Mute for what a recorded mute may still mean. Silence is likewise the recorded silence, or nil, lapsed or not (see Silence). BannedAt is when the user was banned, or nil when they are not: a ban has no duration, and holds until lifted (see Store.UnbanUser). A banned user is never a member of the group nor waiting in its lobby, which the store guarantees in the writes that join, admit or enqueue them (see ErrBanned). RestrictedAt is the time of the latest real silence or ban set on the user — a silence replaced moves it, lifting one restriction leaves it as it is while the other remains — and is set exactly when Silence or BannedAt is: it orders the creator's listing, most recently restricted first.
Version is state, not a delta: every real change — a mute or silence set, replaced or cleared, a ban made or lifted — moves it by exactly one and an idempotent no-op leaves it alone, so a client keeps the greater of two versions and drops the rest, whatever order they arrived in. The user's own settings and the creator's restrictions share it, since one ViewerState carries both. It is not the roster version (see RosterSummary), which nothing here ever moves; a ban that removes a member moves both, each by its own rule.
What a client is shown alongside the record is the chat's Permissions for the user (see ToProto), computed from the chat's record and the user's membership at the time of the read rather than stored, and not covered by Version: a membership transition changes what the user may do (see Chat.PermissionsFor) without moving the record, so the same version may carry different permissions before and after one. That is accepted for now — a transition of the user's own is what changes them, and it reaches the user's devices as a RosterUpdate carrying fresh metadata — rather than moving the version on every transition of a user who may hold no record.
func (ViewerState) ActiveMute ¶ added in v1.32.0
func (v ViewerState) ActiveMute(now time.Time) *Mute
ActiveMute returns the recorded mute when it is in force at now, else nil.
func (ViewerState) ActiveSilence ¶ added in v1.36.0
func (v ViewerState) ActiveSilence(now time.Time) *Silence
ActiveSilence returns the recorded silence when it is in force at now, else nil.
func (ViewerState) Clone ¶ added in v1.32.0
func (v ViewerState) Clone() ViewerState
Clone returns a deep copy of the state.
func (ViewerState) IsBanned ¶ added in v1.36.0
func (v ViewerState) IsBanned() bool
IsBanned reports whether the user is banned from the group.
func (ViewerState) IsRestricted ¶ added in v1.36.0
func (v ViewerState) IsRestricted() bool
IsRestricted reports whether the creator has set any restriction on the user, in force or not: a silence recorded, lapsed or not, or a ban.
func (ViewerState) ToProto ¶ added in v1.32.0
func (v ViewerState) ToProto(permissions Permissions) *chatpb.ViewerState
ToProto projects the state onto a chatpb.ViewerState exactly as recorded: the version, under settings the mute if one is recorded, under restrictions the silence if one is recorded, each lapsed or not, and the user's permissions in the chat as the caller computed them (see Permissions). A version names one exact state, and every carrier of that version — a response, a hydrated Metadata, a ViewerStateChanged — must agree on it, which they could not if the projection depended on the clock it was made at. Whether a timed mute or silence is still in force is the reader's call against its own clock (see Mute.Active, Silence.Active); the server makes that call only where it acts on it, in the push fan-out and the speaker gate. Restrictions are absent when no silence is recorded: a ban is not carried, since a banned user is never a member and a non-member is shown no ViewerState (see the proto's ViewerState.Restrictions). The permissions are always carried, empty for a user who may do nothing, so a carrier never leaves a client to guess whether they were withheld or are none.