Messages & Conversations — Technical Reference
← Back to Messages & Conversations
Where this lives
Backend
apps/backend/graphql/resolvers/message.resolver.js—sendMessage,conversationMessages, pin/star/react/forward/reply/expire/view-once/live-location/poll/schedule/draft mutations, plus themessageAdded/messageUpdated/messageDeleted/messageRead/messageReactionAdded/typingIndicatorsubscriptionsapps/backend/graphql/resolvers/message-purchase.resolver.js—lockMessage,unlockMessage,purchaseMessage,refundMessagePurchaseapps/backend/graphql/resolvers/conversation.resolver.js—myConversations,createConversation, mute/archive/pin/lock/block,setTypingStatus, conversation settingsapps/backend/graphql/resolvers/conversation-subscription.resolver.js— subscription-gated conversations (enableConversationSubscription,subscribeToConversation, etc.)apps/backend/managers/message-managers/message.manager.js— core message business logicapps/backend/managers/message-managers/conversation.manager.js— conversation lifecycle; also publishes the typing-indicator event viapubsub.service.jsapps/backend/data-access-services/message/message.access-service.js— message DB access (including reactions, stars, pins)apps/backend/graphql/resolvers/message-translation.resolver.js/apps/backend/graphql/types/message-translation.type.js—messageTranslations/myTranslationSettingqueries andsetMessageTranslationmutationapps/backend/managers/message-managers/message-translation.manager.js/apps/backend/services/message-translation.service.js— translation caching/business logic and the provider-agnostic translation engineapps/backend/managers/message-managers/conversation-invite.manager.js— backsconversationInvitePreview/joinConversationViaInvite(both resolved inconversation.resolver.js)apps/backend/services/message-notification.service.js— dispatches push/in-app notifications when a message is sentapps/backend/services/chat-cache.service.js— Redis-backed chat cache used bymessage-notification.service.js- Note:
services/typing-indicator.service.jsexists in the codebase but isn't currently required anywhere — the live "is typing" indicator is actually driven byconversation.manager.jspublishing throughpubsub.service.js, not by that file. Also,message-reaction/message-star/message-viewaccess-services exist but aren't called by any manager; reactions, stars, and pins go throughmessage.access-service.jsdirectly.
Frontend
apps/frontend-nextjs/src/page-components/MessagesPage.tsx— messages page shellapps/frontend-nextjs/src/app/direct/t/[threadId]/page.tsx— individual conversation thread routeapps/frontend-nextjs/src/components/chat/ChatView.tsx— main chat windowapps/frontend-nextjs/src/components/chat/components/MessageList.tsx— message list renderingapps/frontend-nextjs/src/components/chat/components/MessageInputArea.tsx— composer / send inputapps/frontend-nextjs/src/components/chat/ConversationList.tsx— conversation/inbox list
Technical implementation checklist
-
sendMessage/conversationMessages— resolvers wired inmessage.resolver.js;ChatView.tsxsends/receives viacreateConversationwithtype: 'direct' -
setGroupPostingPolicy/setParticipantPostingPermission— group posting permissions; enforced inmessage.manager.js#createMessage;Conversation.postingPolicy/viewerCanSend,ConversationParticipant.canSendMessages(migration20260721100000) -
conversationRemovalPreview(conversationId, userId)+ refund onremoveParticipant— 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 theadd_to_groupprivacy check) -
messagesByMediaType(mediaType: link)— links tab;linkadded to theMessageTypeenum (matches text messages containing a URL). Starred/Pinned shown as separate sections inSharedContentSection.tsx -
createConversation/addParticipant/updateParticipantRole/transferAdmin— resolvers wired inconversation.resolver.js;ConversationList.tsxhas a "Crear grupo" (New group) button that callscreateGroupConversationwithtype: 'group'(this doc previously said no group-creation UI existed — corrected) - Shareable group invite links —
conversationInvitePreview(query) andjoinConversationViaInvite(mutation) inconversation.resolver.js, backed byconversation-invite.manager.js, consumed by a landing page:apps/frontend-nextjs/src/app/join/[token]/page.tsx→JoinConversationPage.tsx, which previews the group (name, avatar, member count) and requires login before joining. -
setTypingStatusmutation +typingIndicatorsubscription — wired inChatView.tsxviauseSubscription(TypingIndicatorDocument)and asetTypingStatuscall -
messageReadReceiptsquery +messageReadsubscription, auto-marked viaconversationManager.markAsRead/messageManager.markConversationMessagesReadwhenconversationMessagesis fetched —useChatMessages.tssubscribes tomessageRead(declared inline asMESSAGE_READ_SUB, since codegen hasn't generated a document for it) and flips the matching message'sstatustoreadin the Apollo cache;MessageBubble.tsxrenders the blue double-check whenstatus === 'read' -
addReaction/removeReaction— resolvers wired inmessage.resolver.js; called fromuseChatActions.ts,MessageContextMenu.tsx,MessageBubble.tsx; themessageReactionAddedsubscription (also declared inline inuseChatMessages.tsas it isn't in the generated operations either) refetchesmessageReactionsfor the affected message so reactions from other participants appear live -
pinMessage/unpinMessage/starMessage/unstarMessage— wired inChatView.tsx,MessageContextMenu.tsx,useChatActions.ts, rendered viaPinnedMessagesSection.tsx -
messageAddedsubscription — consumed inuseChatMessages.ts/ChatView.tsxto append incoming messages live -
isPaid/price(set at send time) +purchaseMessageto unlock — both wired inChatView.tsxand rendered viaPaidMediaModal.tsx/MessageBubble.tsx -
startCall— mutation incall.resolver.js;VoiceCallModal.tsxlaunched fromChatView.tsx -
sendCoinsViaMessage— resolver wired inmessage.resolver.js; called fromChatView.tsx's send handler whenpendingCoinsis set -
createPoll/voteInPoll— wired inChatView.tsxviaPollCreationModal.tsx -
setMessageExpiration— per-message disappearing toggle; wired viauseChatActions.ts#handleSetExpirationand offered fromMessageContextMenu.tsx(sender-only, hidden once a message is already disappearing). Conversation-level disappearing messages (updateConversationSettings→disappearingMessagesEnabled/disappearingMessagesDuration) is the other, separate path described below -
shareLocation— wired viaChatView.tsx#handleShareLocation(browser geolocation → static pin) behind the composer's "Compartir ubicación" action; rendered inMessageBubble.tsxwith a Maps link -
updateLiveLocation/stopLiveLocation— resolvers exist inmessage.resolver.js, but zero frontend references in the chat components; live-location send also still needslocationTypeadded to theLocationInputGraphQL input (currently onlylatitude/longitude/address/name) -
updateConversationSettings(disappearing messages) —disappearingMessages/disappearingDurationmap to the realdisappearingMessagesEnabled/disappearingMessagesDurationcolumns inconversation.manager.js; enabling without an explicit duration defaults to 86400s (24h) -
messageTranslations/myTranslationSetting/setMessageTranslation—message-translation.resolver.js+message-translation.manager.js; results cached per(message_id, target_language)in themessage_translationtable; wired in the chat header (ChatHeader.tsx) and mobile details panel (ConversationDetailsPanel.tsx) viauseMessageTranslations.ts -
@mentions—createMessageparses@usernameand writesMessageMentionrows;userMentions/messageMentions/markMentionAsReadresolvers and theMessageMentionGraphQL 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
| Field | Description |
|---|---|
messageType | Type of message (see enum above) |
replyToMessageId | Quoted message reference |
sharedPostId | Post shared inside the chat |
sharedConversationId | Conversation shared as a link |
sharedUserId | User profile shared in chat |
isPaid / price | Paid message with a price |
unlockPrice | Coin price to unlock locked content |
isDisappearing / disappearsAt | Ephemeral messages |
viewOnce | Recipient can view only once |
isPinned | Pinned in the conversation |
isEdited / editHistory | Full edit history |
poll | Embedded poll |
location / locationType | Static 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/memberroles - 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 } } }
Paid messages
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.