package model import ( "encoding/json" "time" ) // Who may start a conversation with a user. const ( WhoAnyone = "anyone" WhoFriends = "friends" WhoAccountAge = "account_age" WhoSameGender = "same_gender" ) // ChatSettings are a user's own chat preferences. Absence of a stored row means // all defaults, so DefaultChatSettings and the column defaults must agree. type ChatSettings struct { ReadReceipts bool `json:"read_receipts"` WhoCanMessage string `json:"who_can_message"` MinAccountAgeMonths int `json:"min_account_age_months"` TrainKautilya bool `json:"train_kautilya"` // GenderSet reports whether the user has a gender on their profile. The // same_gender option is unusable without one, so the UI needs to know. GenderSet bool `json:"gender_set"` } // DefaultChatSettings mirrors the column defaults in migration 0050. func DefaultChatSettings() ChatSettings { return ChatSettings{ ReadReceipts: true, WhoCanMessage: WhoAnyone, MinAccountAgeMonths: 3, TrainKautilya: true, } } // Conversation kinds. DMs and groups fill the inbox's "Chats" tab; ai fills // the "Kautilya" tab. // Group send policies. Only groups honour these — a DM has no administrators // and the Kautilya thread has one participant. const ( SendPolicyEveryone = "everyone" SendPolicyAdmins = "admins" ) // Participant roles, most privileged first. const ( RoleOwner = "owner" RoleAdmin = "admin" RoleMember = "member" ) const ( ConversationDM = "dm" ConversationGroup = "group" ConversationAI = "ai" ) // Message kinds describe who produced a message, not what it contains — the // content type lives on its attachments. const ( MessageText = "text" MessageSystem = "system" MessageAssistant = "assistant" ) // Conversation is a chat thread. type Conversation struct { ID string `json:"id"` Kind string `json:"kind"` Title *string `json:"title,omitempty"` AvatarKey *string `json:"avatar_key,omitempty"` // SendPolicy is "everyone" or "admins"; meaningful for groups only. SendPolicy string `json:"send_policy"` LastSeq int64 `json:"last_seq"` LastMessageAt *time.Time `json:"last_message_at,omitempty"` CreatedAt time.Time `json:"created_at"` } // ChatPeer is the counterpart shown on a DM row: enough to render the inbox // without a second round trip. type ChatPeer struct { ID string `json:"id"` Handle string `json:"handle"` Name string `json:"name"` AvatarURL *string `json:"avatar_url,omitempty"` // Role and JoinedAt are filled for a thread's participant list; they are // empty on the `peer` of a DM, where a role means nothing. Role string `json:"role,omitempty"` JoinedAt *time.Time `json:"joined_at,omitempty"` } // MessagePreview is the truncated last-message line on an inbox row. type MessagePreview struct { Seq int64 `json:"seq"` SenderID *string `json:"sender_id,omitempty"` SenderName string `json:"sender_name"` Kind string `json:"kind"` Body string `json:"body"` DeletedAt *time.Time `json:"deleted_at,omitempty"` } // InboxItem is one row of the chat inbox. type InboxItem struct { Conversation Unread int64 `json:"unread"` // MutedUntil is non-nil while the caller has this thread muted. Unread // still accrues — muting silences the alert, it does not hide the chat. MutedUntil *time.Time `json:"muted_until,omitempty"` Peer *ChatPeer `json:"peer,omitempty"` // DMs only LastMessage *MessagePreview `json:"last_message,omitempty"` // nil on an empty thread } // ChatRelationship is how two people in a DM stand with each other — what the // chat profile shows above the shared-media tabs. type ChatRelationship struct { // Following: the caller follows the peer. FollowedBy: the reverse. Both // true is mutual; one true is a one-way follow. Following bool `json:"following"` FollowedBy bool `json:"followed_by"` Blocked bool `json:"blocked"` BlockedBy bool `json:"blocked_by"` // FriendsSince is when the first follow between them was accepted — the // "friends since" line. Nil when neither follows the other. FriendsSince *time.Time `json:"friends_since,omitempty"` } // ConversationDetail is everything a thread screen needs in one request: // the thread, who is in it, and the caller's own state. It replaces scanning // the inbox to find a single conversation. type ConversationDetail struct { Conversation // Peer is the counterpart on a DM, nil otherwise. Peer *ChatPeer `json:"peer,omitempty"` // Participants is everyone still in the thread, the caller included, so // the UI can name a message's sender without another lookup. Participants []ChatPeer `json:"participants"` Unread int64 `json:"unread"` MutedUntil *time.Time `json:"muted_until,omitempty"` // MyRole is the caller's role in this thread, so the UI can show admin // controls without deriving it from the participant list. MyRole string `json:"my_role"` // Relationship is populated for DMs only; a group has no single peer to // stand in a relationship with. Relationship *ChatRelationship `json:"relationship,omitempty"` } // Participant is a user's membership of a conversation, including the two // cursors that drive tick state. type Participant struct { ConversationID string `json:"conversation_id"` UserID string `json:"user_id"` Role string `json:"role"` LastReadSeq int64 `json:"last_read_seq"` LastDeliveredSeq int64 `json:"last_delivered_seq"` MutedUntil *time.Time `json:"muted_until,omitempty"` JoinedAt time.Time `json:"joined_at"` LeftAt *time.Time `json:"left_at,omitempty"` } // Attachment kinds. const ( AttachmentImage = "image" AttachmentVideo = "video" AttachmentAudio = "audio" AttachmentFile = "file" AttachmentLink = "link" AttachmentEntity = "entity" // AttachmentQuote is a passage shared from the library: url is an // app-relative deep link back to the phrase, title the source reference, // description the passage itself. AttachmentQuote = "quote" ) // Entity types a message can reference. const ( EntityPost = "post" ) // Attachment is a media file, link preview or shared entity on a message. // // Media carries an R2 key rather than a URL: chat media lives in the private // bucket and is only ever reachable through a participant-checked, short-lived // presigned GET, so there is no URL to store. type Attachment struct { ID string `json:"id"` MessageID string `json:"message_id"` Ord int `json:"ord"` Kind string `json:"kind"` // Media Mime *string `json:"mime,omitempty"` SizeBytes *int64 `json:"size_bytes,omitempty"` Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` DurationMs *int `json:"duration_ms,omitempty"` Blurhash *string `json:"blurhash,omitempty"` FileName *string `json:"file_name,omitempty"` // Link preview. Metadata is fetched by the BFF, never supplied by a // client — otherwise any link could be made to claim anything. URL *string `json:"url,omitempty"` Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` SiteName *string `json:"site_name,omitempty"` ImageURL *string `json:"image_url,omitempty"` // Shared entity (a post, for now). EntityType/EntityID are the durable // reference; Entity is resolved per viewer at read time, so the same row // renders differently — or not at all — depending on who is looking. EntityType *string `json:"entity_type,omitempty"` EntityID *string `json:"entity_id,omitempty"` Entity json.RawMessage `json:"entity,omitempty"` // EntityUnavailable is set when the entity was deleted, hidden, or the // viewer has since lost access. The client renders a tombstone. EntityUnavailable bool `json:"entity_unavailable,omitempty"` CreatedAt time.Time `json:"created_at"` } // ReplyPreview is the quoted snippet shown above a reply. Deliberately small: // the bubble needs a name and a line of text, not the whole message. type ReplyPreview struct { ID string `json:"id"` Seq int64 `json:"seq"` SenderID *string `json:"sender_id,omitempty"` SenderName string `json:"sender_name"` Body string `json:"body"` Deleted bool `json:"deleted"` } // Message is one chat message. Entities carries the formatting spans produced // by ParseEntities; it is always a JSON array, never null. type Message struct { ID string `json:"id"` ConversationID string `json:"conversation_id"` Seq int64 `json:"seq"` SenderID *string `json:"sender_id,omitempty"` Kind string `json:"kind"` Body string `json:"body"` Entities json.RawMessage `json:"entities"` ClientMsgID *string `json:"client_msg_id,omitempty"` ReplyToID *string `json:"reply_to_id,omitempty"` ForwardedFromID *string `json:"forwarded_from_id,omitempty"` EditedAt *time.Time `json:"edited_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` // ReplyTo is hydrated on read when this message replies to another in the // SAME conversation. Nil otherwise. ReplyTo *ReplyPreview `json:"reply_to,omitempty"` // Attachments is always present (possibly empty) so clients need no nil // check before iterating. Attachments []Attachment `json:"attachments"` // Reactions is always present (possibly empty), same contract as // Attachments — clients render the chip row without a nil check. Reactions []Reaction `json:"reactions"` } // ReactionDetail is one person's reaction, for the "who reacted" list. type ReactionDetail struct { User ChatPeer `json:"user"` Emoji string `json:"emoji"` CreatedAt time.Time `json:"created_at"` } // Reaction is one emoji grouped across the people who chose it. // // Grouped rather than listed per user: a bubble shows "❤️ 3", and sending the // full membership of every reaction would grow with the room. type Reaction struct { Emoji string `json:"emoji"` Count int `json:"count"` // Mine marks the viewer's own choice, so the chip can be highlighted and // tapping it toggles off. Mine bool `json:"mine"` } // Receipts are the conversation-wide delivery cursors used to draw ticks on // the sender's own messages. Both are the MINIMUM across the other // participants, so in a group a message is "read" only once everyone has read // it — matching what users expect from the blue double tick. type Receipts struct { DeliveredSeq int64 `json:"delivered_seq"` ReadSeq int64 `json:"read_seq"` }