Social & services apps
Chirp, Yellow Pages, Maps, Calendar, Stocks, Transit (home screen) and News (App Store). Config: shared/config/social.lua (Config.Social). Tables: sql/social.sql (created automatically). Server: server/apps/{social,chirp,yp,maps,calendar,stocks,transit,news}.lua (social.lua holds shared helpers Phone.Social.*: charge/pay with wallet fallback, admin/job checks, URL validation, per-key money locks). Client: client/apps/maps.lua (client RPC maps.distances). Web: web/src/apps/<id>/.
Admin actions (deleting other people's chirps / ads) need the ace np_phone.admin: add_ace group.admin np_phone.admin allow.
Money goes through Phone.Wallet.charge (city team, logs a transaction) when present, otherwise Phone.fw('removeMoney', src, 'bank', …). Payouts (stock sales) use Phone.fw('addMoney', …) plus Phone.Wallet.log.
Chirp (chirp.*, accent #e0423b)
An account is created from the character name on first open. The handle can be changed once. Posts starting with #tip are shown as "Anonymous Tip" (Config.Social.chirp.anonymousTips); the author still sees and can delete them, admins see the real author. Verified badge: manual verified column, or the player's job is in Config.Social.verifiedJobs (refreshed whenever the player uses Chirp).
| RPC | params → result |
|---|---|
chirp.me | → { account, stats, handleLocked, notify, unread, admin, maxLength, maxImages } |
chirp.updateProfile | { displayName?, avatar? ('' clears), bio?, handle? (once), notify? } → { account, handleLocked, notify } |
chirp.profile | { id } or { handle } → { account, stats, following, me } |
chirp.follow | { id, follow } → { following, followers } |
chirp.feed | { tab: 'foryou'|'following'|'trending', cursor? } → { posts, cursor } (cursor = last id; trending uses an offset) |
chirp.userPosts | { id, tab: 'posts'|'replies'|'likes', cursor? } → { posts, cursor } |
chirp.thread | { id } → { post, parent?, replies } |
chirp.hashtag | { tag, cursor? } → { posts, cursor, count } |
chirp.trends | → { tag, count }[] (last trendingHours) |
chirp.search | { q } → { accounts, posts } |
chirp.activity | → { items } (likes, replies, mentions, follows, reposts; marks them read) |
chirp.post | { text ≤ 280, images?: url[] ≤ 4, replyTo? } → post |
chirp.delete | { id } → true (own posts, or admin) |
chirp.like / chirp.repost | { id, value } → { value, count } |
Push: chirp:new (post, sent to every player except the author; the NUI shows a "new Chirps" pill), chirp:deleted { id }, chirp:activity { type, postId }. Notifications (mentions, replies, likes, reposts, follows) via Phone.notify, only when the account's notify setting is on. NUI deep links: openApp('chirp', { postId } | { handle } | { compose } | { attachImage }) (attachImage: Photos share sheet → composer with the image attached). Notification taps carry { postId, handle } and open the post. Images are picked from rpc('photos.list') (media team) or pasted as a URL. Every image URL in Chirp (images, avatar), Yellow Pages (photo) and News (cover) must be https on a Config.Media.allowedHosts host (no IP grabbers in the public feed). Search input longer than 40 bytes is cut, not rejected.
Yellow Pages (yp.*, accent #e1b52f)
| RPC | params → result |
|---|---|
yp.list | { category?, q?, mine? } → { listings, fee, expireHours, categories } |
yp.create | { category, title, price?, description?, photo?, location? } → listing (charges ypPostFee) |
yp.delete | { id } → true (own, or admin; no refund) |
yp.businesses | → { job, label, icon, number, count, open }[] (on-duty players per Config.Social.businesses, cached 15 s) |
Ads expire after ypExpireHours (filtered in queries, expired rows deleted at most every 5 min). Max active ads per player: ypMaxActivePerPlayer. Push: yp:new, yp:deleted. Call / Message open phone / messages with { number }.
Maps (maps.*)
| RPC | params → result |
|---|---|
maps.data | → { places, shared, pois, icons } |
maps.savePlace | { name, icon, x, y, z?, street? } → place (max maxPlaces) |
maps.updatePlace | { id, name?, icon? } → true |
maps.deletePlace / maps.deleteShared | { id } → true |
maps.share | { number, label, x, y, z? } → true (notifies + pushes maps:shared to the receiver; tapping the notification { sharedId } opens the Shared tab with that location's actions) |
maps.distances (client) | { points: {x,y}[] } → { me: {x,y,z,street,zone}, distances: number[] } |
Waypoints use the core client RPC gps.setWaypoint. The map header is a stylised SVG (no game tiles): pins are projected into the bounds x −4000..4500, y −4000..8000 (web/src/apps/maps/geo.ts, reused by Transit).
Calendar (calendar.*, accent #e98a52)
Times are epoch milliseconds. Reminders: one server thread checks every calendarReminderCheck seconds (single indexed query, skipped when nobody is online) and notifies the owner and accepted invitees.
| RPC | params → result |
|---|---|
calendar.list | { from, to } → events (own + accepted invites; own events include invitees) |
calendar.create | { title, startsAt, endsAt?, allDay?, location?, notes?, color?, reminder? (minutes), invite?: numbers[] } → event |
calendar.update | { id, …partial, invite? } → event (owner; false clears optional fields) |
calendar.delete | { id } → true (owner deletes; an invitee leaves) |
calendar.invites | → pending invites { id, status, event, from } |
calendar.respond | { id, accept } → true |
calendar.next | → { title, startsAt, endsAt, time, date, location, allDay } | null (home / lock widget, next 7 days) |
Push: calendar:update { id }, calendar:invite { id, eventId }. Notification taps: { inviteId } opens Invitations, { eventId, startsAt? } (reminders, accepted / declined invites) selects the day and opens the event once loaded. Server API: Phone.Calendar.add(identifier, { title, startsAt, endsAt?, location?, notes?, allDay?, color?, reminder? }) → eventId. Export: exports.np_phone:addCalendarEvent(serverIdOrIdentifier, event).
Stocks (stocks.*)
Prices follow a random walk with mild mean reversion towards base, ticking every stockTickMinutes on the server (latest price + stockHistory points persisted in phone_stocks). Crypto symbols allow 4 decimals.
| RPC | params → result |
|---|---|
stocks.list | → { stocks: { symbol, name, crypto, price, change, changePct, history }[], tickMinutes } |
stocks.portfolio | → { holdings: { symbol, shares, cost, value, avgPrice }[], value, cost, bank } |
stocks.buy | { symbol, shares } → { shares, cost, price, portfolio } (cost rounded up, charged from bank) |
stocks.sell | { symbol, shares } → { shares, proceeds, price, portfolio } (rounded down, paid to bank) |
Push: stocks:tick (summary for every symbol, broadcast).
Transit (transit.*, accent #da4d1e)
Lines come from Config.Social.transit.lines. Departures are computed in the NUI from the in-game clock: trains leave the first stop every headway in-game minutes between firstDeparture and lastDeparture and need travel minutes per stop. The nearest stop uses maps.distances.
| RPC | params → result |
|---|---|
transit.lines | → { lines, ticketPrice, ticketHours, tickets } |
transit.buy | → { item = true } (when ticketItem is set and Phone.fw('addItem') succeeds) or { ticket } (digital) |
News (news.*, App Store app)
Players with a job in Config.Social.newsJobs (or the admin ace) can publish and delete any article. Body format: # / ## headings, - lists, > quotes, **bold**, *italic*, blank-line paragraphs.
| RPC | params → result |
|---|---|
news.list | { cursor? } → { articles (with excerpt), cursor, canPublish } |
news.get | { id } → article with body |
news.publish | { title, body, cover? } → article |
news.delete | { id } → true |
Push: news:new, news:deleted (broadcast with Phone.pushAll). With newsNotifyAll = true every player with a loaded phone gets a "Breaking News" notification (Phone.notifyAll, data = { articleId } opens the article).
Browser dev
Every app has a mock.ts. Screenshot helpers (dev only, ignored in FiveM): ?app=chirp&view=compose|profile|search|activity|thread, ?app=yp&view=detail|post, ?app=calendar&view=new, ?app=stocks&view=detail, ?app=transit&view=line, ?app=news&view=reader.