Skip to content

Media apps: Camera, Photos, Notes, Files ​

Files: web/src/apps/{camera,photos,notes,files}/, server/apps/{media,camera,photos,notes,files}.lua, client/apps/camera.lua, shared/config/media.lua (Config.Media), sql/media.sql. server/apps/media.lua holds the shared helpers (Phone.Media: URL allow-list, nearby players, AirDrop offers).

Camera (dock 3, rose) ​

Opening the app calls the client RPC camera.open. The client checks that screenshot-basic is started and fetches an upload target, closes the phone UI (Phone.close()), and starts the GTA phone camera (CreateMobilePhone + CellCamActivate). The NUI shows a full-screen HUD (web/src/apps/camera/hud.tsx) through the shell's full-screen overlay mode: the app calls os.setFullscreen(true) and renders the HUD into <FullscreenPortal> (a layer over the whole viewport, outside the phone frame). The phone frame is hidden but the app stays mounted, and the close from Phone.close() doesn't auto-lock a passcode phone, so the next open (camera exit) comes back unlocked into the Camera app. Fullscreen ends with setFullscreen(false) on exit or when the app unmounts. The player keeps walking. client/apps/camera.lua reads the keys:

KeyAction
Enter / left mouseshoot
↑ (cellphone up)flip front / rear (CELL_CAM_ACTIVATE_SELFIE_MODE 0x2491A93618B7D838)
Backspace / right mouse / Escexit, then reopen the phone on the home screen

Keys are configurable in Config.Media.camera.keys. There is no zoom, because the phone-camera natives don't support it. The camera also exits if the player dies, gets cuffed or unloads, or opens the phone with its key.

Errors shown in the app or HUD: screenshot_basic_missing, upload_not_configured, upload_failed, storage_full, invalid_url.

Upload security ​

  • FiveManage (recommended): the API key stays on the server (set np_phone:fivemanage_token "...", never setr). The server RPC camera.getUploadTarget calls GET https://api.fivemanage.com/api/v3/file/presigned-url?expiresAt=<now+presignTtl> with the key and returns only the presigned URL (JWT, short-lived) plus the form field file. The client uploads with exports['screenshot-basic']:requestScreenshotUpload(url, 'file', { encoding, quality }, cb). Each target is used once, then the next one is prefetched. The target endpoint is rate-limited to one call per second per player.
  • Saving: the resulting URL goes through photos.add, which only accepts https URLs whose host is in Config.Media.allowedHosts. This stops clients from saving arbitrary links (IP grabbers, NSFW hosts).
  • Discord webhooks can't be presigned. Uploading from the client would expose the webhook URL to the client, and Discord attachment links also expire. It's disabled unless Config.Media.camera.allowDiscordClientUpload = true.

Photos (home 14) ​

Library grid (3 columns, newest first, infinite scroll by cursor), Albums tab (Recents, Favourites, custom albums), full-screen viewer (swipe, thumbnail strip, favourite, delete, info), share sheet: AirDrop to a nearby person, Messages (openApp('messages', { attachImage })), Chirp ({ attachImage }), Notes ({ appendText }), Copy Link, Add to Album, Use as Wallpaper (updateSettings({ wallpaper: url })).

RPCParams → result
photos.list{ cursor?, album? ('favourites' | albumId) } → { items: Photo[], nextCursor }; Photo = { id, url, createdAt, favourite, source }
photos.add{ url, selfie? } → Photo (host allow-list, storage_full above Config.Media.photos.max)
photos.delete{ id } | { ids } → { deleted, ids }
photos.favourite{ id, favourite } → Photo
photos.albums→ { all: {count, cover}, favourites: {count, cover}, albums: {id, name, count, cover}[] }
photos.createAlbum / photos.deleteAlbum{ name } → album / { id } → true
photos.addToAlbum / photos.removeFromAlbum{ albumId, ids }
photos.share{ id, target } → AirDrop offer { id, expiresAt }

Push: photos:new (Photo). Server: Phone.Photos.add(identifierOrSrc, url, meta) → photo | nil, err. Export: exports.np_phone:addPhoto(identifierOrSrc, url, meta) (server callers are trusted, no host check).

