Skip to main content

Messages & Conversations — Technical Reference

← Back to Messages & Conversations

Where this lives

Backend

Frontend

Technical implementation checklist

  • sendMessage / conversationMessages — resolvers wired in message.resolver.js; ChatView.tsx sends/receives via createConversation with type: 'direct'
  • setGroupPostingPolicy / setParticipantPostingPermission — group posting permissions; enforced in message.manager.js#createMessage; Conversation.postingPolicy / viewerCanSend, ConversationParticipant.canSendMessages (migration 20260721100000)
  • conversationRemovalPreview(conversationId, userId) + refund on removeParticipant — prorated refund of the unused period for paid members (conversation-subscription.manager.js#refundSubscription / computeProratedRefund)
  • User.canAddToGroup — viewer-relative field used to filter the add-members search (mirrors the add_to_group privacy check)
  • messagesByMediaType(mediaType: link) — links tab; link added to the MessageType enum (matches text messages containing a URL). Starred/Pinned shown as separate sections in SharedContentSection.tsx
  • createConversation / addParticipant / updateParticipantRole / transferAdmin — resolvers wired in conversation.resolver.js; ConversationList.tsx has a "Crear grupo" (New group) button that calls createGroupConversation with type: 'group' (this doc previously said no group-creation UI existed — corrected)
  • Shareable group invite links — conversationInvitePreview (query) and joinConversationViaInvite (mutation) in conversation.resolver.js, backed by conversation-invite.manager.js, consumed by a landing page: apps/frontend-nextjs/src/app/join/[token]/page.tsxJoinConversationPage.tsx, which previews the group (name, avatar, member count) and requires login before joining.
  • setTypingStatus mutation + typingIndicator subscription — wired in ChatView.tsx via useSubscription(TypingIndicatorDocument) and a setTypingStatus call
  • messageReadReceipts query + messageRead subscription, auto-marked via conversationManager.markAsRead / messageManager.markConversationMessagesRead when conversationMessages is fetched — useChatMessages.ts subscribes to messageRead (declared inline as MESSAGE_READ_SUB, since codegen hasn't generated a document for it) and flips the matching message's status to read in the Apollo cache; MessageBubble.tsx renders the blue double-check when status === 'read'
  • addReaction / removeReaction — resolvers wired in message.resolver.js; called from useChatActions.ts, MessageContextMenu.tsx, MessageBubble.tsx; the messageReactionAdded subscription (also declared inline in useChatMessages.ts as it isn't in the generated operations either) refetches messageReactions for the affected message so reactions from other participants appear live
  • pinMessage / unpinMessage / starMessage / unstarMessage — wired in ChatView.tsx, MessageContextMenu.tsx, useChatActions.ts, rendered via PinnedMessagesSection.tsx
  • messageAdded subscription — consumed in useChatMessages.ts / ChatView.tsx to append incoming messages live
  • isPaid / price (set at send time) + purchaseMessage to unlock — both wired in ChatView.tsx and rendered via PaidMediaModal.tsx/MessageBubble.tsx
  • startCall — mutation in call.resolver.js; VoiceCallModal.tsx launched from ChatView.tsx
  • sendCoinsViaMessage — resolver wired in message.resolver.js; called from ChatView.tsx's send handler when pendingCoins is set
  • createPoll / voteInPoll — wired in ChatView.tsx via PollCreationModal.tsx
  • setMessageExpiration — per-message disappearing toggle; wired via useChatActions.ts#handleSetExpiration and offered from MessageContextMenu.tsx (sender-only, hidden once a message is already disappearing). Conversation-level disappearing messages (updateConversationSettingsdisappearingMessagesEnabled/disappearingMessagesDuration) is the other, separate path described below
  • shareLocation — wired via ChatView.tsx#handleShareLocation (browser geolocation → static pin) behind the composer's "Compartir ubicación" action; rendered in MessageBubble.tsx with a Maps link
  • updateLiveLocation / stopLiveLocation — resolvers exist in message.resolver.js, but zero frontend references in the chat components; live-location send also still needs locationType added to the LocationInput GraphQL input (currently only latitude/longitude/address/name)
  • updateConversationSettings (disappearing messages) — disappearingMessages / disappearingDuration map to the real disappearingMessagesEnabled / disappearingMessagesDuration columns in conversation.manager.js; enabling without an explicit duration defaults to 86400s (24h)
  • messageTranslations / myTranslationSetting / setMessageTranslationmessage-translation.resolver.js + message-translation.manager.js; results cached per (message_id, target_language) in the message_translation table; wired in the chat header (ChatHeader.tsx) and mobile details panel (ConversationDetailsPanel.tsx) via useMessageTranslations.ts
  • @mentionscreateMessage parses @username and writes MessageMention rows; userMentions / messageMentions / markMentionAsRead resolvers and the MessageMention GraphQL type exist and are functional, but there is no frontend consumer of any of the three — no in-chat mention-history UI yet

Message types

enum MessageType {
text · link · image · video · audio · file
post · voice · gif · sticker · location · poll
coin_transfer
}

Message model — key fields

FieldDescription
messageTypeType of message (see enum above)
replyToMessageIdQuoted message reference
sharedPostIdPost shared inside the chat
sharedConversationIdConversation shared as a link
sharedUserIdUser profile shared in chat
isPaid / pricePaid message with a price
unlockPriceCoin price to unlock locked content
isDisappearing / disappearsAtEphemeral messages
viewOnceRecipient can view only once
isPinnedPinned in the conversation
isEdited / editHistoryFull edit history
pollEmbedded poll
location / locationTypeStatic or live location

Core queries

conversationMessages returns a paginated message history for a conversation, ordered newest-first. Pass before (a message ID) for cursor-based pagination — load older messages as the user scrolls up.

pinnedMessages returns messages the conversation admin has pinned — show these in a collapsible "Pinned" bar at the top of the chat.

starredMessages returns messages the current user has starred across all conversations — useful for a "Saved messages" inbox.

unreadCount returns the unread message count for a specific conversation or all conversations if conversationId is omitted.

searchMessages performs a full-text search within a conversation. The response includes hasMore for pagination.

query ConversationMessages($conversationId: ID!, $limit: Int, $before: String) {
conversationMessages(conversationId: $conversationId, limit: $limit, before: $before) {
id messageType messageText mediaUrls isPaid unlockPrice
sender { username profilePicture }
replyTo { id messageText }
reactions { emoji user { username } }
readReceipts { user { username } readAt }
}
}

query PinnedMessages($conversationId: ID!) { pinnedMessages(conversationId: $conversationId) { id messageText } }
query StarredMessages { starredMessages { id message { messageText } } }
query UnreadCount($conversationId: ID) { unreadCount(conversationId: $conversationId) }

query SearchMessages($conversationId: ID!, $query: String!) {
searchMessages(conversationId: $conversationId, query: $query) {
messages { id messageText createdAt }
total hasMore
}
}

Mutations

sendMessage is the primary send mutation. Set messageType to control which media fields are required. The mutation returns the full message object so the client can append it to the list immediately.

pinMessage highlights a message in the pinned bar; starMessage saves it to the user's personal starred list (not visible to others).

addReaction attaches an emoji to a message. Multiple reactions from different users are allowed.

forwardMessage copies the message to one or more other conversations. forwardedCount confirms how many were sent.

replyToMessage creates a new message with replyToMessageId set, showing the quoted message above the reply.

setMessageExpiration makes a message disappear after expirationSeconds. Call this on a message that already exists to enable disappearing mode. setViewOnce makes a message viewable only once by the recipient — after viewing it is permanently deleted.

shareLocation sends a static location pin. updateLiveLocation updates the position for a live-tracking message. stopLiveLocation ends live sharing.

createPoll embeds a poll in the chat. voteInPoll records a vote for an option.

scheduleMessage queues a message for future delivery at scheduledFor. cancelScheduledMessage removes it from the queue.

saveMessageDraft stores an unsent draft in the database — retrieved when the user re-opens the conversation.

# Send a message — messageType controls which media fields are required
mutation SendMessage($input: MessageCreateInput!) { sendMessage(input: $input) { id messageType messageText } }

# Conversation-level pin (visible to all participants)
mutation PinMessage($messageId: ID!) { pinMessage(messageId: $messageId) { id } }

# Personal star (private, like bookmarking a message)
mutation StarMessage($messageId: ID!) { starMessage(messageId: $messageId) { id } }

# Attach an emoji reaction to a message
mutation AddReaction($messageId: ID!, $emoji: String!) { addReaction(messageId: $messageId, emoji: $emoji) { id emoji } }

# Copy message to other conversations
mutation ForwardMessage($messageId: ID!, $conversationIds: [ID!]!) { forwardMessage(messageId: $messageId, conversationIds: $conversationIds) { forwardedCount } }

# Quote-reply (creates a new message with replyToMessageId set)
mutation ReplyTo($messageId: ID!, $input: MessageCreateInput!) { replyToMessage(messageId: $messageId, input: $input) { id } }

# Make a message self-destruct after N seconds
mutation SetExpiration($messageId: ID!, $seconds: Int!) { setMessageExpiration(messageId: $messageId, expirationSeconds: $seconds) { disappearsAt } }

# Make a message viewable only once — deleted immediately after the recipient views it
mutation SetViewOnce($messageId: ID!) { setViewOnce(messageId: $messageId) { viewOnce } }

# Share a static location pin
mutation ShareLocation($input: LocationInput!) { shareLocation(input: $input) { id location { latitude longitude } } }

# Update position for a live-location message
mutation UpdateLiveLocation($messageId: ID!, $lat: Float!, $lng: Float!) { updateLiveLocation(messageId: $messageId, latitude: $lat, longitude: $lng) { id } }

# Stop broadcasting live location
mutation StopLiveLocation($messageId: ID!) { stopLiveLocation(messageId: $messageId) { id } }

# Create a poll inside a conversation
mutation CreatePoll($conversationId: ID!, $input: PollInput!) { createPoll(conversationId: $conversationId, input: $input) { id poll { question options { text votes } } } }

# Record a vote on a poll option
mutation VoteInPoll($messageId: ID!, $optionId: ID!) { voteInPoll(messageId: $messageId, optionId: $optionId) { id } }

# Queue a message to be sent at a future time
mutation ScheduleMessage($input: MessageCreateInput!, $scheduledFor: DateTime!) { scheduleMessage(input: $input, scheduledFor: $scheduledFor) { id } }

# Remove a scheduled message before it sends
mutation CancelScheduled($messageId: ID!) { cancelScheduledMessage(messageId: $messageId) }

# Transfer coins directly via a message
mutation SendCoins($conversationId: ID!, $recipientId: ID, $amount: Int!, $message: String) {
sendCoinsViaMessage(conversationId: $conversationId, recipientId: $recipientId, amount: $amount, message: $message) {
success transaction { amount balanceAfter }
}
}

# Persist an unsent draft (retrieved on conversation open)
mutation SaveDraft($conversationId: ID!, $content: String!) { saveMessageDraft(conversationId: $conversationId, content: $content) { id } }

Real-time subscriptions

Subscribe to these on the chat screen so the UI updates without polling.

# New message sent by anyone in the conversation
subscription MessageAdded($conversationId: ID!) { messageAdded(conversationId: $conversationId) { id messageText sender { username } } }

# Existing message was edited
subscription MessageUpdated($conversationId: ID!) { messageUpdated(conversationId: $conversationId) { id isEdited } }

# Message was deleted
subscription MessageDeleted($conversationId: ID!) { messageDeleted(conversationId: $conversationId) { messageId } }

# Shows "Alice is typing..." indicator
subscription TypingIndicator($conversationId: ID!) { typingIndicator(conversationId: $conversationId) { userId username isTyping } }

# Message was read by another participant
subscription MessageRead($conversationId: ID!) { messageRead(conversationId: $conversationId) { messageId userId readAt } }

# A reaction was added to a message in the conversation
subscription MessageReactionAdded($conversationId: ID!) { messageReactionAdded(conversationId: $conversationId) { id messageId userId emoji createdAt } }

Chat translation

Per-conversation, per-participant translation preference, stored on ConversationParticipant (translationEnabled / preferredLanguage) and read/written by message-translation.manager.js. Translated text is cached per (message_id, target_language) in the message_translation table so a message is only ever sent to the paid translation API once per language. myTranslationSetting also returns available: false (hiding the feature client-side) when no translation provider is configured via env vars.

type MessageTranslation { messageId: ID! text: String! sourceLanguage: String targetLanguage: String! }
type TranslationSetting { enabled: Boolean! language: String available: Boolean! }

# Cache-first translations for the given messages, in the viewer's reading language
query MessageTranslations($messageIds: [ID!]!) { messageTranslations(messageIds: $messageIds) { messageId text sourceLanguage targetLanguage } }

# The viewer's current translation preference for a conversation
query MyTranslationSetting($conversationId: ID!) { myTranslationSetting(conversationId: $conversationId) { enabled language available } }

# Turn translation on/off and set the reading language for the caller
mutation SetMessageTranslation($conversationId: ID!, $enabled: Boolean!, $language: String) {
setMessageTranslation(conversationId: $conversationId, enabled: $enabled, language: $language) { enabled language available }
}

Conversations

Types

  • DM — direct conversation between two users
  • Group — multiple participants with admin / member roles
  • Subscription — requires a coin payment to join (isSubscriptionRequired, subscriptionPriceCoins)

Queries

myConversations returns the conversation list sorted by last activity — the primary inbox view. unreadCount on each item powers the badge. lastMessage provides the preview text.

archivedConversations returns conversations the user has archived (hidden from main inbox). lockedConversations returns conversations the user has locked with a passcode.

totalUnreadCount is a global integer for the app-level message badge.

query MyConversations($limit: Int, $offset: Int) {
myConversations(limit: $limit, offset: $offset) {
id type name avatarUrl unreadCount
lastMessage { messageText createdAt }
participants { user { username profilePicture } role }
}
}

query ArchivedConversations { archivedConversations { id name } }
query LockedConversations { lockedConversations { id name } }
query TotalUnread { totalUnreadCount }

Conversation settings

Group conversations support per-conversation settings:

onlyAdminsCanSend restricts posting to admins — useful for announcement channels. disappearingMessages enables auto-deletion for all new messages in the conversation. disappearingDuration sets the TTL in seconds.

mutation UpdateConversationSettings($conversationId: ID!, $settings: ConversationSettingsInput!) {
updateConversationSettings(conversationId: $conversationId, settings: $settings) { id }
}

ConversationSettingsInput fields: onlyAdminsCanSend (restricts posting to admins), allowInviteMembers, disappearingMessages (bool), disappearingDuration (seconds).

Management mutations

createConversation creates a DM or group. addParticipant adds a user to an existing group. updateParticipantRole promotes/demotes between admin and member. transferAdmin hands admin ownership to another participant (the calling admin loses their role).

muteConversation suppresses push notifications for duration minutes (or indefinitely if omitted). archiveConversation hides the conversation from the main inbox. pinConversation keeps it at the top of the list. lockConversation / unlockConversation toggle the passcode lock. blockConversation prevents the other user from sending new messages. clearConversationHistory deletes all messages locally (server may retain them for the other participant). reportConversation files a report against the conversation for admin review. setTypingStatus broadcasts the "is typing" indicator.

mutation CreateConversation($input: ConversationCreateInput!) { createConversation(input: $input) { id } }
mutation AddParticipant($conversationId: ID!, $userId: ID!) { addParticipant(conversationId: $conversationId, userId: $userId) { id role } }
mutation UpdateRole($conversationId: ID!, $participantId: ID!, $role: ParticipantRole!) { updateParticipantRole(conversationId: $conversationId, participantId: $participantId, role: $role) { role } }
mutation TransferAdmin($conversationId: ID!, $newAdminId: ID!) { transferAdmin(conversationId: $conversationId, newAdminId: $newAdminId) }
mutation MuteConversation($id: ID!, $duration: Int) { muteConversation(conversationId: $id, duration: $duration) }
mutation ArchiveConversation($id: ID!) { archiveConversation(conversationId: $id) }
mutation PinConversation($id: ID!) { pinConversation(conversationId: $id) }
mutation LockConversation($id: ID!) { lockConversation(conversationId: $id) }
mutation UnlockConversation($id: ID!) { unlockConversation(conversationId: $id) }
mutation BlockConversation($id: ID!) { blockConversation(conversationId: $id) }
mutation ClearHistory($id: ID!) { clearConversationHistory(conversationId: $id) }
mutation ReportConversation($id: ID!, $input: ConversationReportInput!) { reportConversation(conversationId: $id, input: $input) { status } }
mutation SetTyping($id: ID!, $isTyping: Boolean!) { setTypingStatus(conversationId: $id, isTyping: $isTyping) }

Subscriptions

# Fires when a new conversation is created involving the user (e.g. someone DMs them)
subscription ConversationAdded($userId: ID!) { conversationAdded(userId: $userId) { id type } }

# Fires when a conversation's metadata changes (name, last message, etc.)
subscription ConversationUpdated($conversationId: ID!) { conversationUpdated(conversationId: $conversationId) { id lastMessage { messageText } } }

Creators can lock individual messages behind a coin price. Only image or video message types can be monetized — text-only messages cannot be paid.

lockMessage sets a coin unlockPrice on a message. unlockMessage removes the lock (free access). purchaseMessage deducts coins from the buyer's wallet, grants access, and credits the creator (minus the platform fee). refundMessagePurchase reverses a purchase.

hasMessageAccess is a boolean check for rendering the lock/unlock state. myMessagePurchases lists what the user has unlocked. messagePurchaseStats returns revenue analytics for a specific message. myCreatorEarnings aggregates all paid-message earnings for the authenticated creator.

# Lock a message behind a coin price
mutation LockMessage($input: LockMessageInput!) { lockMessage(input: $input) { id unlockPrice } }

# Remove the lock (make free again)
mutation UnlockMessage($messageId: ID!) { unlockMessage(messageId: $messageId) }

# Purchase access — deducts coins and grants access
mutation PurchaseMessage($input: PurchaseMessageInput!) {
purchaseMessage(input: $input) { id status amount creatorEarningsCoins platformFeeCoins purchasedAt }
}

# Issue a refund (admin or creator)
mutation RefundMessagePurchase($purchaseId: ID!, $reason: String) {
refundMessagePurchase(purchaseId: $purchaseId, reason: $reason) { id status refundedAt }
}

# Check if the caller has already unlocked this message
query HasAccess($messageId: ID!) { hasMessageAccess(messageId: $messageId) }

# Buyer's purchase history
query MyMessagePurchases($limit: Int) { myMessagePurchases(limit: $limit) { id amount status purchasedAt message { id } } }

# Revenue breakdown for a specific message
query MessagePurchaseStats($messageId: ID!) {
messagePurchaseStats(messageId: $messageId) {
totalPurchases totalRevenue totalPlatformFees totalCreatorEarnings averagePurchaseAmount
}
}

# Creator's total earnings across all paid messages
query CreatorEarnings { myCreatorEarnings { totalSales totalRevenue totalEarnings platformFees } }

PurchaseStatus values: pending / completed / refunded / failed. Paid messages must be of type image or video — text-only messages cannot be monetized.

Subscription-gated conversations

A group conversation can require a recurring coin payment to join, independent of the creator subscription system.

enableConversationSubscription adds a coin price requirement to an existing group conversation. disableConversationSubscription removes it (existing subscribers retain access until their period ends).

grantFreeConversationAccess gives a specific user free access — useful for moderators or VIP guests.

subscribeToConversation pays the coin price and grants access. cancelConversationSubscription cancels a subscription — access continues until currentPeriodEnd.

hasConversationAccess is a boolean check — use it before loading the message history to decide whether to show the subscribe prompt. mySubscriptionEarnings aggregates lifetime earnings for the creator. conversationSubscriptionStats returns per-conversation metrics.

# Enable subscription requirement on a group conversation
mutation EnableSub($input: EnableSubscriptionInput!) {
enableConversationSubscription(input: $input) { id isSubscriptionRequired subscriptionPriceCoins }
}

# Remove subscription requirement
mutation DisableSub($conversationId: ID!) {
disableConversationSubscription(conversationId: $conversationId) { id }
}

# Give a specific user free access (e.g. moderators, VIPs)
mutation GrantFreeAccess($conversationId: ID!, $userId: ID!) {
grantFreeConversationAccess(conversationId: $conversationId, userId: $userId) { id isFree status }
}

# Pay the coin price and join the conversation
mutation Subscribe($input: SubscribeToConversationInput!) {
subscribeToConversation(input: $input) {
id coinPrice isFree status
currentPeriodStart currentPeriodEnd nextBillingDate
}
}

# Cancel — access continues until end of current period
mutation CancelSub($subscriptionId: ID!) {
cancelConversationSubscription(subscriptionId: $subscriptionId) { id status cancelledAt }
}

# Check if the caller has access before loading the chat
query HasAccess($conversationId: ID!) { hasConversationAccess(conversationId: $conversationId) }

# Creator's lifetime subscription earnings
query SubEarnings {
mySubscriptionEarnings {
totalConversations totalSubscriptions
totalRevenue totalEarnings platformFees
}
}

# Per-conversation subscription metrics
query SubStats($conversationId: ID!) {
conversationSubscriptionStats(conversationId: $conversationId) {
totalSubscriptions activeSubscriptions cancelledSubscriptions
totalRevenue averagePrice
}
}

SubscriptionStatus values: active / cancelled / expired / pending.