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:
| Key | Action |
|---|---|
| Enter / left mouse | shoot |
| ↑ (cellphone up) | flip front / rear (CELL_CAM_ACTIVATE_SELFIE_MODE 0x2491A93618B7D838) |
| Backspace / right mouse / Esc | exit, 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 "...", neversetr). The server RPCcamera.getUploadTargetcallsGET 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 fieldfile. The client uploads withexports['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 acceptshttpsURLs whose host is inConfig.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 })).
| RPC | Params → 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.
| RPC | Params → 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.
-- 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)| RPC | Params → 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.timeoutseconds. A player can hold at mostmaxPendingPerTargetopen offers. Sender side: the AirDrop row (NearbyRowinphotos/airdrop.tsx, used by Photos, Notes and Files) keeps the offer id from the*.sharereply and shows Waiting… → Accepted / Declined, or No answer onceexpiresAtpassed; 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.