feat: add chat
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user