Notes (home 16, yellow #e1b52f) ​

Folders screen (All Notes, Notes, custom folders), list grouped by Pinned / Today / Previous 7 Days / …, search, and an editor that autosaves (700 ms debounce, flushed on leave). Empty notes are deleted. The first line is the title. The ⋯ menu has pin, move, copy and delete. Share sends the note to a nearby person or copies the text. openApp('notes', { appendText }) opens a new note.

RPCParams → result
notes.list{ folder? ('all' | 'none' | id), q? } → { id, title, preview, folderId, pinned, updatedAt, createdAt }[]
notes.get{ id } → note + body
notes.save{ id?, body, folderId? } → note (creates without id; error empty)
notes.delete / notes.pin / notes.move{ id } / { id, pinned } / { id, folderId ('none' | id) }
notes.folders→ { all, none, folders: {id, name, count}[] }
notes.createFolder / notes.deleteFolder{ name } / { id } (notes move back to "Notes")
notes.share{ id, target } → offer

Push: notes:new. Server: Phone.Notes.add(identifierOrSrc, body), export addNote.

Files (home 6, olivef) ​

Browse: folders by type (Documents, Contracts, Images, Licences, Receipts), a "needs your signature" banner, and Recents. Documents render as a paper card with letterhead, body, and an issuer stamp. Contracts show both signature lines. Images show the picture. You can share a file with a nearby person, who gets a copy, or delete it.

lua
-- any server script
exports.np_phone:addFile(src or identifier, {
  type = 'document', -- 'document' | 'contract' | 'image' | 'licence' | 'receipt'
  title = 'Property Deed — 3671 Whispymound Dr',
  body = 'This deed certifies ...',   -- optional
  url = 'https://r2.fivemanage.com/...', -- optional (required for 'image')
  issuer = 'Dynasty 8 Real Estate',
  meta = { parcel = 'VH-0219' },     -- optional, returned to the NUI
})
-- two-party contract: src signs by creating it, target gets it with a "Sign" button
local contractId = exports.np_phone:createContract(src, targetSrc, { title = 'Vehicle Bill of Sale', body = '...', meta = { plate = '46EEK572' } })
AddEventHandler('np_phone:files:contractSigned', function(contractId, c)
  -- c = { id, title, partyA, partyB (identifiers), sourceA, sourceB, meta, signedAt }
end)
-- also: 'np_phone:files:contractDeclined' (contractId, { id, partyA, partyB }), exports.np_phone:getContract(id)
RPCParams → result
files.list{ type?, q? } → { id, type, title, issuer, url, createdAt, status? }[]
files.get{ id } → file + body, meta, contract = { id, status, partyA, partyB, signedAt, role, canSign }
files.delete{ id }
files.sign / files.decline{ id } (party B, pending only; conditional update so only one wins) → file
files.share{ id, target } → offer (contract copies can't be signed by the receiver)

Push: files:new, files:update { id, status }. Notifications carry data.fileId, so tapping one opens the file.

AirDrop (nearby share) ​

media.nearby → { target, label, distance }[] (same routing bucket, ≤ Config.Media.share.distance). The label is the sender's contact name for that person, or "Someone nearby"; set share.label = 'name' to show character names. The *.share RPCs re-check the distance and create an offer. The receiver gets the push media:shareOffer and a notification (data.shareId). media.pendingShares { app? } lists open offers. media.respondShare { id, accept } copies the item and notifies the sender (media:shareResult { id, kind, accepted }

  • a notification). Offers expire after share.timeout seconds. A player can hold at most maxPendingPerTarget open offers. Sender side: the AirDrop row (NearbyRow in photos/airdrop.tsx, used by Photos, Notes and Files) keeps the offer id from the *.share reply and shows Waiting… → Accepted / Declined, or No answer once expiresAt passed; tapping a declined / expired person sends again.

Config (shared/config/media.lua) ​

allowedHosts, share { distance, timeout, maxPendingPerTarget, label }, photos { max, pageSize, maxAlbums }, camera { encoding, quality, cooldown, presignEndpoint, presignTtl, allowDiscordClientUpload, phoneType, shutterSound, keys }, notes { max, maxLength, maxFolders }, files { max, maxBody, types }.

Browser dev helpers ​

?app=photos&viewer=1[&share=1], ?app=photos&tab=albums, ?app=photos&airdrop=1 (incoming offer), ?app=camera&shot=1 (HUD with a capture), ?app=camera&camerror=screenshot_basic_missing, ?app=notes&screen=list|editor, ?app=files&doc=<id> (41 deed, 42 contract to sign, 37 signed contract).

Not done ​

  • Music (optional store app) was skipped.
  • Video recording and camera zoom aren't supported by the phone-camera natives used here.

np_* FiveM resources