Skip to content

Comms: Phone, Messages, Contacts, Darkchat ​

Files: web/src/apps/{phone,messages,contacts,darkchat}/, server/apps/{comms,calls,contacts,messages,darkchat}.lua, client/apps/{calls,contacts,messages}.lua, shared/config/comms.lua (Config.Comms), sql/comms.sql.

Features ​

  • Phone (dock 0): Favourites, Recents (all/missed, edit/clear, missed badge), Contacts, Keypad (typing works too). Server-side call state machine: ringing → accept / decline / timeout (ringTimeout) / cancel, busy when the callee is in a call, unreachable when offline, in airplane mode or the caller is blocked. Hidden caller ID via settings.hideCallerId or a #31# prefix. Voice via Phone.Voice.startCall/endCall (also ended on drop / character switch), speaker → Phone.Voice.setSpeaker, mute → Phone.Voice.setMuted if a bridge provides it, otherwise UI only. Call pose while dialing/in a call. Full-screen incoming call (Decline / Accept, Remind Me, Message quick replies). Dynamic Island: incoming call card with accept/decline while locked or in another app; during a call a live activity (timer, expand → name + timer, open, red hang-up). Ringtone: receiver hears a frontend sound; nearby players hear a 3D sound via the replicated state bag np_phoneRinging (state bag change handler, no polling). Silent / DND = no ringtone. Ring mode: an incoming call while the phone is put away makes it slide up partly without NUI focus (the phone service in web/src/apps/phone/service.tsx starts it with the client rpc phone.ring, Lua Phone.ring), showing the incoming call screen and key hints next to it. Config.CallKeys = { accept = 'Y', decline = 'BACK' } answers / declines (key mappings np_phone_accept / np_phone_decline, rebindable in GTA settings, false disables a key); accept opens the phone on the call. Ring mode ends on answer, decline, timeout or when the phone is opened. client/apps/calls.lua only re-sends its peek banner (every 12 s) while the phone is closed and not in ring mode (Phone.isRinging() ~= 'call'), e.g. when the NUI couldn't start ring mode. Payphones: near a payphoneModels prop (checked once a second, only while the phone is closed) → "E: use payphone" → opens the keypad in payphone mode; the server checks the player stands at the reported payphone, charges payphoneCost (Phone.Wallet.charge, cash fallback) and the callee sees "Payphone".
  • Messages (dock 1): pinned row, unread dots, search, edit (delete / pin), 1:1 + group chats (create, rename, add/remove members, leave), text, image (from photos.list or a pasted URL), location (tap → waypoint), live location (15/30/60 min; the owner's client sends coords every liveLocationInterval only while sharing, the server relays messages:live), money (Phone.Wallet.transfer, error wallet_unavailable when the city wallet isn't loaded), contact card, voice note playback (no recorder yet). Read receipts (1:1), unread badge, notifications open the thread, delete message / thread for self, blocked senders' messages are hidden for the receiver. Business threads (sys:<label>) are read-only.
  • Contacts (dock 2): CRUD, favourites, colour, search, block list, share contact / my card with the closest player within shareDistance (client finds the player, the server re-checks the distance, the receiver gets an accept prompt in any app).
  • Darkchat (App Store, Config.StoreApps → item vpn, already configured in core): join channels by name, random alias per channel, messages expire after darkchatTTLHours (purged every 10 min). Every RPC re-checks the gate.

Deep links / share intents (openApp('messages', params)): { threadId } or { number } opens the thread; { text? , attachImage? } prefills the composer (text) and attaches an image shown above the input (sent as an image message before the text). With a share intent but no thread / number, the New Message recipient picker opens first. Used by Chirp's "Send in Messages" ({ text }) and the Photos share sheet ({ attachImage }).

RPC methods ​

MethodParams → result
calls.start{ number, payphone? } → call · errors busy, unreachable, airplane, invalid_number, already_in_call, no_payphone, not_enough_money (the payphone is only charged when the call actually rings)
calls.accept / calls.decline / calls.end{ callId }
calls.speaker{ callId, enabled } → { enabled }
calls.mute{ callId, muted } → { muted, voice }
calls.state→ current call or null
calls.recents / calls.deleteRecent {id} / calls.clearRecentscall log (recents marks missed calls seen)
contacts.list / contacts.save {id?, name, number, favourite?, color?, email?, notes?} / contacts.delete {id} / contacts.favourite {id, favourite}contacts
contacts.blocked / contacts.block {number} / contacts.unblock {number}block list
contacts.share {id} (client) → contacts.shareTo {id, target} (server) / contacts.respondShare {shareId, accept}sharing (id = 0 = my card)
messages.threads / messages.unread / messages.open {number} / messages.list {threadId, before?} / messages.read {threadId}threads
messages.send {threadId, type, body?, data?}type: text, image {url}, location {x,y,label}, live {x,y,label,minutes}, money {amount,note}, contact {name,number}, voice {url,duration}. Image / voice URLs must be https on a Config.Media.allowedHosts host. Error slow_down (< 400 ms since the last message)
messages.pin / messages.deleteThread / messages.deleteMessage {messageId} / messages.liveStop {messageId}
messages.createGroup {name, numbers} / messages.rename / messages.addMember / messages.removeMembergroups
darkchat.channels / darkchat.join {name} / darkchat.leave {channelId} / darkchat.messages {channelId} / darkchat.send {channelId, body}darkchat (send: error slow_down within 750 ms)

Push events ​

calls:incoming|outgoing|connected|ended (via client event np_phone:calls:update), calls:payphone {cost} (client), messages:new {threadId, message}, messages:read {threadId, number, lastRead}, messages:live, messages:thread, contacts:shared {shareId, from, contact}, darkchat:new {channelId, message}, phone:badge for phone (missed calls) and messages (unread).

Server API / exports ​

lua
Phone.Contacts.getName(identifier, number) -> string|nil
Phone.Contacts.isBlocked(identifier, number) -> boolean
Phone.Messages.sendSystem(fromLabel, toNumber, text) -> boolean      -- export: sendSystemMessage(fromLabel, toNumber, text)
Phone.Calls.isInCall(src) -> boolean
Phone.Comms.normalizeNumber(input) -> '555-0142' | nil

Consumes (guarded): Phone.Wallet.transfer, Phone.Wallet.charge, Phone.Voice.setMuted (optional), rpc photos.list.

Browser dev ​

?app=phone&tab=recents, ?app=phone&incoming=1 (incoming call, value > 1 = delay in ms), ?call=1 (active call; with ?app=messages the call shows as a Live Activity), ?payphone=1, ?app=messages&thread=1, ?app=contacts&share=1 (share prompt), ?app=darkchat&channel=1. Mock numbers ending in 0000 are unreachable, 1111 busy.

np_* FiveM resources