246 lines
9.0 KiB
Go
246 lines
9.0 KiB
Go
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"
|
|
)
|
|
|
|
// 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"`
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|