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,busywhen the callee is in a call,unreachablewhen offline, in airplane mode or the caller is blocked. Hidden caller ID viasettings.hideCallerIdor a#31#prefix. Voice viaPhone.Voice.startCall/endCall(also ended on drop / character switch), speaker →Phone.Voice.setSpeaker, mute →Phone.Voice.setMutedif 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 bagnp_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 inweb/src/apps/phone/service.tsxstarts it with the client rpcphone.ring, LuaPhone.ring), showing the incoming call screen and key hints next to it.Config.CallKeys = { accept = 'Y', decline = 'BACK' }answers / declines (key mappingsnp_phone_accept/np_phone_decline, rebindable in GTA settings,falsedisables 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.luaonly 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 apayphoneModelsprop (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, chargespayphoneCost(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.listor a pasted URL), location (tap → waypoint), live location (15/30/60 min; the owner's client sends coords everyliveLocationIntervalonly while sharing, the server relaysmessages:live), money (Phone.Wallet.transfer, errorwallet_unavailablewhen 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→ itemvpn, already configured in core): join channels by name, random alias per channel, messages expire afterdarkchatTTLHours(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
| Method | Params → 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.clearRecents | call 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.removeMember | groups |
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
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' | nilConsumes (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.