mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 01:38:55 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2388ba9b4e | |||
| c6394fda66 | |||
| 4516469c33 | |||
| 7708ed725b | |||
| 00067543cb | |||
| fd24c8b272 | |||
| aac916efc6 | |||
| 603f4de54e | |||
| ef2d805079 | |||
| 829f8da82a | |||
| 7ca39ef4b9 | |||
| b673b15b97 | |||
| a12e43b624 | |||
| 312ae8a3a3 | |||
| 435320e8fd | |||
| ea959f59f2 | |||
| 8b4a32bfab | |||
| cb6c94a7d8 | |||
| 2059e19e1c | |||
| 99e943dbc5 | |||
| bd9f4c2695 | |||
| 4fa4bf3c78 |
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
fi
|
||||
|
||||
@@ -138,10 +138,10 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
|
||||
| `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata |
|
||||
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
|
||||
|
||||
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
|
||||
|
||||
@@ -216,6 +216,7 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -241,21 +242,31 @@ Config.PhoneConfigurator = {
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates
|
||||
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone
|
||||
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
|
||||
The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
|
||||
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
|
||||
loads its saved values before framework and phone
|
||||
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
|
||||
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
|
||||
applies the new server, client, media, app, item, command, provider, animation, and UI values through
|
||||
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
|
||||
|
||||
Every `Config.*` value from `config.lua`, including server-only sections, and every value from
|
||||
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains
|
||||
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
|
||||
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
|
||||
value from `Config.Media` is discovered automatically. The bootstrap switch and
|
||||
`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
|
||||
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
|
||||
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
|
||||
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
|
||||
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
|
||||
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
|
||||
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
|
||||
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
|
||||
|
||||
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
|
||||
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
|
||||
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
|
||||
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
|
||||
|
||||
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||
shown only as configured and are replaced only when an administrator enters a new value.
|
||||
|
||||
@@ -365,7 +376,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
|
||||
+40
-5
@@ -2,15 +2,49 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const frontendRoot = __dirname
|
||||
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
|
||||
const sourceDirectory = path.join(frontendRoot, 'dist')
|
||||
const targetDirectory = path.join(
|
||||
frontendRoot,
|
||||
'..',
|
||||
'sky_phone',
|
||||
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
|
||||
const configDefaultPath = path.join(
|
||||
resourceDirectory,
|
||||
'source',
|
||||
'html',
|
||||
'shared',
|
||||
'config_default.lua',
|
||||
)
|
||||
|
||||
function readLuaSource(...segments) {
|
||||
return fs
|
||||
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function generateConfigDefault() {
|
||||
const configSource = readLuaSource('config', 'config.lua').replace(
|
||||
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaSource = readLuaSource('config', 'media.lua')
|
||||
const generatedSource = [
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configSource,
|
||||
'',
|
||||
mediaSource,
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
|
||||
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
|
||||
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
throw new Error(
|
||||
'Missing frontend/dist. Run vite build before publishing the NUI.',
|
||||
@@ -28,4 +62,5 @@ const normalizedIndex = fs
|
||||
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
|
||||
fs.writeFileSync(targetIndex, normalizedIndex)
|
||||
|
||||
generateConfigDefault()
|
||||
console.log(`Published NUI to ${targetDirectory}`)
|
||||
|
||||
+12
-1
@@ -530,8 +530,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
@@ -1876,6 +1885,8 @@ onBeforeUnmount(() => {
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
'phone-screen--app': isAppRoute || isDevelopmentRoute,
|
||||
'phone-screen--camera-landscape':
|
||||
activeAppId === 'camera' && phone.cameraLandscape,
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
|
||||
@@ -460,6 +460,7 @@ button {
|
||||
}
|
||||
}
|
||||
.phone-screen {
|
||||
--phone-screen-portrait-ratio: 2.30951;
|
||||
position: relative;
|
||||
container-type: size;
|
||||
margin: auto;
|
||||
@@ -470,6 +471,12 @@ button {
|
||||
background: #08080a;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.phone-screen--camera-landscape {
|
||||
background: transparent;
|
||||
}
|
||||
.phone-screen--camera-landscape .springboard {
|
||||
visibility: hidden;
|
||||
}
|
||||
.phone-app {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
@@ -3239,6 +3246,9 @@ button {
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, border-radius, opacity;
|
||||
}
|
||||
.app-window--camera-landscape {
|
||||
background: transparent;
|
||||
}
|
||||
.app-window--citywarn {
|
||||
backface-visibility: visible;
|
||||
filter: none;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const config = readFileSync(
|
||||
new URL('../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const testServer = readFileSync(
|
||||
new URL('../testserver/index.cjs', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
|
||||
function sourceBlock(source: string, startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker)
|
||||
const end = source.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('Companies emergency request contract', () => {
|
||||
it('ships non-emergency police assistance as a requestable service', () => {
|
||||
const police = sourceBlock(
|
||||
config,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const mockPolice = sourceBlock(
|
||||
testServer,
|
||||
'const companyProfiles = [',
|
||||
' {\n acceptsRequests: false,\n announcement: null,',
|
||||
)
|
||||
|
||||
expect(police).toContain('Emergency = true')
|
||||
expect(police).toContain('AcceptsRequests = true')
|
||||
expect(police).toContain('Id = "police-assistance"')
|
||||
expect(police).toContain('RequestsEnabled = true')
|
||||
expect(mockPolice).toContain('acceptsRequests: true')
|
||||
expect(mockPolice).toContain("name: 'Los Santos Police Department'")
|
||||
expect(mockPolice).toContain("id: 'police-assistance'")
|
||||
})
|
||||
|
||||
it('authorizes configured emergency companies through the normal request gates', () => {
|
||||
const validation = sourceBlock(
|
||||
companiesServer,
|
||||
'local function validate_configuration(configuration)',
|
||||
'local function seed_companies()',
|
||||
)
|
||||
const payload = sourceBlock(
|
||||
companiesServer,
|
||||
'local function company_payload(',
|
||||
'local function public_company(',
|
||||
)
|
||||
const createRequest = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:cancel-request"',
|
||||
)
|
||||
const updateProfile = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:update-profile"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:update-hours"',
|
||||
)
|
||||
|
||||
expect(validation).not.toContain(
|
||||
'definition.Emergency and definition.AcceptsRequests',
|
||||
)
|
||||
expect(payload).toContain(
|
||||
'acceptsRequests = tonumber(row.accepts_requests) == 1,',
|
||||
)
|
||||
expect(payload).not.toContain('not definition.Emergency')
|
||||
expect(createRequest).toContain(
|
||||
'if not definition or not definition.Public then',
|
||||
)
|
||||
expect(createRequest).not.toContain('definition.Emergency')
|
||||
expect(createRequest).toContain('SELECT `accepts_requests`')
|
||||
expect(createRequest).toContain('AND `requests_enabled` = 1')
|
||||
expect(updateProfile).not.toContain('member.definition.Emergency')
|
||||
})
|
||||
|
||||
it('migrates existing requestable emergency profiles exactly once', () => {
|
||||
const migration = sourceBlock(
|
||||
companiesServer,
|
||||
'local function migrate_requestable_emergency_companies()',
|
||||
'local function tombstone_removed_companies()',
|
||||
)
|
||||
const refresh = sourceBlock(
|
||||
companiesServer,
|
||||
'local function refresh_runtime_configuration()',
|
||||
'\n\nrefresh_runtime_configuration()',
|
||||
)
|
||||
|
||||
expect(migration).toContain('sky-phone:companies:requestable-emergency:v1')
|
||||
expect(migration).toContain(
|
||||
'if definition.Emergency and definition.AcceptsRequests then',
|
||||
)
|
||||
expect(migration).toContain('SET `accepts_requests` = 1')
|
||||
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(refresh.indexOf('seed_companies()')).toBeLessThan(
|
||||
refresh.indexOf('migrate_requestable_emergency_companies()'),
|
||||
)
|
||||
expect(
|
||||
refresh.indexOf('migrate_requestable_emergency_companies()'),
|
||||
).toBeLessThan(refresh.indexOf('tombstone_removed_companies()'))
|
||||
})
|
||||
|
||||
it('migrates the existing Phone Configurator police defaults', () => {
|
||||
const migration = sourceBlock(
|
||||
configuratorServer,
|
||||
'local function migrate_police_request_defaults()',
|
||||
'\n\ndefault_config = {}',
|
||||
)
|
||||
|
||||
expect(migration).toContain('sky-phone:configurator:police-requests:v1')
|
||||
expect(migration).toContain('police.AcceptsRequests == false')
|
||||
expect(migration).toContain('next(police.Services) == nil')
|
||||
expect(migration).toContain(
|
||||
'police.AcceptsRequests = defaults.AcceptsRequests',
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'police.Services = copy_value(defaults.Services)',
|
||||
)
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(configuratorServer).toContain(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
)
|
||||
}
|
||||
|
||||
function sourceBlock(
|
||||
value: string,
|
||||
startMarker: string,
|
||||
endMarker: string,
|
||||
): string {
|
||||
const start = value.indexOf(startMarker)
|
||||
const end = value.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return value.slice(start, end)
|
||||
}
|
||||
|
||||
const companiesServer = source('../../sky_phone/source/server/companies.lua')
|
||||
const messagesServer = source('../../sky_phone/source/server/messages.lua')
|
||||
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
|
||||
const configDefaults = source(
|
||||
'../../sky_phone/source/shared/config_default.lua',
|
||||
)
|
||||
const messagesApp = source('./views/apps/MessagesApp.vue')
|
||||
const phoneFallbacks = source('./stores/phone.ts')
|
||||
const locales = ['en', 'de', 'es'].map((locale) =>
|
||||
source(`../../sky_phone/config/locales/${locale}.lua`),
|
||||
)
|
||||
|
||||
describe('Companies service-line message routing contract', () => {
|
||||
it('enables the police service line and persists an indexed route channel', () => {
|
||||
const police = sourceBlock(
|
||||
configDefaults,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const schema = sourceBlock(
|
||||
databaseMigration,
|
||||
' name = "sky_phone_company_requests",',
|
||||
' name = "sky_phone_company_request_reads",',
|
||||
)
|
||||
|
||||
expect(police).toContain('Number = "911"')
|
||||
expect(police).toContain('CanMessage = true')
|
||||
expect(schema).toContain(
|
||||
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
|
||||
)
|
||||
expect(schema).toContain(
|
||||
'name = "idx_sky_phone_company_requests_service_line"',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves short service numbers before the normal SIM-number path', () => {
|
||||
const resolver = sourceBlock(
|
||||
messagesServer,
|
||||
'local function resolve_message_recipient(value)',
|
||||
'\n\nlocal function shared_contact',
|
||||
)
|
||||
const send = sourceBlock(
|
||||
messagesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:messages:send"',
|
||||
'\n\nend)',
|
||||
)
|
||||
|
||||
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
|
||||
expect(resolver).toContain('return service_line.number, service_line')
|
||||
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
|
||||
expect(send).toContain('service_line_text_only')
|
||||
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
|
||||
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
|
||||
send.indexOf('SELECT s.`id`, s.`phone_number`'),
|
||||
)
|
||||
})
|
||||
|
||||
it('atomically mirrors inbound SMS into one active company request', () => {
|
||||
const route = sourceBlock(
|
||||
companiesServer,
|
||||
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
)
|
||||
|
||||
expect(route).toContain('current_device(source, false)')
|
||||
expect(route).toContain('service_line.canMessage')
|
||||
expect(route).toContain('UPDATE `sky_phone_sims`')
|
||||
expect(route).toContain("`channel` = 'service_line'")
|
||||
expect(route).toContain('AND NOT EXISTS (')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
|
||||
expect(route).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(route).toContain('emit_request_change(row, true, true, source)')
|
||||
expect(route).toContain('"sky_phone:companies:notification"')
|
||||
})
|
||||
|
||||
it('mirrors authorized company replies back to the customer SMS thread', () => {
|
||||
const sendMessage = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
|
||||
)
|
||||
|
||||
expect(sendMessage).toContain('access.row.channel == "service_line"')
|
||||
expect(
|
||||
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
|
||||
).toBe(2)
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'company'")
|
||||
expect(sendMessage).toContain('"sky_phone:messages:changed"')
|
||||
expect(sendMessage).toContain('"sky_phone:messages:new"')
|
||||
})
|
||||
|
||||
it('keeps service-line composition text-only in every shipped locale', () => {
|
||||
expect(messagesApp).toContain(
|
||||
"() => activeContact.value?.source === 'company'",
|
||||
)
|
||||
expect(messagesApp).toContain(
|
||||
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
|
||||
)
|
||||
expect(messagesApp).toContain('v-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain("'service_line_text_only'")
|
||||
expect(phoneFallbacks).toContain('service_line_text_only:')
|
||||
for (const locale of locales) {
|
||||
expect(locale).toContain('service_line_text_only =')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const companiesServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const englishLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const germanLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const spanishLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/es.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('company configurator creation contract', () => {
|
||||
it('publishes complete defaults for newly added company definitions', () => {
|
||||
expect(configuratorServer).toContain(
|
||||
'local function company_definition_entry_default(company_id, configuration)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'local function next_available_company_service_number(configuration)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'entryDefault = company_definition_entry_default()',
|
||||
)
|
||||
expect(configuratorServer).toContain('Routing = "round_robin"')
|
||||
expect(configuratorServer).toContain('RequestsEnabled = true')
|
||||
})
|
||||
|
||||
it('validates the candidate runtime config before encoding or writing SQL', () => {
|
||||
const save = configuratorServer.slice(
|
||||
configuratorServer.indexOf('function SkyPhoneConfigurator.Save('),
|
||||
)
|
||||
const validation = save.indexOf(
|
||||
'SkyPhoneCompanies.ValidateConfiguration(candidate_config)',
|
||||
)
|
||||
const encoding = save.indexOf(
|
||||
'local config_encoded = encode_payload(next_config, "config")',
|
||||
)
|
||||
const update = save.indexOf('UPDATE `%s`')
|
||||
|
||||
expect(validation).toBeGreaterThanOrEqual(0)
|
||||
expect(validation).toBeLessThan(encoding)
|
||||
expect(validation).toBeLessThan(update)
|
||||
expect(save).toContain(
|
||||
'return { success = false, error = "invalid_company_configuration" }',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates into isolated registries before replacing the live company state', () => {
|
||||
expect(companiesServer).toContain(
|
||||
'function SkyPhoneCompanies.ValidateConfiguration(configuration)',
|
||||
)
|
||||
expect(companiesServer).toContain(
|
||||
'local validated, validation_error = validate_configuration(configuration)',
|
||||
)
|
||||
expect(companiesServer).toContain('definitions = validated.definitions')
|
||||
expect(companiesServer).toContain(
|
||||
'definition_ids = validated.definition_ids',
|
||||
)
|
||||
})
|
||||
|
||||
it('localizes rejected company configurations in every shipped locale', () => {
|
||||
for (const locale of [englishLocale, germanLocale, spanishLocale]) {
|
||||
expect(locale).toContain('invalid_company_configuration =')
|
||||
}
|
||||
})
|
||||
|
||||
it('repairs company rows created by the legacy blank schema before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_blank_company_definitions()',
|
||||
),
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_request_defaults()',
|
||||
),
|
||||
)
|
||||
const blankRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)',
|
||||
)
|
||||
const policeRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:company-definition-defaults:v1',
|
||||
)
|
||||
expect(migration).toContain('company_definition_entry_default(')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(blankRegistration).toBeGreaterThanOrEqual(0)
|
||||
expect(blankRegistration).toBeLessThan(policeRegistration)
|
||||
})
|
||||
|
||||
it('enables persisted police service-line messaging before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_service_line_messaging()',
|
||||
),
|
||||
configuratorServer.indexOf('\n\ndefault_config = {}'),
|
||||
)
|
||||
const policeRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
const messageRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:service-line-messaging:v2',
|
||||
)
|
||||
expect(migration).toContain('line.CanMessage ~= true')
|
||||
expect(migration).toContain('line.CanMessage = true')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(messageRegistration).toBeGreaterThan(policeRegistration)
|
||||
expect(companiesServer).not.toContain(
|
||||
'enables messaging without a virtual service-line message router',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,10 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { vConfigInputWidth } from '@/directives/configInputWidth'
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
import {
|
||||
blankFromConfiguratorStructure,
|
||||
createMutableTableEntry,
|
||||
} from '@/utils/adminConfiguratorDefaults'
|
||||
import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription'
|
||||
|
||||
export type AdminConfigEditorLabels = {
|
||||
@@ -342,40 +346,6 @@ function mapValuePath(entry: SerializedMapEntry): string {
|
||||
return props.path ? `${props.path}.${entry.key}` : String(entry.key)
|
||||
}
|
||||
|
||||
function blankFromStructure(structure: AdminConfiguratorStructure): unknown {
|
||||
if (structure.kind === 'optionalString') return ''
|
||||
if (structure.kind === 'value') return blankValue(structure.valueType)
|
||||
if (structure.kind === 'vector') {
|
||||
const axes = ['x', 'y', 'z', 'w'].slice(
|
||||
0,
|
||||
Number(structure.vectorType.slice(-1)),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
['__skyType', structure.vectorType],
|
||||
...axes.map((axis) => [axis, 0]),
|
||||
])
|
||||
}
|
||||
if (structure.kind === 'list') {
|
||||
return structure.items.map(blankFromStructure)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: structure.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: blankFromStructure(entry.structure),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(structure.fields).map(([key, field]) => [
|
||||
key,
|
||||
blankFromStructure(field),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function updateScalar(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
@@ -413,7 +383,7 @@ function addListRow(): void {
|
||||
const value = rows.length
|
||||
? blankLike(rows[0])
|
||||
: listTemplate.value
|
||||
? blankFromStructure(listTemplate.value)
|
||||
? blankFromConfiguratorStructure(listTemplate.value)
|
||||
: blankValue(newArrayKind.value)
|
||||
rows.push(value)
|
||||
emit('update:modelValue', rows)
|
||||
@@ -476,12 +446,21 @@ function addTableField(): void {
|
||||
const template = tableStructure.value?.mutableKeys
|
||||
? tableStructure.value.template
|
||||
: undefined
|
||||
const value = template
|
||||
? blankFromStructure(template)
|
||||
: blankCollectionValue(
|
||||
newObjectKind.value,
|
||||
Object.values(tableValue.value).filter((_, index) => index < 50),
|
||||
const structuredValue = tableStructure.value?.mutableKeys
|
||||
? createMutableTableEntry(
|
||||
tableStructure.value,
|
||||
props.path,
|
||||
key,
|
||||
tableValue.value,
|
||||
)
|
||||
: undefined
|
||||
const value =
|
||||
structuredValue !== undefined
|
||||
? structuredValue
|
||||
: blankCollectionValue(
|
||||
newObjectKind.value,
|
||||
Object.values(tableValue.value).filter((_, index) => index < 50),
|
||||
)
|
||||
emit('update:modelValue', {
|
||||
...tableValue.value,
|
||||
[key]: value,
|
||||
@@ -556,7 +535,7 @@ function addMapEntry(): void {
|
||||
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
|
||||
const key = parsedNewMapKey.value
|
||||
const value = mapTemplate.value
|
||||
? blankFromStructure(mapTemplate.value)
|
||||
? blankFromConfiguratorStructure(mapTemplate.value)
|
||||
: blankCollectionValue(
|
||||
newMapValueKind.value,
|
||||
mapEntries.value.slice(0, 50).map((entry) => entry.value),
|
||||
|
||||
@@ -54,6 +54,14 @@ const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configDefaultsSource = config.replace(
|
||||
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
@@ -69,6 +77,17 @@ const configuratorServer = readFileSync(
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const configDefault = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/shared/config_default.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const frontendBuild = readFileSync(
|
||||
new URL('../../build.cjs', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorClient = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||
@@ -133,24 +152,23 @@ describe('standalone admin panel contracts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).toContain("'--admin-accent': accentColor")
|
||||
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||
expect(source).toContain(
|
||||
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-color-value"')
|
||||
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||
expect(source).toContain('class="admin-panel-font-select"')
|
||||
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||
expect(source).not.toContain('type="color"')
|
||||
expect(source).not.toContain("'sky-phone-admin-accent'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).not.toContain("t('appearance.")
|
||||
expect(source).toContain('--admin-accent: #00b8e4')
|
||||
expect(source).toContain('class="admin-panel-statistics-layout"')
|
||||
expect(source).toContain('class="admin-panel-activity-grid"')
|
||||
expect(source).toContain('class="admin-panel-coverage-list"')
|
||||
expect(source).toContain('statisticsTimer = window.setInterval')
|
||||
expect(source).toContain('15_000')
|
||||
expect(server).toContain('AS `messages_today`')
|
||||
expect(server).toContain('AS `calls_today`')
|
||||
expect(server).toContain('AS `active_devices`')
|
||||
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||
@@ -170,6 +188,22 @@ describe('standalone admin panel contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies global bundled-app availability to phones and admin management', () => {
|
||||
expect(config).toContain('Config.Apps = {')
|
||||
expect(config).toContain('memory = true,')
|
||||
expect(config).toContain('weather = true,')
|
||||
expect(configDefault).toContain('Config.Apps = {')
|
||||
expect(configuratorServer).toContain('Apps = true,')
|
||||
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
|
||||
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
|
||||
expect(phoneServer).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
|
||||
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(store).toContain('disabledApps: [] as string[]')
|
||||
expect(store).toContain('disabledAppsFromConfigurator(response.data)')
|
||||
expect(source).toContain('!admin.disabledApps.includes(app.id)')
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
@@ -216,12 +250,14 @@ describe('standalone admin panel contracts', () => {
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||
'Bridge.Framework.HasPermission(source, "phonepanel")',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
@@ -272,6 +308,12 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/shared/config_default.lua'"),
|
||||
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
|
||||
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||
@@ -281,11 +323,36 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||
expect(configuratorServer).toContain('configurator_enabled')
|
||||
expect(configuratorServer).toMatch(
|
||||
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||
/for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'default_media = serialize_value(Config.Media)',
|
||||
'default_media = serialize_value(ConfigDefaults.Media)',
|
||||
)
|
||||
expect(configDefault).toContain(
|
||||
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
|
||||
)
|
||||
expect(configDefault).toContain('local realConfig = Config')
|
||||
expect(configDefault).toContain('ConfigDefaults = Config')
|
||||
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
|
||||
[
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
|
||||
'',
|
||||
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
].join('\n'),
|
||||
)
|
||||
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
|
||||
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
|
||||
expect(frontendBuild).toContain("'config_default.lua'")
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configuratorServer).toContain(
|
||||
'build_sections("config", stored_config',
|
||||
)
|
||||
@@ -430,7 +497,8 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||
expect(configuratorValueEditor).toContain('function blankFromStructure(')
|
||||
expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure')
|
||||
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
|
||||
expect(source).toContain("'is-structured': field.type === 'json'")
|
||||
expect(configuratorValueEditor).toContain('function isStructuredValue(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChartNoAxesCombined,
|
||||
BadgeDollarSign,
|
||||
BriefcaseBusiness,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Clipboard,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
PhoneCall,
|
||||
PhoneForwarded,
|
||||
Save,
|
||||
@@ -81,17 +80,6 @@ type AdminTab =
|
||||
type ConfiguratorScope = 'config' | 'media'
|
||||
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
|
||||
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
|
||||
type AccentChannel = 'blue' | 'green' | 'red'
|
||||
type AdminFontFamily =
|
||||
| 'classic'
|
||||
| 'georgia'
|
||||
| 'inter'
|
||||
| 'mono'
|
||||
| 'system'
|
||||
| 'tahoma'
|
||||
| 'trebuchet'
|
||||
| 'verdana'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const admin = useAdminStore()
|
||||
const phone = usePhoneStore()
|
||||
@@ -109,61 +97,11 @@ const revealDialogImei = ref('')
|
||||
const deviceAction = ref<DeviceAction | null>(null)
|
||||
const deviceActionInput = ref('')
|
||||
const discardDialog = ref(false)
|
||||
const fontMenuOpen = ref(false)
|
||||
const pendingAction = ref<PendingAction | null>(null)
|
||||
const toast = ref('')
|
||||
const toastTone = ref<'error' | 'success'>('success')
|
||||
let toastTimer: number | undefined
|
||||
|
||||
const accentOptions = [
|
||||
{ color: '#74d66f', key: 'emerald' },
|
||||
{ color: '#4f9cff', key: 'blue' },
|
||||
{ color: '#a875ff', key: 'violet' },
|
||||
{ color: '#f0a24b', key: 'orange' },
|
||||
{ color: '#ef6969', key: 'red' },
|
||||
] as const
|
||||
const accentChannels = ['red', 'green', 'blue'] as const
|
||||
const fontFamilyOptions: Array<{
|
||||
key: AdminFontFamily
|
||||
value: string
|
||||
}> = [
|
||||
{ key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' },
|
||||
{
|
||||
key: 'system',
|
||||
value:
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
},
|
||||
{ key: 'classic', value: 'Arial, Helvetica, sans-serif' },
|
||||
{ key: 'verdana', value: 'Verdana, Geneva, sans-serif' },
|
||||
{ key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' },
|
||||
{
|
||||
key: 'trebuchet',
|
||||
value: '"Trebuchet MS", "Lucida Grande", sans-serif',
|
||||
},
|
||||
{ key: 'georgia', value: 'Georgia, "Times New Roman", serif' },
|
||||
{
|
||||
key: 'mono',
|
||||
value:
|
||||
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
]
|
||||
const accentColor = ref('#74d66f')
|
||||
const adminFontFamily = ref<AdminFontFamily>('inter')
|
||||
const adminFontSize = ref(100)
|
||||
const adminFontSizePreview = ref(100)
|
||||
const activeAdminFontFamily = computed(
|
||||
() =>
|
||||
fontFamilyOptions.find((option) => option.key === adminFontFamily.value)
|
||||
?.value ?? fontFamilyOptions[0].value,
|
||||
)
|
||||
const accentRgb = computed(() => {
|
||||
const color = accentColor.value.replace('#', '')
|
||||
return {
|
||||
red: Number.parseInt(color.slice(0, 2), 16),
|
||||
green: Number.parseInt(color.slice(2, 4), 16),
|
||||
blue: Number.parseInt(color.slice(4, 6), 16),
|
||||
}
|
||||
})
|
||||
let statisticsTimer: number | undefined
|
||||
|
||||
const filteredPlayers = computed(() => {
|
||||
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
@@ -196,7 +134,9 @@ const manageableApps = computed(() => {
|
||||
const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
return PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && !app.adminOnly,
|
||||
isLaunchablePhoneApp(app) &&
|
||||
!app.adminOnly &&
|
||||
!admin.disabledApps.includes(app.id),
|
||||
)
|
||||
.filter(
|
||||
(app) =>
|
||||
@@ -595,7 +535,9 @@ function queueAction(action: PendingAction): void {
|
||||
|
||||
function selectTab(nextTab: AdminTab): void {
|
||||
tab.value = nextTab
|
||||
fontMenuOpen.value = false
|
||||
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
|
||||
void admin.load()
|
||||
}
|
||||
if (nextTab === 'configurator' && !admin.configurator) {
|
||||
void admin.loadConfigurator().then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
@@ -603,61 +545,13 @@ function selectTab(nextTab: AdminTab): void {
|
||||
}
|
||||
}
|
||||
|
||||
function selectAccent(color: string): void {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return
|
||||
accentColor.value = color.toLowerCase()
|
||||
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
|
||||
function formatStatistic(value: number): string {
|
||||
return value.toLocaleString(phone.lang)
|
||||
}
|
||||
|
||||
function updateAccent(event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value)
|
||||
else input.value = accentColor.value.toUpperCase()
|
||||
}
|
||||
|
||||
function updateAccentChannel(channel: AccentChannel, event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const value = Math.min(255, Math.max(0, Number(input.value) || 0))
|
||||
const channels = { ...accentRgb.value, [channel]: value }
|
||||
selectAccent(
|
||||
`#${channels.red.toString(16).padStart(2, '0')}${channels.green
|
||||
.toString(16)
|
||||
.padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`,
|
||||
)
|
||||
}
|
||||
|
||||
function accentChannelGradient(channel: AccentChannel): string {
|
||||
const { red, green, blue } = accentRgb.value
|
||||
if (channel === 'red') {
|
||||
return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))`
|
||||
}
|
||||
if (channel === 'green') {
|
||||
return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))`
|
||||
}
|
||||
return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))`
|
||||
}
|
||||
|
||||
function selectAdminFontFamily(family: AdminFontFamily): void {
|
||||
adminFontFamily.value = family
|
||||
fontMenuOpen.value = false
|
||||
window.localStorage.setItem('sky-phone-admin-font-family', family)
|
||||
}
|
||||
|
||||
function closeFontMenu(event: FocusEvent): void {
|
||||
const menu = event.currentTarget as HTMLElement
|
||||
if (!menu.contains(event.relatedTarget as Node | null)) {
|
||||
fontMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdminFontSize(event: Event): void {
|
||||
const size = Number((event.currentTarget as HTMLInputElement).value)
|
||||
adminFontSize.value = Math.min(115, Math.max(90, size))
|
||||
adminFontSizePreview.value = adminFontSize.value
|
||||
window.localStorage.setItem(
|
||||
'sky-phone-admin-font-size',
|
||||
String(adminFontSize.value),
|
||||
)
|
||||
function statisticPercentage(value: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.min(100, Math.round((value / total) * 100))
|
||||
}
|
||||
|
||||
async function runAction(action: PendingAction): Promise<void> {
|
||||
@@ -918,10 +812,6 @@ function auditDescription(entry: AdminAuditEntry): string {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
if (fontMenuOpen.value) {
|
||||
fontMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
if (revealDialogImei.value) {
|
||||
revealDialogImei.value = ''
|
||||
return
|
||||
@@ -941,46 +831,21 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
const storedAccent = window.localStorage.getItem('sky-phone-admin-accent')
|
||||
if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) {
|
||||
accentColor.value = storedAccent.toLowerCase()
|
||||
}
|
||||
const storedFontFamily = window.localStorage.getItem(
|
||||
'sky-phone-admin-font-family',
|
||||
)
|
||||
if (
|
||||
storedFontFamily &&
|
||||
fontFamilyOptions.some((option) => option.key === storedFontFamily)
|
||||
) {
|
||||
adminFontFamily.value = storedFontFamily as AdminFontFamily
|
||||
}
|
||||
const storedFontSize = Number(
|
||||
window.localStorage.getItem('sky-phone-admin-font-size'),
|
||||
)
|
||||
if (storedFontSize >= 90 && storedFontSize <= 115) {
|
||||
adminFontSize.value = storedFontSize
|
||||
adminFontSizePreview.value = storedFontSize
|
||||
}
|
||||
void refreshData()
|
||||
statisticsTimer = window.setInterval(() => {
|
||||
if (tab.value === 'overview' && !admin.loading) void admin.load()
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
if (statisticsTimer) window.clearInterval(statisticsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-panel-overlay"
|
||||
role="dialog"
|
||||
:aria-label="t('name')"
|
||||
:style="{
|
||||
'--admin-accent': accentColor,
|
||||
'--admin-font-family': activeAdminFontFamily,
|
||||
'--admin-font-scale': String(adminFontSize / 100),
|
||||
}"
|
||||
>
|
||||
<div class="admin-panel-overlay" role="dialog" :aria-label="t('name')">
|
||||
<div class="admin-panel-window">
|
||||
<header class="admin-panel-header">
|
||||
<div class="admin-panel-brand">
|
||||
@@ -1384,22 +1249,22 @@ onBeforeUnmount(() => {
|
||||
<article>
|
||||
<UsersRound :size="18" />
|
||||
<span>{{ t('overview.online') }}</span>
|
||||
<strong>{{ admin.stats.online }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.online) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<Smartphone :size="18" />
|
||||
<span>{{ t('overview.devices') }}</span>
|
||||
<strong>{{ admin.stats.devices }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.devices) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<Database :size="18" />
|
||||
<span>{{ t('overview.accounts') }}</span>
|
||||
<strong>{{ admin.stats.accounts }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.accounts) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<ScrollText :size="18" />
|
||||
<span>{{ t('overview.audit') }}</span>
|
||||
<strong>{{ admin.audit.length }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.auditEntries) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1408,146 +1273,100 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="admin-panel-section-card__heading">
|
||||
<div>
|
||||
<span>{{ t('appearance.eyebrow') }}</span>
|
||||
<h2>{{ t('appearance.title') }}</h2>
|
||||
<p>{{ t('appearance.body') }}</p>
|
||||
<span>{{ t('statistics.eyebrow') }}</span>
|
||||
<h2>{{ t('statistics.title') }}</h2>
|
||||
<p>{{ t('statistics.body') }}</p>
|
||||
</div>
|
||||
<Palette :size="20" />
|
||||
<ChartNoAxesCombined :size="20" />
|
||||
</div>
|
||||
<div class="admin-panel-accent-picker">
|
||||
<button
|
||||
v-for="option in accentOptions"
|
||||
:key="option.color"
|
||||
type="button"
|
||||
:class="{ 'is-active': accentColor === option.color }"
|
||||
:aria-label="t('appearance.colors.' + option.key)"
|
||||
:title="t('appearance.colors.' + option.key)"
|
||||
@click="selectAccent(option.color)"
|
||||
>
|
||||
<span :style="{ backgroundColor: option.color }"></span>
|
||||
<strong>{{ t('appearance.colors.' + option.key) }}</strong>
|
||||
<Check v-if="accentColor === option.color" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-panel-appearance-controls">
|
||||
<section class="admin-panel-appearance-control is-color">
|
||||
<div>
|
||||
<strong>{{ t('appearance.controls.customColor') }}</strong>
|
||||
<small>{{
|
||||
t('appearance.controls.customColorBody')
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="admin-panel-rgb-picker">
|
||||
<label class="admin-panel-color-value">
|
||||
<span :style="{ backgroundColor: accentColor }"></span>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="text"
|
||||
maxlength="7"
|
||||
:value="accentColor.toUpperCase()"
|
||||
:aria-label="t('appearance.controls.hex')"
|
||||
@change="updateAccent"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-rgb-fields">
|
||||
<label v-for="channel in accentChannels" :key="channel">
|
||||
<span>{{ t(`appearance.controls.${channel}`) }}</span>
|
||||
<input
|
||||
class="admin-panel-rgb-range"
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:style="{
|
||||
background: accentChannelGradient(channel),
|
||||
}"
|
||||
:aria-label="t(`appearance.controls.${channel}`)"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:aria-label="`${t(`appearance.controls.${channel}`)} ${t('appearance.controls.value')}`"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-statistics-layout">
|
||||
<section class="admin-panel-activity-statistics">
|
||||
<div class="admin-panel-statistics-heading">
|
||||
<div>
|
||||
<strong>{{ t('statistics.today') }}</strong>
|
||||
<small>{{ t('statistics.todayBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel-activity-grid">
|
||||
<article>
|
||||
<MessageSquare :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.messagesToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.messagesToday') }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<PhoneCall :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.callsToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.callsToday') }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<ScrollText :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.auditToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.auditToday') }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontFamily') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontFamilyBody') }}</small>
|
||||
</span>
|
||||
<div
|
||||
class="admin-panel-font-select"
|
||||
@focusout="closeFontMenu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="fontMenuOpen"
|
||||
@click="fontMenuOpen = !fontMenuOpen"
|
||||
<section class="admin-panel-coverage-statistics">
|
||||
<div class="admin-panel-statistics-heading">
|
||||
<div>
|
||||
<strong>{{ t('statistics.coverage') }}</strong>
|
||||
<small>{{ t('statistics.coverageBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel-coverage-list">
|
||||
<article
|
||||
v-for="metric in [
|
||||
{
|
||||
key: 'linkedDevices',
|
||||
value: admin.stats.linkedDevices,
|
||||
},
|
||||
{
|
||||
key: 'simDevices',
|
||||
value: admin.stats.simDevices,
|
||||
},
|
||||
{
|
||||
key: 'activeDevices',
|
||||
value: admin.stats.activeDevices,
|
||||
},
|
||||
]"
|
||||
:key="metric.key"
|
||||
>
|
||||
<span :style="{ fontFamily: activeAdminFontFamily }">
|
||||
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }}
|
||||
</span>
|
||||
<ChevronDown :size="14" />
|
||||
</button>
|
||||
<div
|
||||
v-if="fontMenuOpen"
|
||||
class="admin-panel-font-select__menu"
|
||||
role="listbox"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
>
|
||||
<button
|
||||
v-for="option in fontFamilyOptions"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="adminFontFamily === option.key"
|
||||
:class="{ 'is-active': adminFontFamily === option.key }"
|
||||
:style="{ fontFamily: option.value }"
|
||||
@click="selectAdminFontFamily(option.key)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ t(`statistics.${metric.key}`) }}</strong>
|
||||
<span>{{
|
||||
t(`appearance.controls.fonts.${option.key}`)
|
||||
t('statistics.ofDevices', {
|
||||
count: formatStatistic(metric.value),
|
||||
total: formatStatistic(admin.stats.devices),
|
||||
})
|
||||
}}</span>
|
||||
<Check
|
||||
v-if="adminFontFamily === option.key"
|
||||
:size="13"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="admin-panel-statistics-progress"
|
||||
role="progressbar"
|
||||
:aria-label="t(`statistics.${metric.key}`)"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="100"
|
||||
:aria-valuenow="
|
||||
statisticPercentage(metric.value, admin.stats.devices)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: `${statisticPercentage(metric.value, admin.stats.devices)}%`,
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontSize') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontSizeBody') }}</small>
|
||||
</span>
|
||||
<div class="admin-panel-font-size-control">
|
||||
<input
|
||||
v-model.number="adminFontSizePreview"
|
||||
type="range"
|
||||
min="90"
|
||||
max="115"
|
||||
step="5"
|
||||
:aria-label="t('appearance.controls.fontSize')"
|
||||
@change="updateAdminFontSize"
|
||||
/>
|
||||
<strong>{{ adminFontSizePreview }}%</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -2488,7 +2307,8 @@ onBeforeUnmount(() => {
|
||||
--admin-text: #f0f3f0;
|
||||
--admin-muted: #818781;
|
||||
--admin-dim: #555b55;
|
||||
--admin-green: var(--admin-accent, #74d66f);
|
||||
--admin-accent: #00b8e4;
|
||||
--admin-green: var(--admin-accent);
|
||||
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
|
||||
--admin-toggle-on: #63d471;
|
||||
--admin-row-hover: linear-gradient(
|
||||
@@ -2512,10 +2332,7 @@ onBeforeUnmount(() => {
|
||||
padding: 2.5vh 2.5vw;
|
||||
color: var(--admin-text);
|
||||
background: transparent;
|
||||
font-family: var(
|
||||
--admin-font-family,
|
||||
var(--sky-font-family, Inter, sans-serif)
|
||||
);
|
||||
font-family: var(--sky-font-family, Inter, sans-serif);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -2529,7 +2346,6 @@ onBeforeUnmount(() => {
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.72),
|
||||
inset 0 1px rgba(255, 255, 255, 0.018);
|
||||
zoom: var(--admin-font-scale, 1);
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
@@ -2653,15 +2469,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready {
|
||||
color: #74d66f;
|
||||
color: var(--admin-green);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 5px rgba(116, 214, 111, 0.42));
|
||||
filter: drop-shadow(
|
||||
0 0 5px color-mix(in srgb, var(--admin-green) 42%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready:hover:not(:disabled) {
|
||||
color: color-mix(in srgb, #74d66f 82%, white);
|
||||
color: color-mix(in srgb, var(--admin-green) 82%, white);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 7px rgba(116, 214, 111, 0.65));
|
||||
filter: drop-shadow(
|
||||
0 0 7px color-mix(in srgb, var(--admin-green) 65%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
@@ -3352,293 +3172,127 @@ button:disabled {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker {
|
||||
.admin-panel-statistics-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-activity-statistics,
|
||||
.admin-panel-coverage-statistics {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background: #0b0d0c;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button {
|
||||
min-height: 38px;
|
||||
.admin-panel-statistics-heading {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-statistics-heading > div {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button:hover,
|
||||
.admin-panel-accent-picker button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker strong {
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin-top: 8px;
|
||||
background: #0a0c0b;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control.is-color {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(170px, 0.7fr) minmax(320px, 1.3fr);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control > div:first-child,
|
||||
.admin-panel-appearance-control > span:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control strong {
|
||||
.admin-panel-statistics-heading strong {
|
||||
color: #d9ddd9;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control small {
|
||||
.admin-panel-statistics-heading small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-picker {
|
||||
.admin-panel-activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-activity-grid article {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(230px, 1fr);
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 2px 6px;
|
||||
padding: 10px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-color-value {
|
||||
min-width: 96px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
background: #1a1d1b;
|
||||
}
|
||||
|
||||
.admin-panel-color-value > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 48%, transparent);
|
||||
}
|
||||
|
||||
.admin-panel-color-value input {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #cbd0cb;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(90px, 1fr) 42px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number'] {
|
||||
width: 42px !important;
|
||||
max-width: 42px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 8px;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-inner-spin-button,
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range::-webkit-slider-thumb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid #f3f5f3;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 0 2px #171a18;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-font-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button svg {
|
||||
color: var(--admin-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button[aria-expanded='true'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.09);
|
||||
background: #101211;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button:hover,
|
||||
.admin-panel-font-select__menu button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu svg {
|
||||
.admin-panel-activity-grid svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) 38px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
.admin-panel-activity-grid strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control input {
|
||||
width: 100%;
|
||||
.admin-panel-activity-grid span {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
color: var(--admin-muted);
|
||||
font-size: 7px;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list article {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list article > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list strong,
|
||||
.admin-panel-coverage-list span {
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list strong {
|
||||
color: #d9ddd9;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list span {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-panel-statistics-progress {
|
||||
height: 3px;
|
||||
accent-color: var(--admin-green);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #1d201e;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control > strong {
|
||||
color: var(--admin-green);
|
||||
text-align: right;
|
||||
.admin-panel-statistics-progress > span {
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: inherit;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 52%, transparent);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-section-card__heading {
|
||||
|
||||
@@ -26,6 +26,10 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const openedFolderAppBlocks =
|
||||
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
|
||||
const homeFolderIconBlocks =
|
||||
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
|
||||
|
||||
describe('Home folder interaction contract', () => {
|
||||
it('opens folders from edit mode and starts dragging only after movement', () => {
|
||||
@@ -36,7 +40,7 @@ describe('Home folder interaction contract', () => {
|
||||
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
|
||||
})
|
||||
|
||||
it('keeps folder app dragging aligned at every phone scale', () => {
|
||||
it('keeps whole-folder dragging aligned at every phone scale', () => {
|
||||
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
|
||||
expect(dragSource).toContain('viewportWidth: bounds.width')
|
||||
expect(dragSource).not.toContain("getPropertyValue('zoom')")
|
||||
@@ -47,6 +51,60 @@ describe('Home folder interaction contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
|
||||
expect(homeFolderIconBlocks).toHaveLength(2)
|
||||
for (const area of ['grid', 'dock'] as const) {
|
||||
const block = homeFolderIconBlocks.find((candidate) =>
|
||||
candidate.includes(`data-home-area="${area}"`),
|
||||
)
|
||||
|
||||
expect(block).toBeDefined()
|
||||
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
|
||||
expect(block).toContain('@dragcancel="stopHomeDrag"')
|
||||
expect(block).toContain('@dragend="finishHomeDrag"')
|
||||
expect(block).toContain('@dragmove="moveHomeDrag"')
|
||||
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the external viewport visual for apps dragged from an opened folder', () => {
|
||||
expect(openedFolderAppBlocks).toHaveLength(1)
|
||||
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
':external-drag-visual="externalDragVisual"',
|
||||
)
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
})
|
||||
|
||||
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
|
||||
expect(overlaySource).toMatch(
|
||||
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
|
||||
)
|
||||
expect(overlaySource).toContain("emit('dragstart', index, event)")
|
||||
expect(overlaySource).toContain("emit('dragmove', event)")
|
||||
expect(overlaySource).toContain("emit('dragcancel')")
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragstart="startFolderAppDrag(entry.index, $event)"',
|
||||
)
|
||||
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragcancel="stopFolderAppDrag"',
|
||||
)
|
||||
})
|
||||
|
||||
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
|
||||
expect(overlaySource).toContain('readPhoneViewportGeometry')
|
||||
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(panel)')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(element)')
|
||||
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
|
||||
expect(overlaySource).not.toContain(
|
||||
'panelElement.value?.getBoundingClientRect()',
|
||||
)
|
||||
expect(overlaySource).not.toContain('document.elementsFromPoint')
|
||||
})
|
||||
|
||||
it('edits the folder name inline with Sky UI controls', () => {
|
||||
expect(overlaySource).toContain('<SkyField')
|
||||
expect(overlaySource).toContain('home-folder-heading--editing')
|
||||
@@ -63,8 +121,9 @@ describe('Home folder interaction contract', () => {
|
||||
expect(springboardSource).toContain(
|
||||
'@drag-outside-change="folderDraggingOutside = $event"',
|
||||
)
|
||||
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
|
||||
expect(overlaySource).toContain(
|
||||
'if (draggingIndex.value === null || draggingOutside.value) return',
|
||||
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'draggingOutside.value || !isPointerInsidePanel(event)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Pencil, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -11,20 +11,35 @@ import {
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
type HomeFolder,
|
||||
} from '@/utils/homeLayout'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type FolderAppEntry = {
|
||||
app: PhoneAppDefinition
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>(),
|
||||
{
|
||||
externalDragVisual: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
dragcancel: []
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [index: number, event: PointerEvent]
|
||||
'drag-outside-change': [outside: boolean]
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
@@ -43,6 +58,7 @@ const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
let pagePointerId: number | null = null
|
||||
let dragGeometry: PhoneViewportGeometry | null = null
|
||||
|
||||
const folderName = computed(
|
||||
() => props.folder.name || phone.t('Home.folders.defaultName'),
|
||||
@@ -113,25 +129,48 @@ function saveRename(): void {
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number): void {
|
||||
function startFolderAppDrag(index: number, event: PointerEvent): void {
|
||||
draggingIndex.value = index
|
||||
dragGeometry = readPhoneViewportGeometry(panelElement.value)
|
||||
setDraggingOutside(false)
|
||||
emit('dragstart', index, event)
|
||||
}
|
||||
|
||||
function fallbackViewportRect(element: Element): PhoneViewportRect {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function folderDragViewportRect(element: Element): PhoneViewportRect {
|
||||
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
|
||||
return geometry?.rect(element) ?? fallbackViewportRect(element)
|
||||
}
|
||||
|
||||
function isPointerInsidePanel(event: PointerEvent): boolean {
|
||||
const panelBounds = panelElement.value?.getBoundingClientRect()
|
||||
if (!panelBounds) return false
|
||||
const panel = panelElement.value
|
||||
return (
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
panel !== null &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(panel),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function moveFolderAppDrag(event: PointerEvent): void {
|
||||
if (draggingIndex.value === null || draggingOutside.value) return
|
||||
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
|
||||
if (draggingIndex.value === null) return
|
||||
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
|
||||
setDraggingOutside(true)
|
||||
}
|
||||
emit('dragmove', event)
|
||||
}
|
||||
|
||||
function setDraggingOutside(outside: boolean): void {
|
||||
@@ -145,31 +184,52 @@ function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) {
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
if (shouldExtract) {
|
||||
emit('extract', sourceIndex, event)
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
setDraggingOutside(false)
|
||||
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
|
||||
.find(
|
||||
(element) =>
|
||||
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
|
||||
)
|
||||
if (!target) return
|
||||
const target = Array.from(
|
||||
panelElement.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-folder-app-index]',
|
||||
) ?? [],
|
||||
).find(
|
||||
(element) =>
|
||||
Number(element.dataset.folderAppIndex) !== sourceIndex &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(element),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
)
|
||||
if (!target) {
|
||||
dragGeometry = null
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
const targetIndex = Number(target.dataset.folderAppIndex)
|
||||
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
|
||||
dragGeometry = null
|
||||
if (!Number.isInteger(targetIndex)) {
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
emit('move', sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
const wasDragging = draggingIndex.value !== null
|
||||
draggingIndex.value = null
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
@@ -195,6 +255,11 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
}
|
||||
pagePointerId = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFolderAppDrag()
|
||||
pagePointerId = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -290,10 +355,11 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
:app="entry.app"
|
||||
:data-folder-app-index="entry.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="externalDragVisual"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragmove="moveFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@dragstart="startFolderAppDrag(entry.index, $event)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -61,16 +61,22 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
const config = source('../../sky_phone/config/config.lua')
|
||||
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
|
||||
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
|
||||
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
|
||||
const configurator = source(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
)
|
||||
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
|
||||
|
||||
describe('fixed server permissions', () => {
|
||||
it('defines every protected phone capability only in config.lua', () => {
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
for (const permission of [
|
||||
'phonepanel',
|
||||
'phonetestdata',
|
||||
'fliptokverify',
|
||||
'picstagramverify',
|
||||
'picstagramadmin',
|
||||
]) {
|
||||
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
|
||||
}
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configDefault).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
|
||||
expect(configurator).toContain('key ~= "CommandPermissions"')
|
||||
expect(configurator).toContain('if key ~= "CommandPermissions" then')
|
||||
expect(configuratorFixture).toContain('delete config.CommandPermissions')
|
||||
for (const path of [
|
||||
'AdminPanel.AdminGroups',
|
||||
'TestData.AdminGroups',
|
||||
'FlipTok.AdminGroups',
|
||||
'Picstagram.AdminGroups',
|
||||
]) {
|
||||
expect(configurator).toContain(`["${path}"] = true`)
|
||||
}
|
||||
expect(configurator).toContain(
|
||||
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes Qbox through ACE before retaining framework group support', () => {
|
||||
expect(framework).toContain(
|
||||
'local groups = Config.CommandPermissions[permission]',
|
||||
)
|
||||
expect(qbox).toContain(
|
||||
'if IsPlayerAceAllowed(tostring(source), group) then',
|
||||
)
|
||||
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
|
||||
qbox.indexOf('exports.qbx_core:HasGroup'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses stable permission identifiers for every protected operation', () => {
|
||||
const expectations = [
|
||||
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
|
||||
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
|
||||
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
|
||||
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
|
||||
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
|
||||
] as const
|
||||
|
||||
for (const [path, permission] of expectations) {
|
||||
expect(source(path)).toContain(
|
||||
`Bridge.Framework.HasPermission(source, "${permission}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,42 @@ describe('phone inventory contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and limits metadata-free adapters', () => {
|
||||
it('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
)
|
||||
@@ -65,8 +100,17 @@ describe('phone inventory contracts', () => {
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
|
||||
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
|
||||
expect(inventoryBridge).toContain(
|
||||
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(inventoryBridge).not.toContain(
|
||||
'cannot store unique phone or physical SIM metadata',
|
||||
)
|
||||
})
|
||||
|
||||
it('provides the LB IsOpen export alias from the authoritative client state', () => {
|
||||
@@ -157,10 +201,10 @@ describe('phone inventory contracts', () => {
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
@@ -242,21 +286,21 @@ describe('phone inventory contracts', () => {
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
|
||||
})
|
||||
|
||||
it('opens from a configurable F1 mapping without client-provided device identity', () => {
|
||||
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
|
||||
const config = readResourceFile('config/config.lua')
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(config).toContain('Keybind = "F1"')
|
||||
expect(phoneClient).toContain('local phone_key_mapping_registered = false')
|
||||
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
|
||||
)
|
||||
expect(phoneClient).not.toContain('sky_phone_toggle_config_')
|
||||
expect(phoneClient).not.toContain('key_mapping_revision')
|
||||
expect(phoneClient).toContain(
|
||||
'if active_key_mapping_command == command_name then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
|
||||
@@ -15,7 +15,32 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
const EMPTY_STATS: AdminStats = {
|
||||
accounts: 0,
|
||||
activeDevices: 0,
|
||||
auditEntries: 0,
|
||||
auditToday: 0,
|
||||
callsToday: 0,
|
||||
devices: 0,
|
||||
linkedDevices: 0,
|
||||
messagesToday: 0,
|
||||
online: 0,
|
||||
simDevices: 0,
|
||||
}
|
||||
|
||||
function disabledAppsFromConfigurator(
|
||||
configurator: AdminConfigurator,
|
||||
): string[] {
|
||||
const apps = configurator.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
|
||||
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
|
||||
|
||||
return Object.entries(apps)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([appId]) => appId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
@@ -25,6 +50,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
detailLoading: false,
|
||||
disabledApps: [] as string[],
|
||||
error: '',
|
||||
initialized: false,
|
||||
loading: false,
|
||||
@@ -47,6 +73,9 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.disabledApps = Array.isArray(response.data.disabledApps)
|
||||
? response.data.disabledApps
|
||||
: []
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
@@ -147,6 +176,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
@@ -167,6 +197,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
} else {
|
||||
if (response.data) this.configurator = response.data
|
||||
|
||||
@@ -75,6 +75,21 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -459,6 +474,41 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('materializes a trailing page only for a successful folder extraction', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
settingsIndex,
|
||||
'Utilities',
|
||||
)
|
||||
expect(folderId).toBeTruthy()
|
||||
|
||||
const originalPageCount = apps.homeLayout.pageCount
|
||||
const targetPage = originalPageCount + 1
|
||||
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
expect(
|
||||
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
|
||||
).toBe(false)
|
||||
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(apps.homeLayout.pageCount).toBe(targetPage)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not commit an installation to a different phone', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
moveHomeAppToGridPage,
|
||||
@@ -45,28 +46,49 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly) return false
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -104,6 +126,7 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -130,6 +153,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -150,6 +174,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -191,6 +216,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -220,8 +249,14 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -256,16 +291,22 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
@@ -277,8 +318,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -310,7 +352,11 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
@@ -322,12 +368,18 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
@@ -335,7 +387,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +397,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -467,25 +520,35 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): boolean {
|
||||
let sourceLayout = this.homeLayout
|
||||
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
|
||||
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
|
||||
while (sourceLayout.pageCount < targetPage) {
|
||||
const expanded = addHomePage(sourceLayout)
|
||||
if (expanded === sourceLayout) return false
|
||||
sourceLayout = expanded
|
||||
}
|
||||
}
|
||||
const next = extractHomeFolderApp(
|
||||
this.homeLayout,
|
||||
sourceLayout,
|
||||
folderId,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
)
|
||||
if (next === this.homeLayout) return false
|
||||
if (next === sourceLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
@@ -848,40 +849,21 @@ const adminPanelFallbackLocales = {
|
||||
auditFeature: 'Review sensitive admin actions',
|
||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||
},
|
||||
appearance: {
|
||||
eyebrow: 'Appearance',
|
||||
title: 'Interface',
|
||||
body: 'Personalize colors and typography across the admin workspace.',
|
||||
colors: {
|
||||
emerald: 'Emerald',
|
||||
blue: 'Blue',
|
||||
violet: 'Violet',
|
||||
orange: 'Orange',
|
||||
red: 'Red',
|
||||
},
|
||||
controls: {
|
||||
customColor: 'Custom color',
|
||||
customColorBody: 'Choose any RGB accent or enter its channel values.',
|
||||
red: 'R',
|
||||
green: 'G',
|
||||
blue: 'B',
|
||||
hex: 'HEX color',
|
||||
value: 'value',
|
||||
fontFamily: 'Font family',
|
||||
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
|
||||
fontSize: 'Font size',
|
||||
fontSizeBody: 'Scale text and controls for comfortable readability.',
|
||||
fonts: {
|
||||
inter: 'Inter',
|
||||
system: 'System',
|
||||
classic: 'Classic',
|
||||
verdana: 'Verdana',
|
||||
tahoma: 'Tahoma',
|
||||
trebuchet: 'Trebuchet',
|
||||
georgia: 'Georgia',
|
||||
mono: 'Monospace',
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
eyebrow: 'Live data',
|
||||
title: 'Phone statistics',
|
||||
body: 'Current usage and device status, refreshed every 15 seconds.',
|
||||
today: 'Activity today',
|
||||
todayBody: 'Phone and admin activity since midnight.',
|
||||
messagesToday: 'Messages',
|
||||
callsToday: 'Calls',
|
||||
auditToday: 'Admin actions',
|
||||
coverage: 'Device coverage',
|
||||
coverageBody: 'Current share of all stored phones.',
|
||||
linkedDevices: 'Account linked',
|
||||
simDevices: 'SIM assigned',
|
||||
activeDevices: 'Updated in 24 hours',
|
||||
ofDevices: '{count} of {total} devices',
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
@@ -945,7 +927,6 @@ const adminPanelFallbackLocales = {
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
subtabs: {
|
||||
AdminGroups: 'Admin Groups',
|
||||
Dictionaries: 'Dictionaries',
|
||||
Clips: 'Clips',
|
||||
Transforms: 'Transforms',
|
||||
@@ -2731,6 +2712,8 @@ const defaultLocales: LocaleTree = {
|
||||
recipient_not_found: 'That number is unavailable.',
|
||||
blocked: 'This contact has blocked calls and messages from your SIM.',
|
||||
messaging_unavailable: 'This company contact does not accept messages.',
|
||||
service_line_text_only:
|
||||
'Company service lines currently accept text messages only.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
rate_limited: 'Too many messages. Try again in a minute.',
|
||||
request_failed: 'Messages are temporarily unavailable.',
|
||||
@@ -4542,8 +4525,8 @@ const defaultLocales: LocaleTree = {
|
||||
video: 'Video',
|
||||
microphoneOn: 'Microphone on',
|
||||
microphoneOff: 'Microphone muted',
|
||||
focusHelp: 'Space for movement',
|
||||
returnHelp: 'Space to return',
|
||||
focusHelp: 'Hold the look key to look around',
|
||||
returnHelp: 'Release the look key for controls',
|
||||
uploading: '{count} uploading',
|
||||
saving: 'Saving video...',
|
||||
openGallery: 'Open Photos',
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
@@ -78,6 +85,7 @@ export type AdminAuditEntry = {
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
@@ -130,6 +138,7 @@ export type AdminConfiguratorStructure =
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
entryDefault?: unknown
|
||||
fields: Record<string, AdminConfiguratorStructure>
|
||||
kind: 'table'
|
||||
mutableKeys?: boolean
|
||||
|
||||
@@ -43,6 +43,17 @@ describe('SkyNavbar', () => {
|
||||
expect(html).toContain('Account')
|
||||
})
|
||||
|
||||
it('constrains long compact titles to the center column', () => {
|
||||
const titleRule = foundationStyles.match(
|
||||
/\.sky-navbar__title\s*\{([^}]*)\}/s,
|
||||
)?.[1]
|
||||
|
||||
expect(titleRule).toContain('max-width: 100%')
|
||||
expect(titleRule).toContain('overflow: hidden')
|
||||
expect(titleRule).toContain('text-overflow: ellipsis')
|
||||
expect(titleRule).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('exposes the large-title header without changing heading semantics', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp(SkyNavbar, {
|
||||
|
||||
Vendored
+1
@@ -168,6 +168,7 @@
|
||||
|
||||
.sky-navbar__title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 var(--sky-space-1);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
|
||||
|
||||
describe('admin configurator defaults', () => {
|
||||
it('creates a usable company draft from its key and the server defaults', () => {
|
||||
const entryDefault = {
|
||||
AcceptsRequests: true,
|
||||
Category: 'public_services',
|
||||
Job: '',
|
||||
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
|
||||
Name: '',
|
||||
ServiceLine: {
|
||||
Number: '500',
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Description: '',
|
||||
Id: '',
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: '',
|
||||
},
|
||||
],
|
||||
}
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
entryDefault,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: {
|
||||
fields: {
|
||||
Job: { kind: 'value', valueType: 'string' },
|
||||
Name: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
structure,
|
||||
'Companies.Definitions',
|
||||
'pizza_palace',
|
||||
),
|
||||
).toEqual({
|
||||
AcceptsRequests: true,
|
||||
Category: 'public_services',
|
||||
Job: 'pizza_palace',
|
||||
LogoUrl: 'https://picsum.photos/seed/companies-pizza_palace-logo/180/180',
|
||||
Name: 'Pizza Palace',
|
||||
ServiceLine: {
|
||||
Number: '500',
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Description: '',
|
||||
Id: 'pizza_palace',
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: 'Pizza Palace',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(entryDefault).toMatchObject({
|
||||
Job: '',
|
||||
Name: '',
|
||||
Services: [{ Id: '', Title: '' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('allocates distinct service numbers for multiple unsaved companies', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
entryDefault: {
|
||||
Job: '',
|
||||
LogoUrl: '',
|
||||
Name: '',
|
||||
ServiceLine: { Number: '500' },
|
||||
},
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
}
|
||||
const existing = {
|
||||
pizzeria: { ServiceLine: { Number: '500' } },
|
||||
restaurant: { ServiceLine: { Number: '501' } },
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
structure,
|
||||
'Companies.Definitions',
|
||||
'bakery',
|
||||
existing,
|
||||
),
|
||||
).toMatchObject({ ServiceLine: { Number: '502' } })
|
||||
})
|
||||
|
||||
it('keeps generic mutable tables on their schema-derived blank value', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
|
||||
expect(createMutableTableEntry(structure, 'FeatureFlags', 'example')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
function cloneConfiguratorValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(cloneConfiguratorValue)
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
cloneConfiguratorValue(child),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function blankScalar(valueType: 'boolean' | 'number' | 'string'): unknown {
|
||||
if (valueType === 'boolean') return false
|
||||
if (valueType === 'number') return 0
|
||||
return ''
|
||||
}
|
||||
|
||||
export function blankFromConfiguratorStructure(
|
||||
structure: AdminConfiguratorStructure,
|
||||
): unknown {
|
||||
if (structure.kind === 'optionalString') return ''
|
||||
if (structure.kind === 'value') return blankScalar(structure.valueType)
|
||||
if (structure.kind === 'vector') {
|
||||
const axes = ['x', 'y', 'z', 'w'].slice(
|
||||
0,
|
||||
Number(structure.vectorType.slice(-1)),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
['__skyType', structure.vectorType],
|
||||
...axes.map((axis) => [axis, 0]),
|
||||
])
|
||||
}
|
||||
if (structure.kind === 'list') {
|
||||
return structure.items.map(blankFromConfiguratorStructure)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: structure.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: blankFromConfiguratorStructure(entry.structure),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(structure.fields).map(([key, field]) => [
|
||||
key,
|
||||
blankFromConfiguratorStructure(field),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export function createMutableTableEntry(
|
||||
structure: Extract<AdminConfiguratorStructure, { kind: 'table' }>,
|
||||
path: string,
|
||||
key: string,
|
||||
entries: Record<string, unknown> = {},
|
||||
): unknown {
|
||||
const value =
|
||||
structure.entryDefault !== undefined
|
||||
? cloneConfiguratorValue(structure.entryDefault)
|
||||
: structure.template
|
||||
? blankFromConfiguratorStructure(structure.template)
|
||||
: undefined
|
||||
|
||||
if (
|
||||
path !== 'Companies.Definitions' ||
|
||||
value === null ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
const company = value as Record<string, unknown>
|
||||
company.Job = key
|
||||
company.Name = key
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
company.LogoUrl = `https://picsum.photos/seed/companies-${key}-logo/180/180`
|
||||
if (Array.isArray(company.Services)) {
|
||||
if (company.Services.length === 0) {
|
||||
company.Services.push({
|
||||
Description: '',
|
||||
Id: key,
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: company.Name,
|
||||
})
|
||||
} else {
|
||||
const service = company.Services[0]
|
||||
if (
|
||||
service !== null &&
|
||||
typeof service === 'object' &&
|
||||
!Array.isArray(service) &&
|
||||
!(service as Record<string, unknown>).Id
|
||||
) {
|
||||
const mutableService = service as Record<string, unknown>
|
||||
mutableService.Id = key
|
||||
mutableService.Title = company.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
const serviceLine = company.ServiceLine
|
||||
if (
|
||||
serviceLine !== null &&
|
||||
typeof serviceLine === 'object' &&
|
||||
!Array.isArray(serviceLine)
|
||||
) {
|
||||
const mutableServiceLine = serviceLine as Record<string, unknown>
|
||||
const number = String(mutableServiceLine.Number ?? '')
|
||||
const numeric = Number(number)
|
||||
if (/^\d+$/.test(number) && Number.isSafeInteger(numeric) && numeric > 0) {
|
||||
const used = new Set(
|
||||
Object.values(entries).map((entry) => {
|
||||
if (entry === null || typeof entry !== 'object') return ''
|
||||
const line = (entry as Record<string, unknown>).ServiceLine
|
||||
if (line === null || typeof line !== 'object') return ''
|
||||
return String((line as Record<string, unknown>).Number ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
)
|
||||
}),
|
||||
)
|
||||
const maximum = 10 ** number.length - 1
|
||||
for (let offset = 0; offset < maximum; offset += 1) {
|
||||
const candidate = ((numeric - 1 + offset) % maximum) + 1
|
||||
const formatted = String(candidate).padStart(number.length, '0')
|
||||
if (!used.has(formatted)) {
|
||||
mutableServiceLine.Number = formatted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return company
|
||||
}
|
||||
@@ -16,9 +16,7 @@ describe('admin configurator descriptions', () => {
|
||||
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
|
||||
'access',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
|
||||
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||
'credential',
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
@@ -63,7 +64,78 @@ function createGeometryFixture(options: {
|
||||
}
|
||||
}
|
||||
|
||||
const PHONE_BASE_ZOOM = 0.69 * 1.2
|
||||
const PHONE_HEIGHT = 844
|
||||
const PHONE_WIDTH = 390
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
|
||||
const displayCases = [
|
||||
{ height: 1080, label: '1080p 16:9', width: 1920 },
|
||||
{ height: 2160, label: '4K 16:9', width: 3840 },
|
||||
{ height: 1440, label: '21:9', width: 3440 },
|
||||
{ height: 1440, label: '32:9', width: 5120 },
|
||||
{ height: 1600, label: '16:10', width: 2560 },
|
||||
] as const
|
||||
|
||||
const phoneScales = [80, 100, 120] as const
|
||||
|
||||
function productionPhoneZoom(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
phoneScale: number,
|
||||
): number {
|
||||
const viewportScale = Math.min(
|
||||
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
|
||||
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const viewportMaximum = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(viewportWidth - edgeGap) / PHONE_WIDTH,
|
||||
(viewportHeight - edgeGap) / PHONE_HEIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
|
||||
}
|
||||
|
||||
const cefDisplayMatrix = displayCases.flatMap((display) =>
|
||||
phoneScales.map((phoneScale) => ({ display, phoneScale })),
|
||||
)
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
|
||||
const rect: PhoneViewportRect = {
|
||||
bottom: 260,
|
||||
height: 160,
|
||||
left: 120,
|
||||
right: 360,
|
||||
top: 100,
|
||||
width: 240,
|
||||
}
|
||||
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||
@@ -124,6 +196,76 @@ describe('phone viewport geometry', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(cefDisplayMatrix)(
|
||||
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
|
||||
({ display, phoneScale }) => {
|
||||
const viewportScale = Math.min(
|
||||
display.width / REFERENCE_VIEWPORT_WIDTH,
|
||||
display.height / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const zoom = productionPhoneZoom(
|
||||
display.width,
|
||||
display.height,
|
||||
phoneScale,
|
||||
)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const wrapper = measuredRect(
|
||||
display.width - edgeGap - PHONE_WIDTH * zoom,
|
||||
display.height - edgeGap - PHONE_HEIGHT * zoom,
|
||||
PHONE_WIDTH * zoom,
|
||||
PHONE_HEIGHT * zoom,
|
||||
)
|
||||
// Live CEF 103 can report the zoomed canvas in a different coordinate
|
||||
// space from pointer events. Preserve that mismatch in this fixture.
|
||||
const rawCanvas = measuredRect(
|
||||
display.width + 117.375,
|
||||
83.625,
|
||||
PHONE_WIDTH,
|
||||
PHONE_HEIGHT,
|
||||
)
|
||||
const rawPanel = measuredRect(
|
||||
rawCanvas.left + 23.25,
|
||||
rawCanvas.top + 246.75,
|
||||
343.5,
|
||||
324.25,
|
||||
)
|
||||
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
|
||||
const expected = {
|
||||
bottom: wrapper.top + (246.75 + 324.25) * zoom,
|
||||
height: 324.25 * zoom,
|
||||
left: wrapper.left + 23.25 * zoom,
|
||||
right: wrapper.left + (23.25 + 343.5) * zoom,
|
||||
top: wrapper.top + 246.75 * zoom,
|
||||
width: 343.5 * zoom,
|
||||
}
|
||||
|
||||
expectRectClose(panel, expected)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(
|
||||
panel,
|
||||
panel.left + panel.width / 2,
|
||||
panel.top + panel.height / 2,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
|
||||
).toBe(true)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
|
||||
).toBe(false)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
|
||||
).toBe(false)
|
||||
expect(panel.left).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.right).toBeLessThanOrEqual(display.width)
|
||||
expect(panel.top).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.bottom).toBeLessThanOrEqual(display.height)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
|
||||
@@ -7,6 +7,14 @@ export type PhoneViewportRect = {
|
||||
width: number
|
||||
}
|
||||
|
||||
export function phoneViewportRectContainsPoint(
|
||||
rect: PhoneViewportRect,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
|
||||
@@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
|
||||
@@ -30,7 +30,11 @@ const launchStyle = computed(() => {
|
||||
<div
|
||||
v-if="app && !app.adminOnly"
|
||||
class="app-window"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:class="{
|
||||
'app-window--camera-landscape':
|
||||
app.id === 'camera' && phone.cameraLandscape,
|
||||
'app-window--citywarn': app.id === 'citywarn',
|
||||
}"
|
||||
:style="launchStyle"
|
||||
>
|
||||
<CustomAppFrame
|
||||
|
||||
@@ -31,6 +31,22 @@ const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
const folderOverlayBlock =
|
||||
viewSource.match(/<HomeFolderOverlay\b[\s\S]*?\/>/)?.[0] ?? ''
|
||||
|
||||
function handlerName(block: string, event: string): string {
|
||||
return (
|
||||
block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function functionSource(name: string): string {
|
||||
if (!name) return ''
|
||||
const start = viewSource.indexOf(`function ${name}(`)
|
||||
if (start < 0) return ''
|
||||
const next = viewSource.indexOf('\nfunction ', start + 1)
|
||||
return viewSource.slice(start, next < 0 ? viewSource.length : next)
|
||||
}
|
||||
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||
@@ -136,7 +152,7 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
expect(
|
||||
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||
).toHaveLength(4)
|
||||
).toHaveLength(5)
|
||||
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||
@@ -160,6 +176,65 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('owns one external drag session for an app leaving an opened folder', () => {
|
||||
expect(folderOverlayBlock).toContain(
|
||||
':external-drag-visual="homeDragVisualActive"',
|
||||
)
|
||||
|
||||
const startSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragstart'),
|
||||
)
|
||||
const moveSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragmove'),
|
||||
)
|
||||
const cancelSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragcancel'),
|
||||
)
|
||||
|
||||
expect(startSource).toContain('createHomeDragGhost(event)')
|
||||
expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
|
||||
expect(moveSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(moveSource).toContain('folderDraggingOutside.value')
|
||||
expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
|
||||
expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
|
||||
expect(viewSource).toContain(
|
||||
'(draggingHomeApp.value || draggingFolderApp.value)',
|
||||
)
|
||||
expect(cancelSource).toContain('clearHomeDragGhost()')
|
||||
expect(cancelSource).toContain('clearTemporaryHomePage()')
|
||||
})
|
||||
|
||||
it('settles an extracted folder app from the tracked viewport preview', () => {
|
||||
const extractSource = functionSource('extractOpenedFolderApp')
|
||||
const targetSource = functionSource('folderExtractionDropTarget')
|
||||
|
||||
expect(extractSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(extractSource).toContain('homeDragPreviewBounds')
|
||||
expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
|
||||
expect(extractSource).not.toContain('sourceElement')
|
||||
expect(extractSource).not.toContain('getBoundingClientRect()')
|
||||
expect(extractSource).toMatch(
|
||||
/animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
|
||||
)
|
||||
expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||
expect(extractSource).toMatch(
|
||||
/if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
|
||||
const moveSource = functionSource('moveOpenedFolderApp')
|
||||
const cancelSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragcancel'),
|
||||
)
|
||||
|
||||
expect(moveSource).toContain('stopOpenedFolderAppDrag()')
|
||||
expect(cancelSource).toContain('clearHomeDragGhost()')
|
||||
expect(viewSource).toMatch(
|
||||
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the external drag visual on every terminal path', () => {
|
||||
expect(
|
||||
viewSource.match(
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
@@ -111,6 +112,11 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const draggingFolderApp = ref<{
|
||||
appId: LaunchablePhoneAppId
|
||||
folderId: string
|
||||
sourceIndex: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
@@ -610,7 +616,11 @@ function queueEdgePageTurn(
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = direction
|
||||
edgePageLocked = dragType === 'widget'
|
||||
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
|
||||
if (
|
||||
dragType === 'app' &&
|
||||
lastHomePointer &&
|
||||
(draggingHomeApp.value || draggingFolderApp.value)
|
||||
) {
|
||||
queueEdgePageTurn(lastHomePointer, 'app')
|
||||
}
|
||||
}, HOME_EDGE_PAGE_TURN_DELAY)
|
||||
@@ -814,7 +824,7 @@ function updateHomeDragGhost(event: {
|
||||
)
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
@@ -827,7 +837,7 @@ function createHomeDragGhost(event: PointerEvent): void {
|
||||
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return null
|
||||
|
||||
const portalBounds = portal.getBoundingClientRect()
|
||||
const clipBounds = geometry.rect(springboard)
|
||||
@@ -838,7 +848,7 @@ function createHomeDragGhost(event: PointerEvent): void {
|
||||
sourceBounds.width <= 0 ||
|
||||
sourceBounds.height <= 0
|
||||
) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||
@@ -865,7 +875,12 @@ function createHomeDragGhost(event: PointerEvent): void {
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
if (
|
||||
attribute.startsWith('data-home-') ||
|
||||
attribute.startsWith('data-folder-')
|
||||
) {
|
||||
element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
@@ -894,6 +909,7 @@ function createHomeDragGhost(event: PointerEvent): void {
|
||||
layer.appendChild(position)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
return position
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
@@ -1190,6 +1206,98 @@ function nearestDockDropTarget(event: {
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
|
||||
function folderExtractionDropTarget(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}):
|
||||
| { area: 'dock'; index: number }
|
||||
| { area: 'grid'; index: number; page: number }
|
||||
| null {
|
||||
const geometry = homeDragGeometry
|
||||
if (!geometry) return null
|
||||
|
||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||
if (dock) {
|
||||
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||
if (
|
||||
phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
|
||||
) {
|
||||
const slots = Array.from(
|
||||
dock.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="dock"][data-home-index]',
|
||||
),
|
||||
).filter((slot) => {
|
||||
const index = Number(slot.dataset.homeIndex)
|
||||
return (
|
||||
Number.isInteger(index) && appStore.homeLayout.dock[index] === null
|
||||
)
|
||||
})
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||
)
|
||||
if (closestIndex === null) return null
|
||||
const index = Number(slots[closestIndex]?.dataset.homeIndex)
|
||||
return Number.isInteger(index) ? { area: 'dock', index } : null
|
||||
}
|
||||
}
|
||||
|
||||
const page = phone.currentPage
|
||||
if (page < 1 || page > appPages.value.length) return null
|
||||
const grid = document.querySelector<HTMLElement>(
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
|
||||
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||
const rawGridBounds = homeDragViewportRect(grid, geometry)
|
||||
const gridBounds = viewportRectAt(
|
||||
springboardBounds.left + (rawGridBounds.left - pageBounds.left),
|
||||
springboardBounds.top + (rawGridBounds.top - pageBounds.top),
|
||||
rawGridBounds.width,
|
||||
rawGridBounds.height,
|
||||
)
|
||||
if (
|
||||
!phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slots = Array.from(
|
||||
grid.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="grid"][data-home-index]',
|
||||
),
|
||||
).filter((slot) => {
|
||||
const index = Number(slot.dataset.homeIndex)
|
||||
const item = appStore.homeLayout.grid[index]
|
||||
return (
|
||||
Number.isInteger(index) &&
|
||||
(item === null ||
|
||||
(item === undefined && page > appStore.homeLayout.pageCount))
|
||||
)
|
||||
})
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => {
|
||||
const bounds = homeDragViewportRect(slot, geometry)
|
||||
return {
|
||||
height: bounds.height,
|
||||
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||
width: bounds.width,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (closestIndex === null) return null
|
||||
const index = Number(slots[closestIndex]?.dataset.homeIndex)
|
||||
return Number.isInteger(index) ? { area: 'grid', index, page } : null
|
||||
}
|
||||
|
||||
function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
@@ -1324,16 +1432,83 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
|
||||
return apps
|
||||
}
|
||||
|
||||
function currentOpenedFolderDragSession(sourceIndex?: number) {
|
||||
const session = draggingFolderApp.value
|
||||
const folder = openedFolder.value
|
||||
if (
|
||||
!session ||
|
||||
!folder ||
|
||||
folder.id !== session.folderId ||
|
||||
(sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
|
||||
folder.apps[session.sourceIndex] !== session.appId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function startOpenedFolderAppDrag(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
const folder = openedFolder.value
|
||||
if (
|
||||
!folder ||
|
||||
!Number.isInteger(sourceIndex) ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= folder.apps.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
const appId = folder.apps[sourceIndex]
|
||||
if (!appId || !installedAppsById.value.has(appId)) return
|
||||
if (!createHomeDragGhost(event)) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
}
|
||||
|
||||
function moveOpenedFolderAppDrag(event: PointerEvent): void {
|
||||
if (!currentOpenedFolderDragSession()) {
|
||||
stopOpenedFolderAppDrag()
|
||||
return
|
||||
}
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
|
||||
queueEdgePageTurn(event, 'app')
|
||||
return
|
||||
}
|
||||
clearEdgePageTurn()
|
||||
}
|
||||
|
||||
function stopOpenedFolderAppDrag(): void {
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
function resetOpenedFolderState(): void {
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
|
||||
function openFolder(folderId: string): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
pageTransitioning.value = false
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
|
||||
function closeFolder(): void {
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
stopOpenedFolderAppDrag()
|
||||
resetOpenedFolderState()
|
||||
}
|
||||
|
||||
function renameOpenedFolder(name: string): void {
|
||||
@@ -1342,71 +1517,68 @@ function renameOpenedFolder(name: string): void {
|
||||
}
|
||||
|
||||
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
|
||||
if (!openedFolderId.value) return
|
||||
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
|
||||
const session = currentOpenedFolderDragSession(sourceIndex)
|
||||
stopOpenedFolderAppDrag()
|
||||
if (!session) return
|
||||
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function extractOpenedFolderApp(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
const folder = openedFolder.value
|
||||
if (!folder) return
|
||||
const appId = folder.apps[sourceIndex]
|
||||
const sourceElement = document
|
||||
.querySelector<HTMLElement>(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||
const sourceBounds = sourceElement
|
||||
? homeDragViewportRect(sourceElement, geometry)
|
||||
: null
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
return (
|
||||
(area === 'grid' || area === 'dock') &&
|
||||
Number.isInteger(index) &&
|
||||
appStore.homeLayout[area][index] === null
|
||||
)
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = homeDragViewportRect(candidate, geometry)
|
||||
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
)
|
||||
const closestDistance = Math.hypot(
|
||||
event.clientX - (closestBounds.left + closestBounds.width / 2),
|
||||
event.clientY - (closestBounds.top + closestBounds.height / 2),
|
||||
)
|
||||
return distance < closestDistance ? candidate : closest
|
||||
}, null)
|
||||
if (!target) {
|
||||
const session = currentOpenedFolderDragSession(sourceIndex)
|
||||
if (!session) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
updateHomeDragGhost(event)
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||
if (!dropGhost || !dropOrigin) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
const target = folderExtractionDropTarget(event)
|
||||
if (!target) {
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
resetOpenedFolderState()
|
||||
return
|
||||
}
|
||||
|
||||
const area = target.dataset.homeArea as HomeArea
|
||||
const targetIndex = Number(target.dataset.homeIndex)
|
||||
const { area, index: targetIndex } = target
|
||||
if (
|
||||
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
|
||||
!appStore.extractHomeFolderApp(
|
||||
session.folderId,
|
||||
session.sourceIndex,
|
||||
area,
|
||||
targetIndex,
|
||||
)
|
||||
) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
closeFolder()
|
||||
if (sourceBounds) {
|
||||
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
|
||||
if (extractedIndex !== null) {
|
||||
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
|
||||
}
|
||||
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(area === 'grid')
|
||||
resetOpenedFolderState()
|
||||
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
|
||||
if (extractedIndex === null) {
|
||||
clearHomeDragGhost(dropGhost)
|
||||
return
|
||||
}
|
||||
void animateHomeItemDrop(
|
||||
session.appId,
|
||||
area,
|
||||
extractedIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
@@ -1426,11 +1598,12 @@ watch(isEditablePage, (visible) => {
|
||||
watch(editMode, (editing) => {
|
||||
emit('editModeChange', editing)
|
||||
if (editing) return
|
||||
stopOpenedFolderAppDrag()
|
||||
stopHomeDrag()
|
||||
stopWidgetDrag()
|
||||
})
|
||||
watch(openedFolder, (folder) => {
|
||||
if (!folder) closeFolder()
|
||||
if (!folder && openedFolderId.value !== null) closeFolder()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
emit('editModeChange', false)
|
||||
@@ -1445,6 +1618,7 @@ onBeforeUnmount(() => {
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingFolderApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
})
|
||||
</script>
|
||||
@@ -1858,9 +2032,13 @@ onBeforeUnmount(() => {
|
||||
v-if="openedFolder"
|
||||
:apps="openedFolderApps"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="openedFolder"
|
||||
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
|
||||
@close="closeFolder"
|
||||
@dragcancel="stopOpenedFolderAppDrag"
|
||||
@dragmove="moveOpenedFolderAppDrag"
|
||||
@dragstart="startOpenedFolderAppDrag"
|
||||
@drag-outside-change="folderDraggingOutside = $event"
|
||||
@edit="enterEditMode"
|
||||
@extract="extractOpenedFolderApp"
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
|
||||
@@ -122,6 +122,7 @@ const catalog = computed(() =>
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
return false
|
||||
}
|
||||
if (!appStore.isAvailable(app.id)) return false
|
||||
|
||||
return !appStore.isInstalled(app.id)
|
||||
}).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
@@ -129,7 +130,9 @@ const catalog = computed(() =>
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
|
||||
isLaunchablePhoneApp(app) &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
appStore.isInstalled(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const installedGameCount = computed(
|
||||
@@ -143,6 +146,7 @@ const dailyCandidates = computed(() =>
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!appStore.isInstalled(app.id),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,15 @@ const cameraView = readFileSync(
|
||||
new URL('./CameraApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appShell = readFileSync(new URL('../../App.vue', import.meta.url), 'utf8')
|
||||
const appWindow = readFileSync(
|
||||
new URL('../PhoneAppWindow.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const shellStyles = readFileSync(
|
||||
new URL('../../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('../../components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -47,6 +56,99 @@ describe('Camera app controls', () => {
|
||||
expect(cameraView).not.toContain('k-navbar')
|
||||
})
|
||||
|
||||
it('stays dark when the global phone appearance is light', () => {
|
||||
const cameraPageTag = cameraView.match(/<sky-app-page\b[^>]*>/s)?.[0]
|
||||
|
||||
expect(cameraPageTag).toBeDefined()
|
||||
expect(cameraPageTag).toMatch(/\bdark\b/)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-page\s*\{[^}]*--sky-bg:\s*#000;[^}]*--sky-text:\s*#fff;[^}]*background:\s*var\(--sky-bg\);[^}]*color:\s*var\(--sky-text\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('fills the translucent landscape shell without letterbox gaps', () => {
|
||||
const landscapeViewport = cameraView.match(
|
||||
/\.camera-page--landscape \.camera-viewport\s*\{([^}]*)\}/s,
|
||||
)?.[1]
|
||||
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-page--landscape\s*\{[^}]*--sky-bg:\s*rgba\(0, 0, 0, 0\.42\);/s,
|
||||
)
|
||||
expect(landscapeViewport).toBeDefined()
|
||||
expect(landscapeViewport).toContain('top: 50%')
|
||||
expect(landscapeViewport).toContain(
|
||||
'width: calc(100% * var(--phone-screen-portrait-ratio))',
|
||||
)
|
||||
expect(landscapeViewport).toContain(
|
||||
'aspect-ratio: var(--phone-screen-portrait-ratio)',
|
||||
)
|
||||
expect(landscapeViewport).not.toContain('16 / 9')
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-page--landscape \.camera-shade\s*\{[^}]*linear-gradient\([^)]*90deg,[^)]*rgba\(0, 0, 0, 0\.42\) 0 18%,[^)]*transparent 18% 75%,[^)]*rgba\(0, 0, 0, 0\.42\) 75% 100%/s,
|
||||
)
|
||||
expect(appShell).toMatch(
|
||||
/'phone-screen--camera-landscape':\s*activeAppId === 'camera' && phone\.cameraLandscape/,
|
||||
)
|
||||
expect(appWindow).toMatch(
|
||||
/'app-window--camera-landscape':\s*app\.id === 'camera' && phone\.cameraLandscape/,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.phone-screen\s*\{[^}]*--phone-screen-portrait-ratio:\s*2\.30951;/s,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.phone-screen--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.phone-screen--camera-landscape \.springboard\s*\{[^}]*visibility:\s*hidden;/s,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.app-window--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders camera notices as plain text without a glass pill', () => {
|
||||
const noticeMarkup = cameraView.slice(
|
||||
cameraView.indexOf('<span v-if="noticeText"'),
|
||||
cameraView.indexOf('<span v-else-if="pendingCount"'),
|
||||
)
|
||||
const noticeStyles = cameraView.match(
|
||||
/\.camera-notice-text\s*,[\s\S]*?\{([^}]*)\}/,
|
||||
)?.[1]
|
||||
|
||||
expect(noticeMarkup).toContain('class="camera-notice-text"')
|
||||
expect(noticeMarkup).not.toContain('camera-focus-pill')
|
||||
expect(noticeStyles).toBeDefined()
|
||||
expect(noticeStyles).toContain('color: #ffd60a')
|
||||
expect(noticeStyles).not.toMatch(
|
||||
/(?:background|backdrop-filter|border-radius|padding)\s*:/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the current compact lens selector treatment', () => {
|
||||
const zoomControl = cameraView.slice(
|
||||
cameraView.indexOf('<div class="camera-zoom-control">'),
|
||||
cameraView.indexOf('<footer class="camera-controls">'),
|
||||
)
|
||||
|
||||
expect(zoomControl).toContain('variant="plain"')
|
||||
expect(zoomControl).not.toContain('glass')
|
||||
expect(zoomControl).toContain('{{ zoomPresetLabel(zoom) }}')
|
||||
expect(cameraView).toContain("return zoom === 0.5 ? '.5' : `${zoom}`")
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-control\s*\{[^}]*bottom:\s*216px;/s,
|
||||
)
|
||||
expect(cameraView).toMatch(/\.camera-zoom-row\s*\{[^}]*gap:\s*10px;/s)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-pill\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*min-height:\s*36px;[^}]*padding:\s*0;/s,
|
||||
)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-pill::before\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*inset-block:\s*-4px;/s,
|
||||
)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-pill\.active\s*\{[^}]*color:\s*#ffd60a;[^}]*background:\s*rgba\(28, 28, 30, 0\.78\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps continuous wheel zoom without an extra slider bar', () => {
|
||||
expect(cameraView).not.toContain('camera-zoom-slider')
|
||||
expect(cameraView).not.toContain('type="range"')
|
||||
@@ -77,7 +179,8 @@ describe('Camera app controls', () => {
|
||||
it('locks look controls without changing the global gameplay camera', () => {
|
||||
expect(cameraView).toContain("nuiCall('camera:setLocked'")
|
||||
expect(cameraView).toContain('cameraLocked.value')
|
||||
expect(cameraView).toContain('Apps.camera.spaceKey')
|
||||
expect(cameraView).toContain('Apps.camera.lookKey')
|
||||
expect(cameraView).not.toContain('Apps.camera.spaceKey')
|
||||
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
|
||||
expect(cameraClient).toContain('INPUT_LOOK_LR')
|
||||
expect(cameraClient).toContain('INPUT_LOOK_UD')
|
||||
@@ -95,10 +198,26 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
|
||||
})
|
||||
|
||||
it('keeps the selfie camera stable while Space still allows movement', () => {
|
||||
expect(cameraView).toMatch(
|
||||
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/,
|
||||
it('uses the configured HoldToLook control and Space in camera modes', () => {
|
||||
expect(cameraView).not.toContain("event.code !== 'Space'")
|
||||
expect(cameraView).not.toContain("window.addEventListener('keydown'")
|
||||
expect(focusClient).toContain(
|
||||
'function SkyPhoneFocus.IsHoldToLookPressed()',
|
||||
)
|
||||
expect(focusClient).toContain(
|
||||
'IsDisabledControlPressed(0, hold_to_look_control)',
|
||||
)
|
||||
expect(cameraClient).toContain('SkyPhoneFocus.IsHoldToLookPressed()')
|
||||
expect(cameraClient).toContain(
|
||||
'IsDisabledControlPressed(0, camera_passthrough_control)',
|
||||
)
|
||||
expect(cameraClient).toMatch(
|
||||
/if data\.active then\s+watch_camera_controls\(\)/,
|
||||
)
|
||||
expect(cameraClient).not.toContain('IsDisabledControlJustReleased(0, 22)')
|
||||
})
|
||||
|
||||
it('orbits the stable selfie camera while HoldToLook allows movement', () => {
|
||||
expect(cameraClient).toContain(
|
||||
'if camera_state.locked or camera_state.front_camera then',
|
||||
)
|
||||
@@ -106,14 +225,22 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).toContain('local front_camera_view_mode = 0')
|
||||
expect(cameraClient).toContain('local front_camera_fov = 32.0')
|
||||
expect(cameraClient).toContain('local front_camera_distance = 1.05')
|
||||
expect(cameraClient).toContain('local front_camera_horizontal_limit = 75.0')
|
||||
expect(cameraClient).toContain('local front_camera_vertical_limit = 35.0')
|
||||
expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
|
||||
expect(cameraClient).toContain(
|
||||
'local dot = (to_camera.x * forward_vector.x)',
|
||||
)
|
||||
expect(cameraClient).toContain('GetDisabledControlNormal(0, 1)')
|
||||
expect(cameraClient).toContain('GetDisabledControlNormal(0, 2)')
|
||||
expect(cameraClient).toContain('update_front_camera_orbit()')
|
||||
expect(cameraClient).toContain('camera_state.front_camera_yaw')
|
||||
expect(cameraClient).toContain('camera_state.front_camera_pitch')
|
||||
expect(cameraClient).toContain('front_camera_target_height')
|
||||
expect(focusClient).toContain(
|
||||
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
|
||||
)
|
||||
expect(focusClient).toContain(
|
||||
'return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }',
|
||||
)
|
||||
expect(focusClient).toContain('gameInput = focus.game_input')
|
||||
expect(cameraClient).toContain('SetCamCoord(')
|
||||
expect(cameraClient).toContain('PointCamAtCoord(')
|
||||
expect(cameraClient).not.toContain('SetCamRot(')
|
||||
@@ -121,10 +248,6 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).not.toContain('front_camera_position')
|
||||
expect(cameraClient).not.toContain('AttachCamToEntity(')
|
||||
expect(cameraClient).not.toContain('PointCamAtEntity(')
|
||||
expect(cameraView).toContain("window.addEventListener('keyup', onKeyup)")
|
||||
expect(cameraView).toContain(
|
||||
"nuiCall('camera:setFocus', { focused: true })",
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a looping camera-hold pose instead of the old selfie dance', () => {
|
||||
|
||||
@@ -56,7 +56,6 @@ const microphoneEnabled = ref(true)
|
||||
const frontCamera = ref(false)
|
||||
const shutterActive = ref(false)
|
||||
const cameraLocked = ref(false)
|
||||
const movementEnabled = ref(false)
|
||||
const recording = ref(false)
|
||||
const savingVideo = ref(false)
|
||||
const recordingStartedAt = ref(0)
|
||||
@@ -78,6 +77,16 @@ const pendingCount = computed(
|
||||
() =>
|
||||
captures.value.filter((capture) => capture.status === 'uploading').length,
|
||||
)
|
||||
|
||||
function zoomPresetIsActive(zoom: (typeof zoomLevels)[number]): boolean {
|
||||
return Math.abs(selectedZoom.value - zoom) < 0.03
|
||||
}
|
||||
|
||||
function zoomPresetLabel(zoom: (typeof zoomLevels)[number]): string {
|
||||
if (zoomPresetIsActive(zoom)) return `${zoom}x`
|
||||
return zoom === 0.5 ? '.5' : `${zoom}`
|
||||
}
|
||||
|
||||
function correlationId(): string {
|
||||
return `${Date.now()}-${crypto.randomUUID()}`
|
||||
}
|
||||
@@ -238,10 +247,6 @@ async function toggleFacing(): Promise<void> {
|
||||
|
||||
async function toggleCameraLock(): Promise<void> {
|
||||
cameraLocked.value = !cameraLocked.value
|
||||
if (cameraLocked.value && movementEnabled.value) {
|
||||
movementEnabled.value = false
|
||||
await nuiCall('camera:setFocus', { focused: true })
|
||||
}
|
||||
await nuiCall('camera:setLocked', { locked: cameraLocked.value })
|
||||
}
|
||||
|
||||
@@ -340,26 +345,6 @@ function updateRecordingTimer(): void {
|
||||
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (
|
||||
event.code !== 'Space' ||
|
||||
event.repeat ||
|
||||
cameraLocked.value ||
|
||||
movementEnabled.value
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
movementEnabled.value = true
|
||||
void nuiCall('camera:setFocus', { focused: false })
|
||||
}
|
||||
|
||||
function onKeyup(event: KeyboardEvent): void {
|
||||
if (event.code !== 'Space' || !movementEnabled.value) return
|
||||
event.preventDefault()
|
||||
movementEnabled.value = false
|
||||
void nuiCall('camera:setFocus', { focused: true })
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
const message = event.data as {
|
||||
@@ -442,8 +427,6 @@ onMounted(() => {
|
||||
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
|
||||
'*',
|
||||
)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('keyup', onKeyup)
|
||||
window.addEventListener('message', onMessage)
|
||||
void nuiCall('camera:setActive', { active: true })
|
||||
void nuiCall<MediaConfig>('media:config').then((response) => {
|
||||
@@ -459,13 +442,7 @@ onBeforeUnmount(() => {
|
||||
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
|
||||
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('keyup', onKeyup)
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (movementEnabled.value) {
|
||||
movementEnabled.value = false
|
||||
void nuiCall('camera:setFocus', { focused: true })
|
||||
}
|
||||
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
|
||||
resizeObserver?.disconnect()
|
||||
gameView?.dispose()
|
||||
@@ -486,6 +463,7 @@ onBeforeUnmount(() => {
|
||||
class="camera-page"
|
||||
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
|
||||
:aria-label="phone.t('Apps.camera.name')"
|
||||
dark
|
||||
>
|
||||
<div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel">
|
||||
<canvas
|
||||
@@ -559,10 +537,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</sky-fab>
|
||||
</div>
|
||||
<span
|
||||
v-if="noticeText"
|
||||
class="camera-focus-pill camera-focus-pill--notice"
|
||||
>
|
||||
<span v-if="noticeText" class="camera-notice-text">
|
||||
{{ noticeText }}
|
||||
</span>
|
||||
<span v-else-if="pendingCount" class="camera-upload-pill">
|
||||
@@ -587,7 +562,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<LockKeyhole v-if="cameraLocked" :size="12" />
|
||||
<LockOpen v-else :size="12" />
|
||||
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
|
||||
<kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
|
||||
</SkyButton>
|
||||
<sky-fab
|
||||
component="button"
|
||||
@@ -618,16 +593,16 @@ onBeforeUnmount(() => {
|
||||
<SkyButton
|
||||
v-for="zoom in zoomLevels"
|
||||
:key="zoom"
|
||||
glass
|
||||
rounded
|
||||
variant="plain"
|
||||
class="camera-zoom-pill"
|
||||
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
|
||||
:class="{ active: zoomPresetIsActive(zoom) }"
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
|
||||
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
|
||||
:aria-pressed="zoomPresetIsActive(zoom)"
|
||||
@click="setZoom(zoom)"
|
||||
>
|
||||
{{ zoom }}x
|
||||
{{ zoomPresetLabel(zoom) }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -718,10 +693,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.camera-page {
|
||||
--sky-bg: #000;
|
||||
--sky-text: #fff;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--sky-bg);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
.camera-page--landscape {
|
||||
--sky-bg: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
.camera-viewport {
|
||||
position: absolute;
|
||||
@@ -733,11 +713,11 @@ onBeforeUnmount(() => {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.camera-page--landscape .camera-viewport {
|
||||
top: 46%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(100% * 16 / 9);
|
||||
width: calc(100% * var(--phone-screen-portrait-ratio));
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: var(--phone-screen-portrait-ratio);
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
.camera-game-view,
|
||||
@@ -795,6 +775,16 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none;
|
||||
background: linear-gradient(#0008, transparent 22%);
|
||||
}
|
||||
.camera-page--landscape .camera-shade {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.42) 0 18%,
|
||||
transparent 18% 75%,
|
||||
rgba(0, 0, 0, 0.42) 75% 100%
|
||||
),
|
||||
linear-gradient(#0008, transparent 22%);
|
||||
}
|
||||
.camera-flash {
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
@@ -855,25 +845,22 @@ onBeforeUnmount(() => {
|
||||
.camera-page--landscape .camera-latest svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.camera-focus-pill:not(.sky-button--glass),
|
||||
.camera-upload-pill {
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
.camera-notice-text,
|
||||
.camera-upload-pill {
|
||||
min-width: 0;
|
||||
color: #ffd60a;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
.camera-upload-pill {
|
||||
color: #ffd60a;
|
||||
}
|
||||
.camera-focus-pill--notice {
|
||||
color: #ffd60a;
|
||||
}
|
||||
.camera-lock-control {
|
||||
min-height: 44px;
|
||||
@@ -924,7 +911,7 @@ onBeforeUnmount(() => {
|
||||
.camera-zoom-control {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
bottom: 196px;
|
||||
bottom: 216px;
|
||||
left: 50%;
|
||||
width: auto;
|
||||
transform: translateX(-50%);
|
||||
@@ -932,16 +919,19 @@ onBeforeUnmount(() => {
|
||||
.camera-zoom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
.camera-zoom-pill {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
@@ -949,8 +939,20 @@ onBeforeUnmount(() => {
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
.camera-zoom-pill::before {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
inset-block: -4px;
|
||||
}
|
||||
.camera-zoom-pill.active {
|
||||
color: #ffd60a;
|
||||
background: rgba(28, 28, 30, 0.78);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.camera-zoom-pill:active:not(:disabled) {
|
||||
background: rgba(28, 28, 30, 0.42);
|
||||
}
|
||||
.camera-zoom-pill.active:active:not(:disabled) {
|
||||
background: rgba(28, 28, 30, 0.86);
|
||||
}
|
||||
.camera-controls {
|
||||
position: absolute;
|
||||
|
||||
@@ -40,6 +40,10 @@ describe('CityWarn product contract', () => {
|
||||
})
|
||||
|
||||
it('reserves the pill navigation, keeps sheets safe and avoids blur flicker', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-scroll\s*\{[^}]*min-height:\s*0;[^}]*height:\s*auto;[^}]*flex:\s*1 1 0;[^}]*overflow-y:\s*auto;/s,
|
||||
)
|
||||
expect(source).not.toMatch(/\.citywarn-scroll\s*\{[^}]*height:\s*100%;/s)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-scroll\.sky-scroll-area--tabbar\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 84px\)/s,
|
||||
)
|
||||
@@ -56,6 +60,51 @@ describe('CityWarn product contract', () => {
|
||||
expect(source).not.toContain('<span>{{ alert.title }}</span>')
|
||||
})
|
||||
|
||||
it('uses one spacing and radius system throughout the current feed', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-overview\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-overview \+ \.citywarn-feed\s*\{[^}]*margin-top:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-alert-card\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-publisher-card\s*\{[^}]*margin:\s*var\(--sky-space-3\) 0 0;[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-publisher-card :deep\(\.sky-card__content\)\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
|
||||
)
|
||||
for (const selector of [
|
||||
'citywarn-overview-symbol',
|
||||
'citywarn-card-icon',
|
||||
'citywarn-publisher-icon',
|
||||
]) {
|
||||
expect(source).toMatch(
|
||||
new RegExp(
|
||||
`\\.${selector}\\s*\\{[^}]*border-radius:\\s*var\\(--sky-radius-control\\);`,
|
||||
's',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps settings groups on one compact spacing rhythm', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings\s*\{[^}]*gap:\s*var\(--sky-space-5\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings :deep\(\.sky-settings-group\)\s*\{[^}]*margin:\s*0;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings :deep\(\.sky-settings-group__title\)\s*\{[^}]*margin:\s*0 var\(--sky-space-1\) var\(--sky-space-2\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings :deep\(\.sky-settings-group__footer\)\s*\{[^}]*margin:\s*var\(--sky-space-2\) var\(--sky-space-1\) 0;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps publishing authorization and validation on the server', () => {
|
||||
expect(server).toContain('Bridge.Framework.GetJob(source)')
|
||||
expect(server).toContain('SkyPhone.RequireSession(source)')
|
||||
|
||||
@@ -1026,7 +1026,9 @@ onMounted(async () => {
|
||||
height: 18px;
|
||||
}
|
||||
.citywarn-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
flex: 1 1 0;
|
||||
padding: 12px 13px calc(28px + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1045,11 +1047,11 @@ onMounted(async () => {
|
||||
}
|
||||
.citywarn-overview {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
padding: var(--sky-space-3);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--sky-space-3);
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--sky-radius-card);
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||||
}
|
||||
.citywarn-overview--active {
|
||||
@@ -1062,7 +1064,7 @@ onMounted(async () => {
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 15px;
|
||||
border-radius: var(--sky-radius-control);
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
}
|
||||
@@ -1086,12 +1088,17 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.citywarn-overview + .citywarn-feed {
|
||||
margin-top: var(--sky-space-3);
|
||||
gap: var(--sky-space-3);
|
||||
}
|
||||
.citywarn-alert-card {
|
||||
position: relative;
|
||||
padding: 13px 13px 12px 17px;
|
||||
padding: var(--sky-space-3) var(--sky-space-3) var(--sky-space-3)
|
||||
var(--sky-space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 17px;
|
||||
border-radius: var(--sky-radius-card);
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 15px rgb(15 23 42 / 5%);
|
||||
cursor: pointer;
|
||||
@@ -1115,7 +1122,7 @@ onMounted(async () => {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--sky-radius-control);
|
||||
color: var(--severity);
|
||||
background: color-mix(in srgb, var(--severity) 12%, white);
|
||||
}
|
||||
@@ -1139,7 +1146,7 @@ onMounted(async () => {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.citywarn-alert-card h3 {
|
||||
margin: 10px 0 5px;
|
||||
margin: var(--sky-space-2) 0 var(--sky-space-1);
|
||||
font-size: 16px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
@@ -1155,10 +1162,10 @@ onMounted(async () => {
|
||||
}
|
||||
.citywarn-alert-card footer {
|
||||
display: flex;
|
||||
margin-top: 11px;
|
||||
margin-top: var(--sky-space-2);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: var(--sky-space-2);
|
||||
color: #6b7280;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
@@ -1172,12 +1179,13 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.citywarn-publisher-card {
|
||||
margin-top: 12px;
|
||||
margin: var(--sky-space-3) 0 0;
|
||||
border-radius: var(--sky-radius-card);
|
||||
}
|
||||
.citywarn-publisher-card :deep(.sky-card__content) {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
padding: var(--sky-space-3);
|
||||
gap: var(--sky-space-3);
|
||||
}
|
||||
.citywarn-publisher-icon {
|
||||
display: grid;
|
||||
@@ -1185,7 +1193,7 @@ onMounted(async () => {
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
border-radius: var(--sky-radius-control);
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
@@ -1523,11 +1531,20 @@ onMounted(async () => {
|
||||
.citywarn-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
gap: var(--sky-space-5);
|
||||
}
|
||||
.citywarn-settings :deep(.sky-settings-group) {
|
||||
margin: 0;
|
||||
}
|
||||
.citywarn-settings :deep(.sky-settings-group__title) {
|
||||
margin: 0 var(--sky-space-1) var(--sky-space-2);
|
||||
}
|
||||
.citywarn-settings :deep(.sky-settings-group__footer) {
|
||||
margin: var(--sky-space-2) var(--sky-space-1) 0;
|
||||
}
|
||||
.citywarn-settings-hint {
|
||||
display: flex;
|
||||
margin: 0 8px;
|
||||
margin: 0 var(--sky-space-1);
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
color: #6b7280;
|
||||
|
||||
@@ -369,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(testServer).toContain('function advanceCryptoCycle(')
|
||||
})
|
||||
|
||||
it('does not keep market and settlement workers active while disabled', () => {
|
||||
expect(server).toContain('local function start_crypto_schedulers()')
|
||||
expect(server).toMatch(
|
||||
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
|
||||
)
|
||||
expect(server).toContain(
|
||||
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
|
||||
)
|
||||
expect(server).not.toMatch(
|
||||
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('stores cash in price-scale minor units throughout the ledger', () => {
|
||||
expect(server).toContain(
|
||||
'local ledger_amount = amount * Config.Crypto.PriceScale',
|
||||
|
||||
@@ -6,6 +6,14 @@ const source = readFileSync(
|
||||
new URL('./DarkChatApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('DarkChatApp Sky UI contract', () => {
|
||||
it('uses first-party Sky UI without direct Konsta markup', () => {
|
||||
@@ -130,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans expired messages in bounded indexed batches', () => {
|
||||
expect(config).toContain('CleanupBatchSize = 250')
|
||||
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
|
||||
expect(server).toMatch(
|
||||
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
|
||||
)
|
||||
expect(server).toContain(']], { batch_size })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
expect(sheetStart).toBeGreaterThan(-1)
|
||||
expect(sheetEnd).toBeGreaterThan(sheetStart)
|
||||
expect(sheet).toContain(
|
||||
':opened="activeCanMessage && attachmentPicker !== null"',
|
||||
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
|
||||
)
|
||||
expect(sheet).toContain('swipe-to-close')
|
||||
expect(sheet).toContain('grabber-clickable')
|
||||
|
||||
@@ -215,6 +215,9 @@ const activeContact = computed(() =>
|
||||
const activeCanMessage = computed(
|
||||
() => activeContact.value?.canMessage !== false,
|
||||
)
|
||||
const activeServiceLine = computed(
|
||||
() => activeContact.value?.source === 'company',
|
||||
)
|
||||
const activeContactEmail = computed(() =>
|
||||
normalizeMailAddress(activeContact.value?.email ?? ''),
|
||||
)
|
||||
@@ -255,12 +258,15 @@ const inboxMenuItems = computed(() => [
|
||||
},
|
||||
])
|
||||
const attachmentPanelOpen = computed(
|
||||
() => emojiOpen.value || attachmentPicker.value !== null,
|
||||
() =>
|
||||
emojiOpen.value ||
|
||||
(!activeServiceLine.value && attachmentPicker.value !== null),
|
||||
)
|
||||
const composerHasContent = computed(
|
||||
() =>
|
||||
Boolean(draft.value.trim() || shareDraft.value) ||
|
||||
pendingAttachments.value.length > 0,
|
||||
Boolean(draft.value.trim()) ||
|
||||
(!activeServiceLine.value &&
|
||||
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
|
||||
)
|
||||
function contactName(number: string): string {
|
||||
return (
|
||||
@@ -417,6 +423,8 @@ function errorText(error?: string): string {
|
||||
'gif_provider_failed',
|
||||
'self_message',
|
||||
'recipient_not_found',
|
||||
'messaging_unavailable',
|
||||
'service_line_text_only',
|
||||
'no_sim',
|
||||
'rate_limited',
|
||||
'blocked',
|
||||
@@ -614,6 +622,7 @@ async function callActiveContact(): Promise<void> {
|
||||
}
|
||||
|
||||
function toggleAttachmentMenu(): void {
|
||||
if (activeServiceLine.value) return
|
||||
attachmentMenuOpen.value = !attachmentMenuOpen.value
|
||||
emojiOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
@@ -646,7 +655,7 @@ function openMediaApp(
|
||||
app: 'camera' | 'photos',
|
||||
mediaType: 'photo' | 'video',
|
||||
): void {
|
||||
if (!messages.activeNumber) return
|
||||
if (!messages.activeNumber || activeServiceLine.value) return
|
||||
const remainingSlots =
|
||||
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
|
||||
if (remainingSlots < 1) {
|
||||
@@ -707,7 +716,14 @@ async function sendAttachment(
|
||||
mediaAssetId: string,
|
||||
mediaDurationMs?: number,
|
||||
): Promise<void> {
|
||||
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
|
||||
if (
|
||||
!messages.activeNumber ||
|
||||
!activeCanMessage.value ||
|
||||
activeServiceLine.value ||
|
||||
sending.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
@@ -722,7 +738,7 @@ async function sendAttachment(
|
||||
}
|
||||
|
||||
async function sendContact(contact: PhoneContact): Promise<void> {
|
||||
if (!messages.activeNumber || sending.value) return
|
||||
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
@@ -885,7 +901,12 @@ function sampleMicrophone(): void {
|
||||
}
|
||||
|
||||
async function startVoiceRecording(): Promise<void> {
|
||||
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
|
||||
if (
|
||||
!activeCanMessage.value ||
|
||||
activeServiceLine.value ||
|
||||
recording.value ||
|
||||
recordingStarting.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
emojiOpen.value = false
|
||||
@@ -1041,6 +1062,27 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(activeServiceLine, (serviceLine) => {
|
||||
if (!serviceLine) return
|
||||
const discarded = Boolean(
|
||||
shareDraft.value ||
|
||||
pendingAttachments.value.length ||
|
||||
recording.value ||
|
||||
recordingStarting.value,
|
||||
)
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
shareDraft.value = null
|
||||
pendingAttachments.value = []
|
||||
if (recording.value) {
|
||||
discardRecording = true
|
||||
cancelVoiceRecording()
|
||||
} else if (recordingStarting.value) {
|
||||
cleanupRecorder()
|
||||
}
|
||||
if (discarded) showToast(errorText('service_line_text_only'))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
discardRecording = true
|
||||
cleanupRecorder()
|
||||
@@ -1614,7 +1656,7 @@ onBeforeUnmount(() => {
|
||||
</SkyScrollArea>
|
||||
|
||||
<section
|
||||
v-if="activeCanMessage && attachmentMenuOpen"
|
||||
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
|
||||
class="messages-attachment-menu"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1659,7 +1701,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkySheet
|
||||
class="messages-media-picker-sheet"
|
||||
:opened="activeCanMessage && attachmentPicker !== null"
|
||||
:opened="
|
||||
activeCanMessage && !activeServiceLine && attachmentPicker !== null
|
||||
"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
attachmentPicker === 'contacts'
|
||||
@@ -1794,7 +1838,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="activeCanMessage && shareDraft && !recording"
|
||||
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
|
||||
class="shared-composer-preview"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1810,7 +1854,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="activeCanMessage && recording"
|
||||
v-if="activeCanMessage && !activeServiceLine && recording"
|
||||
class="messages-recorder"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1898,6 +1942,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="messages-sky-composer-row">
|
||||
<SkyGlass
|
||||
v-if="!activeServiceLine"
|
||||
component="button"
|
||||
type="button"
|
||||
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
|
||||
@@ -1934,7 +1979,7 @@ onBeforeUnmount(() => {
|
||||
<ArrowUpCircle :size="29" :stroke-width="2.4" />
|
||||
</SkyLink>
|
||||
<SkyLink
|
||||
v-else
|
||||
v-else-if="!activeServiceLine"
|
||||
icon-only
|
||||
class="messages-sky-messagebar__send"
|
||||
:disabled="sending || recordingStarting"
|
||||
|
||||
@@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
it('uses the shared full-width Sky tab bar for phone sections', () => {
|
||||
expect(source).toContain('<sky-tab-bar')
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
expect(source).toContain(
|
||||
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses shared interactive liquid glass surfaces for phone controls', () => {
|
||||
@@ -57,6 +63,21 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
expect(source).not.toContain('rgba(10, 132, 255')
|
||||
})
|
||||
|
||||
it('keeps contact profiles readable in light mode', () => {
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light\.phone-calls-app--profile\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-bg\) !important;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light \.phone-profile-action\s*\{[^}]*color:\s*var\(--sky-text\) !important;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light \.phone-profile-action:disabled\s*\{[^}]*color:\s*var\(--sky-subtle\) !important;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light \.phone-profile-card,[\s\S]*?\.phone-app--light \.phone-history-card\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-glass\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
|
||||
const mounted = source.slice(
|
||||
source.indexOf('onMounted(async () => {'),
|
||||
|
||||
@@ -2250,7 +2250,6 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
padding-top: 6px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.phone-contacts-header {
|
||||
@@ -3859,6 +3858,63 @@ onBeforeUnmount(() => {
|
||||
background: var(--sky-glass);
|
||||
}
|
||||
|
||||
.phone-app--light.phone-calls-app--profile {
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-bg) !important;
|
||||
}
|
||||
|
||||
.phone-app--light .phone-call-content--profile,
|
||||
.phone-app--light .phone-contact-hero h2,
|
||||
.phone-app--light .phone-detail-header-button,
|
||||
.phone-app--light .phone-detail-edit {
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-contact-hero .phone-contact-organization,
|
||||
.phone-app--light .phone-profile-notes > p,
|
||||
.phone-app--light .phone-history-copy small,
|
||||
.phone-app--light .phone-history-row time,
|
||||
.phone-app--light .phone-history-empty {
|
||||
color: var(--sky-muted);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-action {
|
||||
color: var(--sky-text) !important;
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-action:disabled {
|
||||
color: var(--sky-subtle) !important;
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-card,
|
||||
.phone-app--light .phone-own-profile-card,
|
||||
.phone-app--light .phone-history-card {
|
||||
border-color: var(--sky-hairline);
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-glass);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-own-profile-card > div:not(:last-child),
|
||||
.phone-app--light .phone-profile-info-card > button,
|
||||
.phone-app--light .phone-profile-options-card > button,
|
||||
.phone-app--light .phone-profile-single-option > button,
|
||||
.phone-app--light .phone-profile-notes,
|
||||
.phone-app--light .phone-history-row {
|
||||
border-color: var(--sky-hairline);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-own-profile-card > div > span {
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-pressed);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-info-card > button > svg,
|
||||
.phone-app--light .phone-profile-notes > span,
|
||||
.phone-app--light .phone-history-card h3 {
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-bottom-tabbar {
|
||||
--sky-app-accent: #3a3a3c;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,52 @@ describe('SkyRide app layout', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('scrolls Ride and Drive away without full-width nav backgrounds', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-navbar :deep\(\.sky-navbar__blur\),\s*\.skyride-navbar :deep\(\.sky-navbar__background\)\s*\{[^}]*display:\s*none;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/<div class="skyride-scroll">\s*<div class="skyride-mode">/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-scroll\s*\{[^}]*box-sizing:\s*border-box;[^}]*inset:\s*114px 0 0;[^}]*padding:\s*0 0 116px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-tabbar :deep\(\.sky-tabbar__blur\),\s*\.skyride-tabbar :deep\(\.sky-tabbar__background\)\s*\{[^}]*display:\s*none;/s,
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.skyride-tabbar :deep\(\.sky-tabbar__pane\)\s*\{\s*border:/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps active-ride card spacing even and outline actions contrasted', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-ride-status-card\s*\{[^}]*margin-bottom:\s*12px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-person-card\s*\{[^}]*margin-bottom:\s*12px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-trip-card\s*\{[^}]*margin-bottom:\s*12px;/s,
|
||||
)
|
||||
expect(source).toContain('.sky-button--primary:not(.sky-button--outline)')
|
||||
expect(source).not.toMatch(
|
||||
/\.skyride-app :deep\(\.sky-button--primary\)\s*\{/,
|
||||
)
|
||||
})
|
||||
|
||||
it('aligns pickup and destination text to one timeline axis', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-route-stop\s*\{[^}]*grid-template-columns:\s*18px minmax\(0, 1fr\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-route-stop > \.skyride-dot\s*\{[^}]*justify-self:\s*center;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-trip-card > i\s*\{[^}]*margin:\s*1px 0 1px 8px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a compact swipeable profile editor with equal actions', () => {
|
||||
expect(source).toContain('class="skyride-profile-sheet"')
|
||||
expect(source).toContain('swipe-to-close')
|
||||
|
||||
@@ -622,26 +622,26 @@ onBeforeUnmount(() => {
|
||||
</k-card>
|
||||
|
||||
<template v-else>
|
||||
<div class="skyride-mode">
|
||||
<k-segmented v-if="skyride.driverEligible">
|
||||
<k-segmented-button
|
||||
:active="mode === 'rider'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'rider'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.rider') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="mode === 'driver'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'driver'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.driver') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</div>
|
||||
|
||||
<div class="skyride-scroll">
|
||||
<div class="skyride-mode">
|
||||
<k-segmented v-if="skyride.driverEligible">
|
||||
<k-segmented-button
|
||||
:active="mode === 'rider'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'rider'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.rider') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="mode === 'driver'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'driver'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.driver') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</div>
|
||||
|
||||
<template v-if="activeTab === 'home'">
|
||||
<section v-if="mode === 'rider'" class="skyride-home-panel">
|
||||
<template v-if="!skyride.activeRide">
|
||||
@@ -1816,6 +1816,11 @@ onBeforeUnmount(() => {
|
||||
color: var(--ride-text);
|
||||
}
|
||||
|
||||
.skyride-navbar :deep(.sky-navbar__blur),
|
||||
.skyride-navbar :deep(.sky-navbar__background) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skyride-navbar :deep(.sky-navbar__title) {
|
||||
color: var(--ride-text);
|
||||
font-size: 18px;
|
||||
@@ -1853,7 +1858,9 @@ onBeforeUnmount(() => {
|
||||
.skyride-scroll {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 148px 0 116px;
|
||||
box-sizing: border-box;
|
||||
inset: 114px 0 0;
|
||||
padding: 0 0 116px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
@@ -2253,6 +2260,10 @@ onBeforeUnmount(() => {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.skyride-person-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skyride-avatar,
|
||||
.skyride-profile-avatar {
|
||||
display: grid;
|
||||
@@ -2329,13 +2340,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.skyride-route-stop {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
column-gap: 11px;
|
||||
}
|
||||
|
||||
.skyride-route-stop > .skyride-dot {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.skyride-route-stop > svg {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--ride-accent-strong);
|
||||
}
|
||||
|
||||
@@ -2366,7 +2383,7 @@ onBeforeUnmount(() => {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 17px;
|
||||
margin: 1px 0 1px 5px;
|
||||
margin: 1px 0 1px 8px;
|
||||
border-left: 1px dashed var(--ride-muted);
|
||||
}
|
||||
|
||||
@@ -2691,6 +2708,11 @@ button.skyride-profile-avatar--editable {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.skyride-tabbar :deep(.sky-tabbar__blur),
|
||||
.skyride-tabbar :deep(.sky-tabbar__background) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skyride-tab-pane {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
@@ -2969,7 +2991,7 @@ button.skyride-profile-avatar--editable {
|
||||
background-color: var(--ride-accent);
|
||||
}
|
||||
|
||||
.skyride-app :deep(.sky-button--primary) {
|
||||
.skyride-app :deep(.sky-button--primary:not(.sky-button--outline)) {
|
||||
color: #171719;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('phone apps use Sky UI', () => {
|
||||
|
||||
it('uses shared liquid glass for remaining compact interaction controls', () => {
|
||||
const minimumGlassButtons: Record<string, number> = {
|
||||
'CameraApp.vue': 2,
|
||||
'CameraApp.vue': 1,
|
||||
'FeatherApp.vue': 2,
|
||||
'FlareApp.vue': 1,
|
||||
'FlipTokApp.vue': 2,
|
||||
|
||||
@@ -116,6 +116,20 @@ describe('voice provider contracts', () => {
|
||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||
})
|
||||
|
||||
it('supports explicit automatic call-provider discovery on client and server', () => {
|
||||
expect(config).toContain(
|
||||
'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)',
|
||||
)
|
||||
expect(clientCalls).toContain('if configured == "auto" then')
|
||||
expect(serverVoice).toContain('if configured == "auto" then')
|
||||
expect(clientCalls).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes Yaca radio volume arguments in the documented order', () => {
|
||||
expect(clientRadio).toContain(
|
||||
'changeRadioChannelVolumeRaw(volume / 100, 1)',
|
||||
|
||||
@@ -476,6 +476,66 @@ function emptyStructure(scope, path) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function companyDefinitionEntryDefault(definitions) {
|
||||
const firstDefinition = definitions[Object.keys(definitions).sort()[0]]
|
||||
const usedNumbers = new Set(
|
||||
Object.values(definitions).map((definition) =>
|
||||
String(definition.ServiceLine.Number).replace(/\D/g, ''),
|
||||
),
|
||||
)
|
||||
let serviceNumber = ''
|
||||
for (let offset = 0; offset < 999; offset += 1) {
|
||||
const candidate = String(((499 + offset) % 999) + 1).padStart(3, '0')
|
||||
if (!usedNumbers.has(candidate)) {
|
||||
serviceNumber = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
AcceptsRequests: true,
|
||||
Address: '',
|
||||
Category: firstDefinition.Category,
|
||||
DefaultAvailability: 'closed',
|
||||
Description: '',
|
||||
District: '',
|
||||
Emergency: false,
|
||||
Icon: 'building',
|
||||
Job: '',
|
||||
Location: { __skyType: 'vector3', x: 0, y: 0, z: 0 },
|
||||
LocationLabel: '',
|
||||
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
|
||||
Name: '',
|
||||
Permissions: {
|
||||
Announcement: 0,
|
||||
Assign: 0,
|
||||
Availability: 0,
|
||||
Hours: 0,
|
||||
Profile: 0,
|
||||
Services: 0,
|
||||
WorkQueue: 0,
|
||||
},
|
||||
Public: true,
|
||||
ServiceLine: {
|
||||
AutoContact: true,
|
||||
CanCall: true,
|
||||
CanMessage: false,
|
||||
MinimumGrade: 0,
|
||||
Number: serviceNumber,
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Description: '',
|
||||
Id: '',
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: '',
|
||||
},
|
||||
],
|
||||
Verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
function buildStructure(value, scope, path) {
|
||||
if (scope === 'config' && path === 'Phone.Keybind') {
|
||||
return { kind: 'optionalString' }
|
||||
@@ -495,6 +555,7 @@ function buildStructure(value, scope, path) {
|
||||
]),
|
||||
)
|
||||
return {
|
||||
entryDefault: companyDefinitionEntryDefault(value),
|
||||
fields,
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
@@ -661,6 +722,7 @@ function loadConfiguratorSections() {
|
||||
)
|
||||
const media = mediaRoot.Media
|
||||
delete config.PhoneConfigurator
|
||||
delete config.CommandPermissions
|
||||
delete config.Media
|
||||
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type ConfiguratorField = {
|
||||
}
|
||||
|
||||
type ConfiguratorStructure = {
|
||||
entryDefault?: unknown
|
||||
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||
fields?: Record<string, ConfiguratorStructure>
|
||||
items?: ConfiguratorStructure[]
|
||||
@@ -61,16 +62,21 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
|
||||
}
|
||||
|
||||
describe('admin configurator fixture', () => {
|
||||
it('exposes every config.lua root through the full live preview', () => {
|
||||
it('exposes every SQL-managed config.lua root through the full live preview', () => {
|
||||
const sections = loadConfiguratorSections()
|
||||
const fields = sections.flatMap((section) => section.fields)
|
||||
const roots = [
|
||||
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator')
|
||||
.filter(
|
||||
(root) =>
|
||||
root !== 'Media' &&
|
||||
root !== 'PhoneConfigurator' &&
|
||||
root !== 'CommandPermissions',
|
||||
)
|
||||
|
||||
expect(sections).toHaveLength(45)
|
||||
expect(sections).toHaveLength(46)
|
||||
expect(
|
||||
fields.reduce(
|
||||
(total, field) => total + countStructure(field.structure),
|
||||
@@ -117,6 +123,22 @@ describe('admin configurator fixture', () => {
|
||||
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
|
||||
expect(companyJobs?.label).toBe('Jobs')
|
||||
expect(companyJobs?.structure).toMatchObject({
|
||||
entryDefault: {
|
||||
AcceptsRequests: true,
|
||||
Category: 'public_services',
|
||||
Job: '',
|
||||
ServiceLine: {
|
||||
Number: '500',
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Id: '',
|
||||
RequestsEnabled: true,
|
||||
Title: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
ambulance: { kind: 'table' },
|
||||
police: { kind: 'table' },
|
||||
@@ -130,6 +152,47 @@ describe('admin configurator fixture', () => {
|
||||
).toBe('map')
|
||||
})
|
||||
|
||||
it('exposes the requestable police assistance defaults', () => {
|
||||
const companyJobs = loadConfiguratorSections()
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === 'Companies.Definitions')
|
||||
const police = (
|
||||
companyJobs?.value as Record<string, Record<string, unknown>> | undefined
|
||||
)?.police
|
||||
|
||||
expect(police).toMatchObject({
|
||||
AcceptsRequests: true,
|
||||
Emergency: true,
|
||||
Services: [
|
||||
{
|
||||
Id: 'police-assistance',
|
||||
RequestsEnabled: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(
|
||||
companyJobs?.structure?.fields?.police.fields?.Services,
|
||||
).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
fields: {
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes fixed schemas for every empty configurable collection', () => {
|
||||
const fields = loadConfiguratorSections().flatMap(
|
||||
(section) => section.fields,
|
||||
@@ -178,7 +241,7 @@ describe('admin configurator fixture', () => {
|
||||
template: { kind: 'value', valueType: 'string' },
|
||||
})
|
||||
expect(
|
||||
root('Companies.Definitions')?.fields?.police.fields?.Services,
|
||||
root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
|
||||
).toMatchObject({
|
||||
items: [],
|
||||
kind: 'list',
|
||||
|
||||
@@ -1978,7 +1978,7 @@ const contacts = [
|
||||
},
|
||||
{
|
||||
canCall: true,
|
||||
canMessage: false,
|
||||
canMessage: true,
|
||||
companyId: 'police',
|
||||
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
id: 'company:police',
|
||||
@@ -4178,7 +4178,7 @@ const companyCategories = [
|
||||
let companyCallAvailable = false
|
||||
const companyProfiles = [
|
||||
{
|
||||
acceptsRequests: false,
|
||||
acceptsRequests: true,
|
||||
announcement: {
|
||||
body: 'Community traffic unit active around Legion Square.',
|
||||
expiresAt: isoTime(6 * 60 * 60 * 1000),
|
||||
@@ -4187,7 +4187,7 @@ const companyProfiles = [
|
||||
availability: 'available',
|
||||
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
|
||||
canCall: true,
|
||||
canMessage: false,
|
||||
canMessage: true,
|
||||
categoryId: 'public_services',
|
||||
categoryName: 'Public Services',
|
||||
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
|
||||
@@ -4202,28 +4202,20 @@ const companyProfiles = [
|
||||
label: 'Mission Row Police Station',
|
||||
},
|
||||
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
name: 'Los Santos Police',
|
||||
name: 'Los Santos Police Department',
|
||||
phoneNumber: '911',
|
||||
revision: 3,
|
||||
services: [
|
||||
{
|
||||
acceptsRequests: false,
|
||||
acceptsRequests: true,
|
||||
active: true,
|
||||
description: 'Immediate police response through the service line.',
|
||||
id: 'emergency-response',
|
||||
description: 'Request non-emergency police assistance.',
|
||||
id: 'police-assistance',
|
||||
priceText: null,
|
||||
title: 'Emergency Response',
|
||||
},
|
||||
{
|
||||
acceptsRequests: false,
|
||||
active: true,
|
||||
description: 'General information and non-emergency assistance.',
|
||||
id: 'public-assistance',
|
||||
priceText: null,
|
||||
title: 'Public Assistance',
|
||||
title: 'Police Assistance',
|
||||
},
|
||||
],
|
||||
serviceSummary: 'Emergency response and public assistance',
|
||||
serviceSummary: 'Non-emergency police assistance',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
@@ -4726,6 +4718,7 @@ function adminMockBootstrap() {
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
disabledApps: [],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
@@ -4740,7 +4733,18 @@ function adminMockBootstrap() {
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
stats: {
|
||||
accounts: 24,
|
||||
activeDevices: 19,
|
||||
auditEntries: 37,
|
||||
auditToday: 4,
|
||||
callsToday: 18,
|
||||
devices: 31,
|
||||
linkedDevices: 22,
|
||||
messagesToday: 146,
|
||||
online: 2,
|
||||
simDevices: 27,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
- `hex_4_inventory`
|
||||
- Native ESX inventory
|
||||
|
||||
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled.
|
||||
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -211,6 +212,18 @@ The files contain clearly separated sections for:
|
||||
|
||||
Restart `sky_phone` after changing Lua configuration.
|
||||
|
||||
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
|
||||
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
|
||||
server-only `media.lua`; do not edit the generated snapshot directly.
|
||||
|
||||
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
|
||||
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
|
||||
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
|
||||
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
|
||||
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
|
||||
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
|
||||
`sky_phone` after changing fixed permissions.
|
||||
|
||||
### Language
|
||||
|
||||
Available locales:
|
||||
@@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
|
||||
+79
-12
@@ -10,12 +10,27 @@
|
||||
Keep option names unchanged. Restart sky_phone after editing this file.
|
||||
]]
|
||||
|
||||
-- When enabled, config.lua and media.lua only provide first-run defaults.
|
||||
-- The active configuration is loaded from SQL and managed through /phonepanel.
|
||||
-- CONFIG_DEFAULT_EXCLUDE_START
|
||||
-- When enabled, the active configuration is loaded from SQL and managed through
|
||||
-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and
|
||||
-- media.lua into source/shared/config_default.lua.
|
||||
Config.PhoneConfigurator = {
|
||||
Enabled = true,
|
||||
}
|
||||
|
||||
-- Fixed server permissions. These values remain authoritative even while the
|
||||
-- Phone Configurator is enabled and are intentionally not shown in its panel.
|
||||
-- Group names use the active framework's permissions. On Qbox they also match
|
||||
-- ACE objects such as "admin" from the standard permissions.cfg.
|
||||
Config.CommandPermissions = {
|
||||
phonepanel = { "god", "superadmin", "admin" },
|
||||
phonetestdata = { "god", "superadmin", "admin" },
|
||||
fliptokverify = { "god", "superadmin", "admin" },
|
||||
picstagramverify = { "god", "superadmin", "admin" },
|
||||
picstagramadmin = { "god", "superadmin", "admin" },
|
||||
}
|
||||
-- CONFIG_DEFAULT_EXCLUDE_END
|
||||
|
||||
-- =============================================================================
|
||||
-- Core, framework and device
|
||||
-- =============================================================================
|
||||
@@ -34,8 +49,9 @@ Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -45,11 +61,56 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
}
|
||||
|
||||
Config.CustomApps = {
|
||||
@@ -76,7 +137,6 @@ Config.Security = {
|
||||
Config.AdminPanel = {
|
||||
Enabled = true,
|
||||
Command = "phonepanel",
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
MaximumPlayers = 128,
|
||||
ReadRequestsPerMinute = 60,
|
||||
ActionRequestsPerMinute = 30,
|
||||
@@ -86,7 +146,7 @@ Config.AdminPanel = {
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
NumberLength = 10, -- total number of digits, including NumberPrefix
|
||||
@@ -103,7 +163,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -288,6 +348,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
@@ -485,7 +546,6 @@ Config.FlipTok = {
|
||||
MaxPostMedia = 10,
|
||||
MusicTracks = {},
|
||||
VerifyCommand = "fliptokverify",
|
||||
AdminGroups = { "admin" },
|
||||
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
|
||||
}
|
||||
|
||||
@@ -507,7 +567,6 @@ Config.Picstagram = {
|
||||
ReportDetailsMaxLength = 500,
|
||||
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
|
||||
VerifyCommand = "picstagramverify",
|
||||
AdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Feather = {
|
||||
@@ -1122,7 +1181,7 @@ if IsDuplicityVersion() then
|
||||
LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180",
|
||||
Description = "Public safety, emergency response, and police services.",
|
||||
DefaultAvailability = "closed",
|
||||
AcceptsRequests = false,
|
||||
AcceptsRequests = true,
|
||||
District = "Mission Row",
|
||||
LocationLabel = "Mission Row Police Station",
|
||||
Address = "Mission Row Police Station",
|
||||
@@ -1131,7 +1190,7 @@ if IsDuplicityVersion() then
|
||||
Number = "911",
|
||||
AutoContact = true,
|
||||
CanCall = true,
|
||||
CanMessage = false,
|
||||
CanMessage = true,
|
||||
Routing = "round_robin",
|
||||
MinimumGrade = 0,
|
||||
},
|
||||
@@ -1144,7 +1203,15 @@ if IsDuplicityVersion() then
|
||||
Services = 3,
|
||||
Announcement = 3,
|
||||
},
|
||||
Services = {},
|
||||
Services = {
|
||||
{
|
||||
Id = "police-assistance",
|
||||
Title = "Police assistance",
|
||||
Description = "Request non-emergency police assistance.",
|
||||
Price = "",
|
||||
RequestsEnabled = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ambulance = {
|
||||
Job = "ambulance",
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["de"] = {
|
||||
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
|
||||
phone_required = "Du benötigst ein Handy in deinem Inventar.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
|
||||
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
|
||||
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
default = "Das Handy konnte nicht geöffnet werden.",
|
||||
},
|
||||
@@ -205,7 +206,7 @@ Locales["de"] = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
@@ -221,7 +222,7 @@ Locales["de"] = {
|
||||
},
|
||||
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt", save_configuration = "Konfiguration gespeichert" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App- oder Konfigurationsänderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
|
||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", invalid_company_configuration = "Die Firmenkonfiguration ist unvollständig oder enthält einen doppelten Job, eine doppelte Servicenummer oder Service-ID.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
@@ -694,6 +695,7 @@ Locales["de"] = {
|
||||
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
|
||||
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
|
||||
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
|
||||
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
|
||||
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
|
||||
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
|
||||
},
|
||||
@@ -1227,7 +1229,7 @@ Locales["de"] = {
|
||||
},
|
||||
skyride = {
|
||||
name = "SkyRide", loading = "Laden SkyRide", unavailable = "SkyRide nicht verfügbar", tryAgain = "Erneut versuchen",
|
||||
mode = { rider = "Reiten", driver = "Antrieb" },
|
||||
mode = { rider = "Passagier", driver = "Fahrer" },
|
||||
riderEyebrow = "Fahr irgendwohin.", whereTo = "Wohin?", pickup = "Abholung", destination = "Ziel",
|
||||
currentLocation = "Aktueller Standort", notSelected = "Nicht ausgewählt", location = "Wähle einen Ort",
|
||||
chooseLocation = { pickup = "Abholvorgang einstellen", destination = "Ziel auswählen" },
|
||||
@@ -1448,8 +1450,8 @@ Locales["de"] = {
|
||||
camera = {
|
||||
name = "Kamera", flash = "Blitz", flip = "Kamera wechseln", landscape = "Zum Querformat wechseln",
|
||||
portrait = "Zum Hochformat wechseln", photo = "Foto", video = "Video", microphoneOn = "Mikrofon eingeschaltet", microphoneOff = "Mikrofon stumm",
|
||||
focusHelp = "Halte die Leertaste gedrückt, um dich umzusehen", returnHelp = "Leertaste für Steuerung", lockCamera = "Kamerabewegung sperren",
|
||||
unlockCamera = "Kamerabewegung entsperren", spaceKey = "Leertaste", uploading = "Upload: {count}",
|
||||
focusHelp = "Halte die Umschautaste gedrückt, um dich umzusehen", returnHelp = "Umschautaste loslassen für Steuerung", lockCamera = "Kamerabewegung sperren",
|
||||
unlockCamera = "Kamerabewegung entsperren", lookKey = "Umschauen", uploading = "Upload: {count}",
|
||||
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
|
||||
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
|
||||
zoom = "Kamerazoom auf {zoom} einstellen",
|
||||
@@ -2139,7 +2141,6 @@ Locales["de"] = {
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Admin-Gruppen",
|
||||
Dictionaries = "Animationsdateien",
|
||||
Clips = "Clips",
|
||||
Transforms = "Transformationen",
|
||||
@@ -2176,29 +2177,3 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Erlaubte Jobs",
|
||||
Websites = "Webseiten",
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
|
||||
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
|
||||
Locales["de"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Eigene Farbe",
|
||||
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX-Farbe",
|
||||
value = "Wert",
|
||||
fontFamily = "Schriftart",
|
||||
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
|
||||
fontSize = "Schriftgröße",
|
||||
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Klassisch",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["en"] = {
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
phone_required = "You need a phone in your inventory.",
|
||||
operation_in_progress = "Another phone operation is already in progress.",
|
||||
rate_limited = "The phone was opened too often. Try again in a moment.",
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
@@ -205,7 +206,7 @@ Locales["en"] = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
@@ -221,7 +222,7 @@ Locales["en"] = {
|
||||
},
|
||||
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset", save_configuration = "Configuration saved" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app or configuration changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
|
||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", invalid_company_configuration = "The company configuration is incomplete or contains a duplicate job, service number, or service ID.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
@@ -694,6 +695,7 @@ Locales["en"] = {
|
||||
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
|
||||
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
|
||||
messaging_unavailable = "This company contact does not accept messages.",
|
||||
service_line_text_only = "Company service lines currently accept text messages only.",
|
||||
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
|
||||
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
|
||||
},
|
||||
@@ -1448,8 +1450,8 @@ Locales["en"] = {
|
||||
camera = {
|
||||
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
|
||||
portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted",
|
||||
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
|
||||
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading",
|
||||
focusHelp = "Hold the look key to look around", returnHelp = "Release the look key for controls", lockCamera = "Lock camera movement",
|
||||
unlockCamera = "Unlock camera movement", lookKey = "Look", uploading = "{count} uploading",
|
||||
saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
|
||||
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
|
||||
zoom = "Set camera zoom to {zoom}",
|
||||
@@ -2139,7 +2141,6 @@ Locales["en"] = {
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Admin Groups",
|
||||
Dictionaries = "Dictionaries",
|
||||
Clips = "Clips",
|
||||
Transforms = "Transforms",
|
||||
@@ -2176,29 +2177,3 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Allowed Jobs",
|
||||
Websites = "Websites",
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
|
||||
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
|
||||
Locales["en"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Custom color",
|
||||
customColorBody = "Choose any RGB accent or enter its channel values.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX color",
|
||||
value = "value",
|
||||
fontFamily = "Font family",
|
||||
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
|
||||
fontSize = "Font size",
|
||||
fontSizeBody = "Scale text and controls for comfortable readability.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Classic",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["es"] = {
|
||||
invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.",
|
||||
phone_required = "Necesitas un teléfono en tu inventario.",
|
||||
operation_in_progress = "Otra operación de teléfono ya está en progreso.",
|
||||
rate_limited = "El teléfono se abrió demasiadas veces. Inténtalo de nuevo en un momento.",
|
||||
voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.",
|
||||
default = "No se pudo abrir el teléfono.",
|
||||
},
|
||||
@@ -205,7 +206,7 @@ Locales["es"] = {
|
||||
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
|
||||
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" },
|
||||
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
|
||||
appearance = { eyebrow = "Apariencia", title = "Color de acento", body = "Cambia el acento en todo el espacio de trabajo del administrador.", colors = { emerald = "Esmeralda", blue = "Azul", violet = "Violeta", orange = "Naranja", red = "Rojo" } },
|
||||
statistics = { eyebrow = "Datos en vivo", title = "Estadísticas del teléfono", body = "Uso actual y estado de los dispositivos, actualizado cada 15 segundos.", today = "Actividad de hoy", todayBody = "Actividad telefónica y administrativa desde medianoche.", messagesToday = "Mensajes", callsToday = "Llamadas", auditToday = "Acciones de administrador", coverage = "Cobertura de dispositivos", coverageBody = "Proporción actual de todos los teléfonos guardados.", linkedDevices = "Cuenta vinculada", simDevices = "SIM asignada", activeDevices = "Actualizado en 24 horas", ofDevices = "{count} de {total} dispositivos" },
|
||||
configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } },
|
||||
players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." },
|
||||
search = { players = "Nombre, identificación, trabajo o número de búsqueda", apps = "Aplicaciones de búsqueda", clear = "Limpiar búsqueda" },
|
||||
@@ -221,7 +222,7 @@ Locales["es"] = {
|
||||
},
|
||||
audit = { eyebrow = "Responsabilidad", title = "Trayectoria de auditoría", body = "Las revelaciones sensibles y los cambios remotos de la aplicación se registran aquí.", empty = "No hay acciones de administración todavía", emptyBody = "Las acciones protegidas aparecerán aquí después de que se realicen.", by = "{actor} · Identificación del objetivo {target}", actions = { grant_app = "Aplicación instalada", revoke_app = "Aplicación eliminada", reveal_account_password = "Se reveló la contraseña", view_messages = "Los mensajes vistos", view_calls = "Las llamadas vistas", reset_passcode = "Reset de código de acceso", change_number = "Cambió el número de teléfono", factory_reset = "Reinicio de fábrica de teléfono", save_configuration = "Configuración guardada" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMINISTRACIÓN", players = "Directorio de jugadores", audit = "Registro de auditoría", selectPlayer = "Selecciona un jugador para inspeccionar la identidad, los dispositivos, las credenciales y el acceso a la aplicación.", save = "Guardar cambios", saveHint = "Aplicar los cambios pendientes", saved = "Cambios guardados.", unsaved = "Cambios no guardados", close = "Cerrar el panel de administración", refresh = "Actualizar los datos en vivo", online = "VIVIENDO", profile = "PROFILES", financial = "FINANCIERO", device = "DISPOSITIVO", security = "LA SEGURIDAD", noAutoSave = "Salvamiento manual", noAutoSaveBody = "Los cambios permanecen locales hasta que se presione el cheque verde.", discardTitle = "¿Deshacerse de los cambios no salvos?", discardBody = "No se han guardado los cambios de configuración de su aplicación en etapas.", keepEditing = "Sigue editando", discard = "Rechazar los cambios", saveFailed = "Algunos cambios no se pudieron salvar.", noSelection = "Ningún jugador seleccionado" },
|
||||
errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." },
|
||||
errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", invalid_company_configuration = "La configuración de la empresa está incompleta o contiene un trabajo, número de servicio o ID de servicio duplicado.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
@@ -694,6 +695,7 @@ Locales["es"] = {
|
||||
gif_provider_rate_limited = "GIPHY está ocupado. Inténtalo de nuevo en un momento.", gif_provider_failed = "La búsqueda de GIFs está temporalmente inaccesible.",
|
||||
self_message = "No puedes enviar mensajes a tu propio número.", recipient_not_found = "Ese número no está disponible.", blocked = "Este contacto ha bloqueado las llamadas y los mensajes de esta SIM.",
|
||||
messaging_unavailable = "Este contacto de empresa no acepta mensajes.",
|
||||
service_line_text_only = "Las líneas de servicio de empresa solo aceptan mensajes de texto por ahora.",
|
||||
no_sim = "Este teléfono no tiene tarjeta SIM.", rate_limited = "Demasiados mensajes, inténtalo de nuevo en un minuto.",
|
||||
request_failed = "Los mensajes están temporalmente inaccesibles.", default = "El mensaje no se pudo enviar.",
|
||||
},
|
||||
@@ -1448,8 +1450,8 @@ Locales["es"] = {
|
||||
camera = {
|
||||
name = "Cámara", flash = "Linterna", flip = "Cambiar de cámara", landscape = "Cambiar a paisaje",
|
||||
portrait = "Cambiar a vertical", photo = "Foto", video = "Vídeo", microphoneOn = "Micrófono encendido", microphoneOff = "Micrófono silenciado",
|
||||
focusHelp = "Presionar Espacio para mirar alrededor", returnHelp = "Soltar espacio para controles", lockCamera = "Bloquear movimiento de la cámara",
|
||||
unlockCamera = "Desbloquear movimiento de la cámara", spaceKey = "Espacio", uploading = "Subiendo {count}",
|
||||
focusHelp = "Mantén pulsada la tecla de vista para mirar alrededor", returnHelp = "Suelta la tecla de vista para usar los controles", lockCamera = "Bloquear movimiento de la cámara",
|
||||
unlockCamera = "Desbloquear movimiento de la cámara", lookKey = "Mirar", uploading = "Subiendo {count}",
|
||||
saving = "Guardando video...", openGallery = "Abrir fotos", takePhoto = "Tomar foto",
|
||||
startRecording = "Iniciar grabación", stopRecording = "Detener grabación", saved = "Guardado en fotos.",
|
||||
zoom = "Establecer zoom de la cámara en {zoom}",
|
||||
@@ -2139,7 +2141,6 @@ Locales["es"] = {
|
||||
}
|
||||
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Grupos de administradores",
|
||||
Dictionaries = "Diccionarios",
|
||||
Clips = "Los clips",
|
||||
Transforms = "Transformaciones",
|
||||
@@ -2176,30 +2177,3 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Trabajos permitidos",
|
||||
Websites = "Las páginas web",
|
||||
}
|
||||
|
||||
Locales["es"].Nui.AdminPanel.appearance.title = "Interfaz"
|
||||
Locales["es"].Nui.AdminPanel.appearance.body = "Personaliza los colores y la tipografía en el espacio de trabajo del administrador."
|
||||
Locales["es"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Color personalizado",
|
||||
customColorBody = "Seleccione cualquier acento RGB o ingrese sus valores de canal.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "EN EL CASO DE B",
|
||||
hex = "El color HEX",
|
||||
value = "el valor",
|
||||
fontFamily = "Familia de fuentes",
|
||||
fontFamilyBody = "Seleccione la tipografía utilizada por el panel de administración completo.",
|
||||
fontSize = "Tamaño de fuente",
|
||||
fontSizeBody = "Escala el texto y los controles para una fácil legibilidad.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "El sistema",
|
||||
classic = "Clásico",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Arbúsqueda",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospacio",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
Sky Phone media configuration
|
||||
|
||||
This file is loaded on the server only. Keep API keys private. When the
|
||||
phone configurator is enabled in config.lua, these values are first-run
|
||||
defaults and the active media configuration is loaded from SQL.
|
||||
phone configurator is enabled in config.lua, the active media configuration
|
||||
is loaded from SQL and frontend builds copy the shipped values from this
|
||||
file into source/shared/config_default.lua.
|
||||
]]
|
||||
|
||||
Config.Media = {
|
||||
GiphyApiKey = "", -- Paste the GIPHY API key here.
|
||||
-- Create or sign in to an account at https://developers.giphy.com/, open
|
||||
-- the Developer Dashboard, choose "Create an API Key", and paste it here.
|
||||
GiphyApiKey = "",
|
||||
GifPageSize = 24,
|
||||
GifRating = "pg-13",
|
||||
UrlMaxLength = 2048,
|
||||
AllowedGifHosts = { "giphy.com" },
|
||||
|
||||
-- Create or sign in at https://fivemanage.com/, create or select your team,
|
||||
-- then open Dashboard > Tokens and create a token with Media access. Keep
|
||||
-- this server-only token private and paste it here.
|
||||
FiveManage = {
|
||||
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here.
|
||||
ApiKey = "",
|
||||
BaseUrl = "https://api.fivemanage.com/api/v3/file",
|
||||
RequestTimeoutMs = 10000,
|
||||
UploadTimeoutMs = 25000,
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.2.6'
|
||||
version '0.3.1'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
@@ -30,9 +30,7 @@ shared_scripts {
|
||||
|
||||
client_scripts {
|
||||
'config/config.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'config/locales/es.lua',
|
||||
'config/locales/*.lua',
|
||||
'source/bridge/client/callbacks.lua',
|
||||
'source/client/phone_configurator.lua',
|
||||
'source/bridge/client/framework.lua',
|
||||
@@ -79,6 +77,7 @@ server_scripts {
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'config/locales/es.lua',
|
||||
'source/shared/config_default.lua',
|
||||
'source/server/nui_build_check.lua',
|
||||
'source/server/update_check.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
|
||||
@@ -11,6 +11,15 @@ local provider_aliases = {
|
||||
|
||||
local function resolve_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = provider_aliases[configured] or configured
|
||||
local resource_name = provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework
|
||||
function Bridge.Framework.GetName()
|
||||
return Bridge.Framework.Name
|
||||
end
|
||||
|
||||
function Bridge.Framework.HasPermission(source, permission)
|
||||
if type(permission) ~= "string" or permission == "" then
|
||||
error("[sky_phone] Permission identifiers must be non-empty strings.")
|
||||
end
|
||||
|
||||
local groups = Config.CommandPermissions[permission]
|
||||
if type(groups) ~= "table" or #groups == 0 then
|
||||
local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group."
|
||||
error(message:format(permission))
|
||||
end
|
||||
for index, group in ipairs(groups) do
|
||||
if type(group) ~= "string" or group == "" then
|
||||
local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string."
|
||||
error(message:format(permission, index))
|
||||
end
|
||||
end
|
||||
|
||||
return Bridge.Framework.HasAdminGroup(source, groups)
|
||||
end
|
||||
|
||||
@@ -24,6 +24,13 @@ function Bridge.Framework.HasAdminGroup(source, groups)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if IsPlayerAceAllowed(tostring(source), group) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return exports.qbx_core:HasGroup(source, groups)
|
||||
end
|
||||
|
||||
|
||||
@@ -61,13 +61,30 @@ end
|
||||
|
||||
Bridge.Inventory.Name = configured_inventory
|
||||
|
||||
if selected_adapter.metadata == false then
|
||||
if Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
|
||||
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
|
||||
:format(configured_inventory)
|
||||
local metadata_free_inventory = selected_adapter.metadata == false
|
||||
|
||||
local function enforce_metadata_compatibility()
|
||||
if not metadata_free_inventory then
|
||||
return
|
||||
end
|
||||
|
||||
local modes_changed = Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false
|
||||
Config.Phone.Unique = false
|
||||
Config.Sim.Enabled = false
|
||||
|
||||
if modes_changed then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Inventory '%s' does not support item metadata; unique phones and physical SIM cards were disabled automatically.",
|
||||
configured_inventory
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
enforce_metadata_compatibility()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
|
||||
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
|
||||
if type(item) ~= "table" then
|
||||
return nil
|
||||
|
||||
@@ -23,8 +23,8 @@ end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
for _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
|
||||
local normalized = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
@@ -40,7 +40,7 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
|
||||
return false
|
||||
end
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
|
||||
@@ -41,6 +41,15 @@ end
|
||||
|
||||
local function resolve_call_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(call_provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = call_provider_aliases[configured] or configured
|
||||
local resource_name = call_provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -9,9 +9,13 @@ local front_camera_fov = 32.0
|
||||
local front_camera_distance = 1.05
|
||||
local front_camera_height = 0.05
|
||||
local front_camera_target_height = 0.03
|
||||
local front_camera_horizontal_limit = 75.0
|
||||
local front_camera_vertical_limit = 35.0
|
||||
local front_camera_rotate_speed = 5.0
|
||||
local camera_passthrough_control = 22 -- INPUT_JUMP (Space by default)
|
||||
local blocked_camera_controls = {
|
||||
0, -- INPUT_NEXT_CAMERA
|
||||
22, -- INPUT_JUMP
|
||||
camera_passthrough_control,
|
||||
24, -- INPUT_ATTACK
|
||||
25, -- INPUT_AIM
|
||||
37, -- INPUT_SELECT_WEAPON
|
||||
@@ -45,6 +49,8 @@ local camera_state = {
|
||||
focus_watcher = false,
|
||||
front_camera = false,
|
||||
front_camera_handle = nil,
|
||||
front_camera_pitch = 0.0,
|
||||
front_camera_yaw = 0.0,
|
||||
game_input = false,
|
||||
landscape = false,
|
||||
locked = false,
|
||||
@@ -89,20 +95,39 @@ end
|
||||
local function get_front_camera_transform(ped)
|
||||
local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
|
||||
local forward = GetEntityForwardVector(ped)
|
||||
local forward_vector = vector3(forward.x, forward.y, forward.z)
|
||||
local camera_offset = forward_vector * front_camera_distance
|
||||
local camera_position = head_position + camera_offset + vector3(0.0, 0.0, front_camera_height)
|
||||
local to_camera = camera_position - head_position
|
||||
local dot = (to_camera.x * forward_vector.x)
|
||||
+ (to_camera.y * forward_vector.y)
|
||||
+ (to_camera.z * forward_vector.z)
|
||||
if dot < 0.0 then
|
||||
camera_position = head_position - camera_offset + vector3(0.0, 0.0, front_camera_height)
|
||||
end
|
||||
local yaw = math.rad(camera_state.front_camera_yaw)
|
||||
local pitch = math.rad(camera_state.front_camera_pitch)
|
||||
local orbit_direction = vector3(
|
||||
(forward.x * math.cos(yaw)) - (forward.y * math.sin(yaw)),
|
||||
(forward.x * math.sin(yaw)) + (forward.y * math.cos(yaw)),
|
||||
0.0
|
||||
)
|
||||
local camera_position = head_position
|
||||
+ (orbit_direction * (math.cos(pitch) * front_camera_distance))
|
||||
+ vector3(0.0, 0.0, front_camera_height + (math.sin(pitch) * front_camera_distance))
|
||||
local target_position = head_position + vector3(0.0, 0.0, front_camera_target_height)
|
||||
return camera_position, target_position
|
||||
end
|
||||
|
||||
local function update_front_camera_orbit()
|
||||
local horizontal_input = GetDisabledControlNormal(0, 1)
|
||||
local vertical_input = GetDisabledControlNormal(0, 2)
|
||||
camera_state.front_camera_yaw = math.max(
|
||||
-front_camera_horizontal_limit,
|
||||
math.min(
|
||||
front_camera_horizontal_limit,
|
||||
camera_state.front_camera_yaw - (horizontal_input * front_camera_rotate_speed)
|
||||
)
|
||||
)
|
||||
camera_state.front_camera_pitch = math.max(
|
||||
-front_camera_vertical_limit,
|
||||
math.min(
|
||||
front_camera_vertical_limit,
|
||||
camera_state.front_camera_pitch - (vertical_input * front_camera_rotate_speed)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
local function apply_front_camera(ped)
|
||||
if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then
|
||||
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
|
||||
@@ -198,7 +223,18 @@ local function watch_camera_controls()
|
||||
CreateThread(function()
|
||||
while camera_state.active do
|
||||
apply_camera_controls()
|
||||
if not camera_state.walkable then
|
||||
local passthrough_pressed = SkyPhoneFocus.IsHoldToLookPressed()
|
||||
or IsDisabledControlPressed(0, camera_passthrough_control)
|
||||
local should_focus = camera_state.locked or not passthrough_pressed
|
||||
if camera_state.nui_focused ~= should_focus then
|
||||
set_camera_focus(should_focus)
|
||||
end
|
||||
end
|
||||
if camera_state.game_input then
|
||||
if camera_state.front_camera and not camera_state.locked then
|
||||
update_front_camera_orbit()
|
||||
end
|
||||
if
|
||||
IsDisabledControlJustPressed(0, 241)
|
||||
or IsDisabledControlJustPressed(0, 261)
|
||||
@@ -214,9 +250,6 @@ local function watch_camera_controls()
|
||||
math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step)
|
||||
)
|
||||
end
|
||||
if IsDisabledControlJustReleased(0, 22) then
|
||||
set_camera_focus(true)
|
||||
end
|
||||
end
|
||||
Wait(0)
|
||||
end
|
||||
@@ -243,7 +276,7 @@ AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
|
||||
camera_state.applied_nui_focus = data.focused
|
||||
SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
|
||||
end
|
||||
if data.active and data.gameInput then
|
||||
if data.active then
|
||||
watch_camera_controls()
|
||||
end
|
||||
end)
|
||||
@@ -256,6 +289,8 @@ local function set_camera_active(active)
|
||||
TriggerEvent("sky_phone:client:cameraActiveChanged", active)
|
||||
if active then
|
||||
camera_state.front_camera = false
|
||||
camera_state.front_camera_pitch = 0.0
|
||||
camera_state.front_camera_yaw = 0.0
|
||||
camera_state.landscape = false
|
||||
camera_state.locked = false
|
||||
camera_state.zoom = 1.0
|
||||
@@ -290,6 +325,8 @@ local function set_camera_active(active)
|
||||
end
|
||||
set_flash_enabled(false)
|
||||
camera_state.front_camera = false
|
||||
camera_state.front_camera_pitch = 0.0
|
||||
camera_state.front_camera_yaw = 0.0
|
||||
camera_state.game_input = false
|
||||
camera_state.landscape = false
|
||||
camera_state.locked = false
|
||||
@@ -314,6 +351,8 @@ local function set_front_camera(active)
|
||||
return
|
||||
end
|
||||
if active then
|
||||
camera_state.front_camera_pitch = 0.0
|
||||
camera_state.front_camera_yaw = 0.0
|
||||
apply_front_camera(PlayerPedId())
|
||||
else
|
||||
clear_front_camera()
|
||||
@@ -405,6 +444,9 @@ RegisterNUICallback("camera:setLocked", function(data, cb)
|
||||
return
|
||||
end
|
||||
camera_state.locked = data.locked == true
|
||||
if camera_state.locked and not camera_state.walkable then
|
||||
set_camera_focus(true)
|
||||
end
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
|
||||
@@ -58,6 +58,16 @@ AddEventHandler("sky_phone:configurator:updated", function()
|
||||
SkyPhoneFocus.Reapply()
|
||||
end)
|
||||
|
||||
function SkyPhoneFocus.IsHoldToLookPressed()
|
||||
if not hold_to_look_enabled then
|
||||
return false
|
||||
end
|
||||
if IsControlPressed(0, hold_to_look_control) then
|
||||
return true
|
||||
end
|
||||
return IsDisabledControlPressed(0, hold_to_look_control)
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.ApplyFocusedControls()
|
||||
for _, group in ipairs(focused_control_groups) do
|
||||
DisableAllControlActions(group)
|
||||
@@ -96,6 +106,10 @@ function SkyPhoneFocus.Resolve(state)
|
||||
if state.camera_active and not state.camera_nui_focused then
|
||||
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
|
||||
end
|
||||
if state.camera_active then
|
||||
-- Forward controls so disabled inputs remain readable while the NUI cursor owns focus.
|
||||
return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }
|
||||
end
|
||||
local game_input = state.is_open
|
||||
and allows_game_input(state)
|
||||
and not state.camera_active
|
||||
@@ -137,7 +151,7 @@ function SkyPhoneFocus.Reapply()
|
||||
active = state.camera_active,
|
||||
cursor = focus.cursor,
|
||||
focused = focus.focused,
|
||||
gameInput = focus.keep_input,
|
||||
gameInput = focus.game_input,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -234,10 +248,9 @@ end
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if game_input or block_game then
|
||||
local look_passthrough = hold_to_look_enabled
|
||||
and game_input
|
||||
local look_passthrough = game_input
|
||||
and not state.cursor_disabled
|
||||
and IsControlPressed(0, hold_to_look_control)
|
||||
and SkyPhoneFocus.IsHoldToLookPressed()
|
||||
if look_passthrough ~= state.look_passthrough then
|
||||
state.look_passthrough = look_passthrough
|
||||
SkyPhoneFocus.Reapply()
|
||||
|
||||
@@ -13,9 +13,7 @@ local suggested_admin_command = nil
|
||||
local suggested_test_data_command = nil
|
||||
local active_development_command = nil
|
||||
local registered_development_commands = {}
|
||||
local active_key_mapping_command = nil
|
||||
local active_key_mapping_key = nil
|
||||
local key_mapping_revision = 0
|
||||
local phone_key_mapping_registered = false
|
||||
local refresh_development_command
|
||||
local refresh_phone_key_mapping
|
||||
local refresh_test_data_command_suggestion
|
||||
@@ -75,6 +73,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function apply_disabled_apps(payload)
|
||||
if type(payload) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
payload.disabledApps = disabled
|
||||
end
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
@@ -136,6 +149,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
|
||||
refresh_admin_command_suggestion()
|
||||
SkyPhoneApps.SendCatalog()
|
||||
if is_open and device_payload then
|
||||
apply_disabled_apps(device_payload)
|
||||
device_payload.lang = locale_name
|
||||
device_payload.locales = locale.Nui
|
||||
device_payload.fallbackLocales = Locales.en.Nui
|
||||
@@ -175,6 +189,22 @@ local function close_phone(close_device_session)
|
||||
end
|
||||
end
|
||||
|
||||
local function request_phone_open(callback_name)
|
||||
if is_open or open_requested then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = true
|
||||
local result = Bridge.Callbacks.Trigger(callback_name, {})
|
||||
if type(result) == "table" and result.success == true then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
|
||||
local function toggle_phone(open, no_focus)
|
||||
if open ~= nil and type(open) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
|
||||
@@ -190,6 +220,9 @@ local function toggle_phone(open, no_focus)
|
||||
should_open = not (is_open or open_requested)
|
||||
end
|
||||
if not should_open then
|
||||
if open == nil and open_requested and not is_open then
|
||||
return true
|
||||
end
|
||||
close_phone()
|
||||
return true
|
||||
end
|
||||
@@ -202,12 +235,7 @@ local function toggle_phone(open, no_focus)
|
||||
return true
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if type(result) ~= "table" or result.success ~= true then
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
return true
|
||||
return request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
SkyPhoneClient.Toggle = toggle_phone
|
||||
@@ -217,13 +245,16 @@ AddEventHandler("sky_phone:client:forceClose", function()
|
||||
end)
|
||||
|
||||
local function run_development_command()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
|
||||
request_phone_open("sky_phone:device:development-open")
|
||||
end
|
||||
|
||||
refresh_development_command = function()
|
||||
@@ -256,13 +287,16 @@ end
|
||||
refresh_development_command()
|
||||
|
||||
local function run_phone_toggle()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_live_activity_open", function()
|
||||
@@ -271,38 +305,35 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
||||
end
|
||||
|
||||
open_home_requested = true
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if not result or not result.success then
|
||||
if not request_phone_open("sky_phone:device:open-request") then
|
||||
open_home_requested = false
|
||||
end
|
||||
end, false)
|
||||
|
||||
RegisterCommand("sky_phone_toggle", run_phone_toggle, false)
|
||||
RegisterCommand("sky_phone_toggle", function()
|
||||
if not Config.Phone.Keybind then
|
||||
return
|
||||
end
|
||||
run_phone_toggle()
|
||||
end, false)
|
||||
|
||||
refresh_phone_key_mapping = function()
|
||||
local key_name = Config.Phone.Keybind
|
||||
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then
|
||||
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
|
||||
end
|
||||
if key_name == active_key_mapping_key then
|
||||
if phone_key_mapping_registered or not key_name then
|
||||
return
|
||||
end
|
||||
|
||||
active_key_mapping_key = key_name
|
||||
active_key_mapping_command = nil
|
||||
if not key_name then
|
||||
return
|
||||
end
|
||||
|
||||
key_mapping_revision = key_mapping_revision + 1
|
||||
local command_name = "sky_phone_toggle_config_" .. key_mapping_revision
|
||||
active_key_mapping_command = command_name
|
||||
RegisterCommand(command_name, function()
|
||||
if active_key_mapping_command == command_name then
|
||||
run_phone_toggle()
|
||||
end
|
||||
end, false)
|
||||
RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)
|
||||
-- FiveM persists player rebindings by command name, so this identifier must remain stable.
|
||||
phone_key_mapping_registered = true
|
||||
RegisterKeyMapping(
|
||||
"sky_phone_toggle",
|
||||
locale.Controls.OpenPhone,
|
||||
"keyboard",
|
||||
key_name
|
||||
)
|
||||
end
|
||||
|
||||
refresh_phone_key_mapping()
|
||||
@@ -459,6 +490,10 @@ end)
|
||||
RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
end
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
@@ -468,6 +503,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
tostring(data.account ~= nil),
|
||||
{ always = true }
|
||||
)
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
open_requested = true
|
||||
@@ -484,6 +520,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
|
||||
return
|
||||
end
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
SendNUIMessage({ type = "device:updated", data = data })
|
||||
@@ -499,8 +536,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
if not is_open and not open_requested then
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
open_home_requested = false
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
|
||||
@@ -98,7 +98,7 @@ end
|
||||
|
||||
local function require_admin(source, operation, maximum)
|
||||
if not Config.AdminPanel.Enabled
|
||||
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||
or not Bridge.Framework.HasPermission(source, "phonepanel")
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
|
||||
return nil, { success = false, error = "not_authorized" }
|
||||
@@ -121,7 +121,7 @@ local function run_admin_command(command_source)
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
|
||||
return
|
||||
end
|
||||
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||
if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected admin panel command from source %s.",
|
||||
@@ -373,6 +373,9 @@ end
|
||||
|
||||
local function app_metadata(app_id)
|
||||
if BUILTIN_APPS[app_id] then
|
||||
if not SkyPhone.IsAppEnabled(app_id) then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||
removable = PROTECTED_APPS[app_id] ~= true,
|
||||
@@ -463,16 +466,36 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
local totals = Bridge.Database.Query([[
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `account_id` IS NOT NULL) AS `linked_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `sim_id` IS NOT NULL) AS `sim_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`
|
||||
WHERE `updated_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) AS `active_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_sms_messages`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `messages_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_calls`
|
||||
WHERE `started_at` >= CURRENT_DATE) AS `calls_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`) AS `audit_entries`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `audit_today`
|
||||
]], {})
|
||||
local stats = totals[1] or {}
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
||||
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
||||
devices = tonumber(stats.devices) or 0,
|
||||
accounts = tonumber(stats.accounts) or 0,
|
||||
linkedDevices = tonumber(stats.linked_devices) or 0,
|
||||
simDevices = tonumber(stats.sim_devices) or 0,
|
||||
activeDevices = tonumber(stats.active_devices) or 0,
|
||||
messagesToday = tonumber(stats.messages_today) or 0,
|
||||
callsToday = tonumber(stats.calls_today) or 0,
|
||||
auditEntries = tonumber(stats.audit_entries) or 0,
|
||||
auditToday = tonumber(stats.audit_today) or 0,
|
||||
},
|
||||
audit = load_audit(),
|
||||
},
|
||||
|
||||
@@ -156,12 +156,18 @@ local function permission_grade(definition, permission)
|
||||
return grade and math.max(0, math.floor(grade)) or nil
|
||||
end
|
||||
|
||||
local function validate_configuration()
|
||||
if type(Config.Companies) ~= "table" or type(Config.Companies.Definitions) ~= "table" then
|
||||
error("[sky_phone] Config.Companies.Definitions must be configured.")
|
||||
local function validate_configuration(configuration)
|
||||
local company_config = configuration.Companies
|
||||
local validated_definitions = {}
|
||||
local validated_definition_ids = {}
|
||||
local validated_definitions_by_job = {}
|
||||
local validated_service_lines_by_number = {}
|
||||
|
||||
if type(company_config) ~= "table" or type(company_config.Definitions) ~= "table" then
|
||||
return nil, "[sky_phone] Config.Companies.Definitions must be configured."
|
||||
end
|
||||
if type(Config.Companies.Enabled) ~= "boolean" then
|
||||
error("[sky_phone] Config.Companies.Enabled must be a boolean.")
|
||||
if type(company_config.Enabled) ~= "boolean" then
|
||||
return nil, "[sky_phone] Config.Companies.Enabled must be a boolean."
|
||||
end
|
||||
for _, field in ipairs({
|
||||
{ "PageSize", 1000 },
|
||||
@@ -184,26 +190,26 @@ local function validate_configuration()
|
||||
{ "AnnouncementMaximumSeconds", 31536000 },
|
||||
{ "RetentionDays", 36500 },
|
||||
}) do
|
||||
if not valid_integer(Config.Companies[field[1]], 1, field[2]) then
|
||||
error(("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1]))
|
||||
if not valid_integer(company_config[field[1]], 1, field[2]) then
|
||||
return nil, ("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1])
|
||||
end
|
||||
end
|
||||
if Config.Companies.PageSize > Config.Companies.MaximumPageSize then
|
||||
error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.")
|
||||
if company_config.PageSize > company_config.MaximumPageSize then
|
||||
return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize."
|
||||
end
|
||||
if type(Config.Companies.RateLimits) ~= "table" then
|
||||
error("[sky_phone] Config.Companies.RateLimits must be configured.")
|
||||
if type(company_config.RateLimits) ~= "table" then
|
||||
return nil, "[sky_phone] Config.Companies.RateLimits must be configured."
|
||||
end
|
||||
for _, name in ipairs({ "Read", "Search", "CreateRequest", "Message", "RequestAction", "Profile", "CallAvailability" }) do
|
||||
if not valid_integer(Config.Companies.RateLimits[name], 1, 100000) then
|
||||
error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name))
|
||||
if not valid_integer(company_config.RateLimits[name], 1, 100000) then
|
||||
return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name)
|
||||
end
|
||||
end
|
||||
if type(Config.Companies.CallRouting) ~= "table"
|
||||
or not valid_integer(Config.Companies.CallRouting.MaxAttempts, 1, 20)
|
||||
or not valid_integer(Config.Companies.CallRouting.RingSeconds, 1, 120)
|
||||
if type(company_config.CallRouting) ~= "table"
|
||||
or not valid_integer(company_config.CallRouting.MaxAttempts, 1, 20)
|
||||
or not valid_integer(company_config.CallRouting.RingSeconds, 1, 120)
|
||||
then
|
||||
error("[sky_phone] Config.Companies.CallRouting is invalid.")
|
||||
return nil, "[sky_phone] Config.Companies.CallRouting is invalid."
|
||||
end
|
||||
local configured_statuses = {
|
||||
new = true,
|
||||
@@ -213,48 +219,48 @@ local function validate_configuration()
|
||||
completed = true,
|
||||
cancelled = true,
|
||||
}
|
||||
if type(Config.Companies.Statuses) ~= "table" then
|
||||
error("[sky_phone] Config.Companies.Statuses must be configured.")
|
||||
if type(company_config.Statuses) ~= "table" then
|
||||
return nil, "[sky_phone] Config.Companies.Statuses must be configured."
|
||||
end
|
||||
for status in pairs(configured_statuses) do
|
||||
if Config.Companies.Statuses[status] ~= true then
|
||||
error(("[sky_phone] Companies status '%s' must be enabled."):format(status))
|
||||
if company_config.Statuses[status] ~= true then
|
||||
return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status)
|
||||
end
|
||||
end
|
||||
for status, enabled in pairs(Config.Companies.Statuses) do
|
||||
for status, enabled in pairs(company_config.Statuses) do
|
||||
if not configured_statuses[status] or enabled ~= true then
|
||||
error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status)))
|
||||
return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))
|
||||
end
|
||||
end
|
||||
if type(Config.Companies.AvailabilityStatuses) ~= "table" then
|
||||
error("[sky_phone] Config.Companies.AvailabilityStatuses must be configured.")
|
||||
if type(company_config.AvailabilityStatuses) ~= "table" then
|
||||
return nil, "[sky_phone] Config.Companies.AvailabilityStatuses must be configured."
|
||||
end
|
||||
local configured_availability = { available = true, busy = true, closed = true }
|
||||
for _, status in ipairs({ "available", "busy", "closed" }) do
|
||||
if Config.Companies.AvailabilityStatuses[status] ~= true then
|
||||
error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status))
|
||||
if company_config.AvailabilityStatuses[status] ~= true then
|
||||
return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status)
|
||||
end
|
||||
end
|
||||
for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do
|
||||
for status, enabled in pairs(company_config.AvailabilityStatuses) do
|
||||
if not configured_availability[status] or enabled ~= true then
|
||||
error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status)))
|
||||
return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))
|
||||
end
|
||||
end
|
||||
if not valid_array(Config.Companies.Categories, 100) then
|
||||
error("[sky_phone] Config.Companies.Categories must be a bounded array.")
|
||||
if not valid_array(company_config.Categories, 100) then
|
||||
return nil, "[sky_phone] Config.Companies.Categories must be a bounded array."
|
||||
end
|
||||
local category_ids = {}
|
||||
local configured_service_ids = {}
|
||||
for _, category_id in ipairs(Config.Companies.Categories) do
|
||||
for _, category_id in ipairs(company_config.Categories) do
|
||||
if type(category_id) ~= "string" or #category_id > 64
|
||||
or not category_id:match("^[a-z0-9_-]+$") or category_ids[category_id]
|
||||
then
|
||||
error("[sky_phone] Companies contains an invalid category ID.")
|
||||
return nil, "[sky_phone] Companies contains an invalid category ID."
|
||||
end
|
||||
category_ids[category_id] = true
|
||||
end
|
||||
|
||||
for company_id, definition in pairs(Config.Companies.Definitions) do
|
||||
for company_id, definition in pairs(company_config.Definitions) do
|
||||
if type(company_id) ~= "string" or #company_id > 64 or not company_id:match("^[a-z0-9_-]+$")
|
||||
or type(definition) ~= "table"
|
||||
or type(definition.Job) ~= "string" or #definition.Job > 64
|
||||
@@ -262,26 +268,25 @@ local function validate_configuration()
|
||||
or not valid_text(definition.Name, 120, false)
|
||||
or not category_ids[definition.Category]
|
||||
then
|
||||
error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id)))
|
||||
return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))
|
||||
end
|
||||
local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true)
|
||||
local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true)
|
||||
local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true)
|
||||
local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true)
|
||||
local location_label = valid_text(
|
||||
definition.LocationLabel or definition.Address or "",
|
||||
Config.Companies.DistrictMaxLength,
|
||||
company_config.DistrictMaxLength,
|
||||
true
|
||||
)
|
||||
local address = valid_text(definition.Address or "", Config.Companies.AddressMaxLength, true)
|
||||
local address = valid_text(definition.Address or "", company_config.AddressMaxLength, true)
|
||||
local logo_url = valid_text(definition.LogoUrl, 2048, false)
|
||||
if not description or not district or not location_label or not address
|
||||
or type(definition.Public) ~= "boolean" or type(definition.Emergency) ~= "boolean"
|
||||
or type(definition.Verified) ~= "boolean" or type(definition.AcceptsRequests) ~= "boolean"
|
||||
or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability]
|
||||
or not company_config.AvailabilityStatuses[definition.DefaultAvailability]
|
||||
or not valid_text(definition.Icon, 64, false)
|
||||
or not logo_url or not logo_url:match("^https://[^%s]+$")
|
||||
or (definition.Emergency and definition.AcceptsRequests)
|
||||
then
|
||||
error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id))
|
||||
return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)
|
||||
end
|
||||
definition.Name = trim(definition.Name)
|
||||
definition.Description = description
|
||||
@@ -292,7 +297,7 @@ local function validate_configuration()
|
||||
if definition.Location ~= nil then
|
||||
local location_type = type(definition.Location)
|
||||
if location_type ~= "table" and location_type ~= "vector3" then
|
||||
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
|
||||
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
|
||||
end
|
||||
local x = tonumber(definition.Location.x)
|
||||
local y = tonumber(definition.Location.y)
|
||||
@@ -300,46 +305,46 @@ local function validate_configuration()
|
||||
if not x or not y or not z or x ~= x or y ~= y or z ~= z
|
||||
or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000
|
||||
then
|
||||
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
|
||||
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
|
||||
end
|
||||
end
|
||||
if definitions_by_job[definition.Job] then
|
||||
error(("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job))
|
||||
if validated_definitions_by_job[definition.Job] then
|
||||
return nil, ("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job)
|
||||
end
|
||||
local line = definition.ServiceLine
|
||||
if type(line) ~= "table" then
|
||||
error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id))
|
||||
return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id)
|
||||
end
|
||||
local number = SkyPhoneSimNumber.NormalizeService(line.Number, Config.Sim.NumberLength)
|
||||
local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength)
|
||||
if not number then
|
||||
error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id))
|
||||
return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id)
|
||||
end
|
||||
if service_lines_by_number[number] then
|
||||
error(("[sky_phone] Service number '%s' is assigned more than once."):format(number))
|
||||
if validated_service_lines_by_number[number] then
|
||||
return nil, ("[sky_phone] Service number '%s' is assigned more than once."):format(number)
|
||||
end
|
||||
if type(line.AutoContact) ~= "boolean" or type(line.CanCall) ~= "boolean"
|
||||
or type(line.CanMessage) ~= "boolean"
|
||||
or not valid_integer(line.MinimumGrade, 0, 10000)
|
||||
then
|
||||
error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id))
|
||||
return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)
|
||||
end
|
||||
if line.AutoContact and not definition.Public then
|
||||
error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id))
|
||||
return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)
|
||||
end
|
||||
if line.Routing ~= "round_robin" then
|
||||
error(("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(company_id, tostring(line.Routing)))
|
||||
end
|
||||
if line.CanMessage then
|
||||
error(("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(company_id))
|
||||
return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(
|
||||
company_id,
|
||||
tostring(line.Routing)
|
||||
)
|
||||
end
|
||||
line.Number = number
|
||||
definitions[company_id] = definition
|
||||
definition_ids[#definition_ids + 1] = company_id
|
||||
definitions_by_job[definition.Job] = company_id
|
||||
service_lines_by_number[number] = company_id
|
||||
validated_definitions[company_id] = definition
|
||||
validated_definition_ids[#validated_definition_ids + 1] = company_id
|
||||
validated_definitions_by_job[definition.Job] = company_id
|
||||
validated_service_lines_by_number[number] = company_id
|
||||
for _, permission in ipairs({ "WorkQueue", "Availability", "Assign", "Profile", "Hours", "Services", "Announcement" }) do
|
||||
if not definition.Permissions or not valid_integer(definition.Permissions[permission], 0, 10000) then
|
||||
error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission))
|
||||
return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)
|
||||
end
|
||||
end
|
||||
local default_services = definition.Services
|
||||
@@ -347,40 +352,54 @@ local function validate_configuration()
|
||||
default_services = {}
|
||||
definition.Services = default_services
|
||||
end
|
||||
if not valid_array(default_services, Config.Companies.MaximumServices) then
|
||||
error(("[sky_phone] Company '%s' has an invalid default service list."):format(company_id))
|
||||
if not valid_array(default_services, company_config.MaximumServices) then
|
||||
return nil, ("[sky_phone] Company '%s' has an invalid default service list."):format(company_id)
|
||||
end
|
||||
for _, service in ipairs(default_services) do
|
||||
if type(service) ~= "table" then
|
||||
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
|
||||
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
|
||||
end
|
||||
local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false)
|
||||
local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false)
|
||||
local service_description = valid_text(
|
||||
service.Description or "",
|
||||
Config.Companies.ServiceDescriptionMaxLength,
|
||||
company_config.ServiceDescriptionMaxLength,
|
||||
true
|
||||
)
|
||||
local price = valid_text(service.Price or "", Config.Companies.ServicePriceMaxLength, true)
|
||||
local price = valid_text(service.Price or "", company_config.ServicePriceMaxLength, true)
|
||||
if not valid_service_id(service.Id) or not title or not service_description or not price
|
||||
or type(service.RequestsEnabled) ~= "boolean"
|
||||
then
|
||||
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
|
||||
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
|
||||
end
|
||||
service.Title = title
|
||||
service.Description = service_description
|
||||
service.Price = price
|
||||
if configured_service_ids[service.Id] then
|
||||
error(("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id))
|
||||
return nil, ("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)
|
||||
end
|
||||
configured_service_ids[service.Id] = true
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(definition_ids, function(left, right)
|
||||
local left_name = definitions[left].Name:lower()
|
||||
local right_name = definitions[right].Name:lower()
|
||||
table.sort(validated_definition_ids, function(left, right)
|
||||
local left_name = validated_definitions[left].Name:lower()
|
||||
local right_name = validated_definitions[right].Name:lower()
|
||||
return left_name == right_name and left < right or left_name < right_name
|
||||
end)
|
||||
return {
|
||||
definition_ids = validated_definition_ids,
|
||||
definitions = validated_definitions,
|
||||
definitions_by_job = validated_definitions_by_job,
|
||||
service_lines_by_number = validated_service_lines_by_number,
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneCompanies.ValidateConfiguration(configuration)
|
||||
local validated, validation_error = validate_configuration(configuration)
|
||||
if not validated then
|
||||
return false, validation_error
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function seed_companies()
|
||||
@@ -466,6 +485,48 @@ local function seed_companies()
|
||||
|
||||
end
|
||||
|
||||
local function migrate_requestable_emergency_companies()
|
||||
local migration_name = "sky-phone:companies:requestable-emergency:v1"
|
||||
local completed = Bridge.Database.Query(
|
||||
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
|
||||
{ migration_name }
|
||||
)
|
||||
if completed[1] then
|
||||
return
|
||||
end
|
||||
|
||||
local statements = {}
|
||||
local migrated_companies = {}
|
||||
for _, company_id in ipairs(definition_ids) do
|
||||
local definition = definitions[company_id]
|
||||
if definition.Emergency and definition.AcceptsRequests then
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
UPDATE `sky_phone_company_profiles`
|
||||
SET `accepts_requests` = 1, `revision` = `revision` + 1
|
||||
WHERE `company_id` = ? AND `accepts_requests` = 0
|
||||
]],
|
||||
params = { company_id },
|
||||
}
|
||||
migrated_companies[#migrated_companies + 1] = company_id
|
||||
end
|
||||
end
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
|
||||
VALUES (?, ?, ?)
|
||||
]],
|
||||
params = {
|
||||
migration_name,
|
||||
"sky-phone",
|
||||
json.encode({ companies = migrated_companies }),
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Could not migrate requestable emergency company profiles.")
|
||||
end
|
||||
end
|
||||
|
||||
local function tombstone_removed_companies()
|
||||
local profiles = Bridge.Database.Query([[
|
||||
SELECT DISTINCT profile.`company_id`
|
||||
@@ -826,7 +887,7 @@ local function company_payload(company_id, include_inactive_services)
|
||||
availability = availability,
|
||||
availabilityUpdatedAt = iso_time(row.availability_updated_at_unix)
|
||||
or iso_time(row.updated_at_unix),
|
||||
acceptsRequests = tonumber(row.accepts_requests) == 1 and not definition.Emergency,
|
||||
acceptsRequests = tonumber(row.accepts_requests) == 1,
|
||||
phoneNumber = line and line.Number or nil,
|
||||
canCall = line and line.CanCall == true or false,
|
||||
canMessage = line and line.CanMessage == true or false,
|
||||
@@ -906,12 +967,16 @@ local function cleanup_retained_data()
|
||||
end
|
||||
|
||||
local function refresh_runtime_configuration()
|
||||
definitions = {}
|
||||
definition_ids = {}
|
||||
definitions_by_job = {}
|
||||
service_lines_by_number = {}
|
||||
validate_configuration()
|
||||
local validated, validation_error = validate_configuration(Config)
|
||||
if not validated then
|
||||
error(validation_error)
|
||||
end
|
||||
definitions = validated.definitions
|
||||
definition_ids = validated.definition_ids
|
||||
definitions_by_job = validated.definitions_by_job
|
||||
service_lines_by_number = validated.service_lines_by_number
|
||||
seed_companies()
|
||||
migrate_requestable_emergency_companies()
|
||||
tombstone_removed_companies()
|
||||
end
|
||||
|
||||
@@ -1123,7 +1188,7 @@ end
|
||||
|
||||
local function request_row(request_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
|
||||
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
|
||||
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
|
||||
r.`company_activity_revision`, r.`revision`,
|
||||
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
|
||||
@@ -1825,6 +1890,175 @@ local function notification_payload(kind, area, row)
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
|
||||
if not Config.Companies.Enabled or type(data) ~= "table"
|
||||
or data.messageType ~= "text" or not valid_uuid(data.id)
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local device, device_error = current_device(source, false)
|
||||
if not device then
|
||||
return device_error
|
||||
end
|
||||
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
|
||||
if not service_line or not service_line.canMessage then
|
||||
return { success = false, error = "messaging_unavailable" }
|
||||
end
|
||||
local body = valid_text(
|
||||
data.body,
|
||||
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
|
||||
false
|
||||
)
|
||||
if not body then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
local request_id = uuid()
|
||||
local created_event_id = uuid()
|
||||
local status_event_id = uuid()
|
||||
local mutation_token = uuid()
|
||||
local statements = {
|
||||
{
|
||||
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
|
||||
params = { device.sim_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_requests`
|
||||
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
|
||||
`company_activity_revision`)
|
||||
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
|
||||
FROM `sky_phone_sims` sim
|
||||
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
|
||||
WHERE sim.`id` = ? AND NOT EXISTS (
|
||||
SELECT 1 FROM `sky_phone_company_requests` existing
|
||||
WHERE existing.`company_id` = profile.`company_id`
|
||||
AND existing.`customer_sim_id` = sim.`id`
|
||||
AND existing.`channel` = 'service_line'
|
||||
AND existing.`status` NOT IN ('completed', 'cancelled')
|
||||
)
|
||||
]],
|
||||
params = {
|
||||
request_id,
|
||||
body,
|
||||
service_line.companyId,
|
||||
device.sim_id,
|
||||
},
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_request_events`
|
||||
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
|
||||
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
|
||||
FROM `sky_phone_company_requests` WHERE `id` = ?
|
||||
]],
|
||||
params = { created_event_id, request_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_request_events`
|
||||
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
|
||||
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
|
||||
FROM `sky_phone_company_requests`
|
||||
WHERE `company_id` = ? AND `customer_sim_id` = ?
|
||||
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
|
||||
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
|
||||
]],
|
||||
params = { status_event_id, service_line.companyId, device.sim_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
UPDATE `sky_phone_company_requests`
|
||||
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
|
||||
`company_activity_revision` = `company_activity_revision` + 1,
|
||||
`revision` = `revision` + 1, `mutation_token` = ?
|
||||
WHERE `company_id` = ? AND `customer_sim_id` = ?
|
||||
AND `channel` = 'service_line'
|
||||
AND `status` NOT IN ('completed', 'cancelled')
|
||||
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
|
||||
]],
|
||||
params = { mutation_token, service_line.companyId, device.sim_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_request_messages`
|
||||
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
|
||||
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
|
||||
FROM `sky_phone_company_requests`
|
||||
WHERE `mutation_token` = ? LIMIT 1
|
||||
]],
|
||||
params = { data.id, body, mutation_token },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_sms_messages`
|
||||
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
|
||||
`message_type`, `body`)
|
||||
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
|
||||
FROM `sky_phone_sims` sim
|
||||
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
|
||||
WHERE sim.`id` = ?
|
||||
]],
|
||||
params = {
|
||||
data.id,
|
||||
service_line.number,
|
||||
body,
|
||||
data.id,
|
||||
device.sim_id,
|
||||
},
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
|
||||
local routed = Bridge.Database.Query([[
|
||||
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
|
||||
FROM `sky_phone_sms_messages` sms
|
||||
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
|
||||
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
|
||||
LEFT JOIN `sky_phone_company_request_events` created
|
||||
ON created.`id` = ? AND created.`request_id` = request.`id`
|
||||
WHERE sms.`id` = ? LIMIT 1
|
||||
]], { created_event_id, data.id })
|
||||
if not routed[1] then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Service-line message %s committed without a complete route.",
|
||||
tostring(data.id),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local row = request_row(routed[1].request_id)
|
||||
if not row then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
|
||||
emit_request_change(row, true, true, source)
|
||||
if routed[1].created_event_id then
|
||||
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
|
||||
"newRequest",
|
||||
"work",
|
||||
row
|
||||
), source)
|
||||
elseif row.assigned_identifier then
|
||||
notify_identifier(
|
||||
row.assigned_identifier,
|
||||
row.company_id,
|
||||
"sky_phone:companies:notification",
|
||||
notification_payload("newMessage", "work", row)
|
||||
)
|
||||
end
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
messageId = data.id,
|
||||
requestId = row.id,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
|
||||
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
|
||||
if not allowed then
|
||||
@@ -1839,7 +2073,7 @@ Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source,
|
||||
end
|
||||
local company_id = data.companyId
|
||||
local definition = type(company_id) == "string" and definitions[company_id] or nil
|
||||
if not definition or not definition.Public or definition.Emergency then
|
||||
if not definition or not definition.Public then
|
||||
return { success = false, error = "company_not_found" }
|
||||
end
|
||||
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false)
|
||||
@@ -2090,6 +2324,11 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
then
|
||||
return { success = false, error = "invalid_status" }
|
||||
end
|
||||
local service_line = access.row.channel == "service_line"
|
||||
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
|
||||
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
|
||||
return { success = false, error = "messaging_unavailable" }
|
||||
end
|
||||
local message_id = uuid()
|
||||
local mutation_token = uuid()
|
||||
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
|
||||
@@ -2129,6 +2368,37 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
|
||||
}
|
||||
end
|
||||
if service_line then
|
||||
if access.audience == "customer" then
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_sms_messages`
|
||||
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
|
||||
`message_type`, `body`)
|
||||
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
|
||||
FROM `sky_phone_company_request_messages` message
|
||||
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
|
||||
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
|
||||
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
|
||||
]],
|
||||
params = { service_line.number, message_id },
|
||||
}
|
||||
else
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_sms_messages`
|
||||
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
|
||||
`message_type`, `body`)
|
||||
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
|
||||
FROM `sky_phone_company_request_messages` message
|
||||
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
|
||||
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
|
||||
WHERE message.`id` = ? AND message.`sender_type` = 'company'
|
||||
]],
|
||||
params = { service_line.number, message_id },
|
||||
}
|
||||
end
|
||||
end
|
||||
if new_status ~= access.row.status then
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
@@ -2143,16 +2413,32 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local inserted = Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
|
||||
{ message_id }
|
||||
)
|
||||
local inserted = Bridge.Database.Query([[
|
||||
SELECT message.`id`, sms.`id` AS `sms_id`
|
||||
FROM `sky_phone_company_request_messages` message
|
||||
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
|
||||
WHERE message.`id` = ? LIMIT 1
|
||||
]], { message_id })
|
||||
if not inserted[1] then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
if service_line and not inserted[1].sms_id then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Company request message %s committed without its service-line SMS.",
|
||||
tostring(message_id),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local row = request_row(request_id)
|
||||
emit_request_change(row, true, true, source)
|
||||
if access.audience == "customer" then
|
||||
if service_line then
|
||||
TriggerClientEvent("sky_phone:messages:changed", source, {
|
||||
phoneNumber = service_line.number,
|
||||
})
|
||||
end
|
||||
local notification = notification_payload("newMessage", "work", row)
|
||||
if row.assigned_identifier then
|
||||
notify_identifier(
|
||||
@@ -2162,6 +2448,12 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
notification
|
||||
)
|
||||
end
|
||||
elseif service_line then
|
||||
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
|
||||
phoneNumber = service_line.number,
|
||||
sender = service_line.name,
|
||||
voice = false,
|
||||
})
|
||||
else
|
||||
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
|
||||
"newMessage",
|
||||
@@ -2636,7 +2928,6 @@ Bridge.Callbacks.Register("sky_phone:companies:update-profile", function(source,
|
||||
local address = valid_text(data.address, Config.Companies.AddressMaxLength, true)
|
||||
if not revision or not description or not district or not location_label or not address
|
||||
or type(data.acceptsRequests) ~= "boolean"
|
||||
or (member.definition.Emergency and data.acceptsRequests)
|
||||
then
|
||||
return { success = false, error = "invalid_profile" }
|
||||
end
|
||||
|
||||
+287
-162
@@ -6,7 +6,10 @@ local exchange_lock = false
|
||||
local markets = {}
|
||||
local market_order = {}
|
||||
local market_dynamics = {}
|
||||
local market_state = {}
|
||||
local market_history = {}
|
||||
local market_cursor = 1
|
||||
local market_persistence_interval = 5 * 60 * 1000
|
||||
local global_market_trend = 0
|
||||
local global_market_cycle = {
|
||||
direction = 0,
|
||||
@@ -370,6 +373,100 @@ local function initialize_markets()
|
||||
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
|
||||
end
|
||||
|
||||
local function load_market_cache()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
||||
FROM `sky_phone_crypto_markets`
|
||||
]], {})
|
||||
local next_market_state = {}
|
||||
local next_market_history = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if markets[row.id] then
|
||||
next_market_state[row.id] = {
|
||||
price = tonumber(row.price) or markets[row.id].InitialPrice,
|
||||
version = tonumber(row.version) or 1,
|
||||
status = row.status,
|
||||
updated_at = tonumber(row.updated_at) or os.time(),
|
||||
dirty = false,
|
||||
}
|
||||
end
|
||||
end
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local state = next_market_state[market_id]
|
||||
if not state then
|
||||
error(("[sky_phone] Crypto market state is missing after initialization: %s"):format(market_id))
|
||||
end
|
||||
local rows_for_market = Bridge.Database.Query([[
|
||||
SELECT `price`,`version`, UNIX_TIMESTAMP(`created_at`) AS `created_at`
|
||||
FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
]], { market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
local history = {}
|
||||
for index = #rows_for_market, 1, -1 do
|
||||
local tick = rows_for_market[index]
|
||||
history[#history + 1] = {
|
||||
price = tonumber(tick.price) or state.price,
|
||||
version = tonumber(tick.version) or state.version,
|
||||
created_at = tonumber(tick.created_at) or state.updated_at,
|
||||
}
|
||||
end
|
||||
if #history == 0 then
|
||||
history[1] = {
|
||||
price = state.price,
|
||||
version = state.version,
|
||||
created_at = state.updated_at,
|
||||
}
|
||||
end
|
||||
next_market_history[market_id] = history
|
||||
end
|
||||
market_state = next_market_state
|
||||
market_history = next_market_history
|
||||
end
|
||||
|
||||
local function persist_market_cache()
|
||||
local queries = {}
|
||||
local persisted_markets = {}
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local state = market_state[market_id]
|
||||
if state and state.dirty then
|
||||
queries[#queries + 1] = {
|
||||
query = [[
|
||||
UPDATE `sky_phone_crypto_markets`
|
||||
SET `price` = ?, `version` = ?, `status` = ?, `updated_at` = FROM_UNIXTIME(?)
|
||||
WHERE `id` = ?
|
||||
]],
|
||||
params = { state.price, state.version, state.status, state.updated_at, market_id },
|
||||
}
|
||||
queries[#queries + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_crypto_market_ticks`
|
||||
(`market_id`,`version`,`price`,`created_at`) VALUES (?, ?, ?, FROM_UNIXTIME(?))
|
||||
]],
|
||||
params = { market_id, state.version, state.price, state.updated_at },
|
||||
}
|
||||
persisted_markets[#persisted_markets + 1] = market_id
|
||||
end
|
||||
end
|
||||
if #queries == 0 then
|
||||
return true
|
||||
end
|
||||
queries[#queries + 1] = {
|
||||
query = [[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `created_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||
]],
|
||||
params = {},
|
||||
}
|
||||
if not Bridge.Database.Transaction(queries) then
|
||||
print("[sky_phone] Failed to persist the crypto market cache.")
|
||||
return false
|
||||
end
|
||||
for _, market_id in ipairs(persisted_markets) do
|
||||
market_state[market_id].dirty = false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function require_phone(source)
|
||||
local phone_session, error_response = SkyPhone.RequireSession(source)
|
||||
if not phone_session then
|
||||
@@ -451,20 +548,7 @@ local function balance(account, asset)
|
||||
row and (tonumber(row.version) or 0) or 0
|
||||
end
|
||||
|
||||
local function market_rows()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
||||
FROM `sky_phone_crypto_markets`
|
||||
]], {})
|
||||
local indexed = {}
|
||||
for _, row in ipairs(rows) do
|
||||
indexed[row.id] = row
|
||||
end
|
||||
return indexed
|
||||
end
|
||||
|
||||
local function market_dtos(selected_market_ids)
|
||||
local current = market_rows()
|
||||
local selected = nil
|
||||
if selected_market_ids then
|
||||
selected = {}
|
||||
@@ -472,28 +556,16 @@ local function market_dtos(selected_market_ids)
|
||||
selected[market_id] = true
|
||||
end
|
||||
end
|
||||
local daily_rows = Bridge.Database.Query([[
|
||||
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
|
||||
FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||
GROUP BY `market_id`
|
||||
]], {})
|
||||
local daily = {}
|
||||
for _, daily_row in ipairs(daily_rows) do
|
||||
daily[daily_row.market_id] = daily_row
|
||||
end
|
||||
local result = {}
|
||||
for _, market_id in ipairs(market_order) do
|
||||
if not selected or selected[market_id] then
|
||||
local config = markets[market_id]
|
||||
local row = current[market_id]
|
||||
local ticks = Bridge.Database.Query([[
|
||||
SELECT `price` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
]], { market_id, Config.Crypto.SparklinePoints })
|
||||
local row = market_state[market_id]
|
||||
local history = market_history[market_id]
|
||||
local prices = {}
|
||||
for index = #ticks, 1, -1 do
|
||||
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
|
||||
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
|
||||
for index = first_history_index, #history do
|
||||
prices[#prices + 1] = history[index].price
|
||||
end
|
||||
if #prices == 0 then
|
||||
prices[1] = tonumber(row.price)
|
||||
@@ -509,7 +581,17 @@ local function market_dtos(selected_market_ids)
|
||||
end
|
||||
local first = prices[1]
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local daily_range = daily[market_id]
|
||||
local daily_low = price
|
||||
local daily_high = price
|
||||
local daily_cutoff = os.time() - 24 * 60 * 60
|
||||
for index = #history, 1, -1 do
|
||||
local tick = history[index]
|
||||
if tick.created_at < daily_cutoff then
|
||||
break
|
||||
end
|
||||
daily_low = math.min(daily_low, tick.price)
|
||||
daily_high = math.max(daily_high, tick.price)
|
||||
end
|
||||
result[#result + 1] = {
|
||||
id = market_id,
|
||||
symbol = config.Symbol,
|
||||
@@ -519,8 +601,8 @@ local function market_dtos(selected_market_ids)
|
||||
price = decimal_string(price, Config.Crypto.PriceScale),
|
||||
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
|
||||
enabled = row.status == "active",
|
||||
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
|
||||
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
|
||||
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
|
||||
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
|
||||
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
|
||||
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
|
||||
priceHistory = price_history,
|
||||
@@ -567,13 +649,12 @@ end
|
||||
|
||||
local function bootstrap(profile)
|
||||
local cash = balance(account_id(profile.id), "CASH")
|
||||
local current_markets = market_rows()
|
||||
local holdings = {}
|
||||
local portfolio = cash
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local available = balance(account_id(profile.id), market_id)
|
||||
if available > 0 then
|
||||
local price = tonumber(current_markets[market_id].price) or 0
|
||||
local price = tonumber(market_state[market_id].price) or 0
|
||||
local value = math.floor(available * price / Config.Crypto.AssetScale)
|
||||
local fill = Bridge.Database.Query([[
|
||||
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
|
||||
@@ -1044,10 +1125,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
|
||||
if not config or not side or not quantity then
|
||||
return { success = false, error = "invalid_quantity" }
|
||||
end
|
||||
local market = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ config.Id }
|
||||
)[1]
|
||||
local market = market_state[config.Id]
|
||||
if not market or market.status ~= "active" then
|
||||
return { success = false, error = "market_unavailable" }
|
||||
end
|
||||
@@ -1127,9 +1205,8 @@ local function execute_trade(profile, data)
|
||||
or { success = false, error = "duplicate_request" }
|
||||
end
|
||||
local quote = Bridge.Database.Query([[
|
||||
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
|
||||
SELECT quote.*
|
||||
FROM `sky_phone_crypto_quotes` quote
|
||||
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
|
||||
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
|
||||
]], { data.quoteId, profile.id })[1]
|
||||
if not quote or quote.consumed_operation_id then
|
||||
@@ -1142,7 +1219,10 @@ local function execute_trade(profile, data)
|
||||
if not expiry or tonumber(expiry.expires_at) < os.time() then
|
||||
return { success = false, error = "quote_expired" }
|
||||
end
|
||||
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
|
||||
local current_market = market_state[quote.market_id]
|
||||
if not current_market or current_market.status ~= "active"
|
||||
or current_market.version ~= tonumber(quote.market_version)
|
||||
then
|
||||
return { success = false, error = "quote_expired" }
|
||||
end
|
||||
local quantity = tonumber(quote.quantity)
|
||||
@@ -1367,8 +1447,7 @@ end)
|
||||
ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
|
||||
load_market_cache()
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||
@@ -1416,8 +1495,6 @@ local function reconcile_settlements(include_recent)
|
||||
end
|
||||
end
|
||||
|
||||
reconcile_settlements(true)
|
||||
|
||||
local function crypto_random_int(minimum, maximum, failure_message)
|
||||
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
|
||||
if type(value) ~= "number" then
|
||||
@@ -1500,134 +1577,182 @@ local function advance_market_cycle(config, dynamics)
|
||||
return dynamics.cycle_bias
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(5 * 60 * 1000)
|
||||
reconcile_settlements(false)
|
||||
local scheduler_generation = 0
|
||||
|
||||
local function start_crypto_schedulers()
|
||||
scheduler_generation = scheduler_generation + 1
|
||||
if Config.Crypto.Enabled ~= true then
|
||||
return
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
Wait(tick_seconds * 1000)
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(market_persistence_interval)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(persist_market_cache)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
reconcile_settlements(false)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
Wait(tick_seconds * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = market_state[market_id]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
local direction_roll = crypto_random_int(
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
local direction_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = row.version + 1
|
||||
local updated_at = os.time()
|
||||
row.price = next_price
|
||||
row.version = next_version
|
||||
row.updated_at = updated_at
|
||||
row.dirty = true
|
||||
local history = market_history[market_id]
|
||||
history[#history + 1] = {
|
||||
price = next_price,
|
||||
version = next_version,
|
||||
created_at = updated_at,
|
||||
}
|
||||
if #history > Config.Crypto.HistoryRetentionTicks then
|
||||
table.remove(history, 1)
|
||||
end
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
end
|
||||
end
|
||||
end
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
if exchange_lock then
|
||||
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
|
||||
return
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
if not persist_market_cache() then
|
||||
return
|
||||
end
|
||||
initialize_markets()
|
||||
load_market_cache()
|
||||
start_crypto_schedulers()
|
||||
end)
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
persist_market_cache()
|
||||
end
|
||||
end)
|
||||
start_crypto_schedulers()
|
||||
|
||||
end)
|
||||
|
||||
@@ -931,10 +931,16 @@ end)
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
|
||||
local batch_size = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_darkchat_messages`
|
||||
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
|
||||
]], {})
|
||||
ORDER BY `expires_at`, `id`
|
||||
LIMIT ?
|
||||
]], { batch_size })
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
@@ -2710,6 +2710,7 @@ local schema = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "channel", type = "ENUM('app','service_line') NOT NULL DEFAULT 'app'" },
|
||||
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
|
||||
@@ -2729,6 +2730,7 @@ local schema = {
|
||||
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
|
||||
{ name = "idx_sky_phone_company_requests_service_line", columns = "(`company_id`, `customer_sim_id`, `channel`, `status`, `updated_at`, `id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
|
||||
|
||||
@@ -943,7 +943,7 @@ local function run_verify_command(source, arguments)
|
||||
notificationType = notification_type,
|
||||
})
|
||||
end
|
||||
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then
|
||||
if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then
|
||||
send_command_feedback(command_locale.noPermission, "error")
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
|
||||
@@ -97,6 +97,14 @@ local function current_device(source)
|
||||
return device
|
||||
end
|
||||
|
||||
local function resolve_message_recipient(value)
|
||||
local service_line = SkyPhoneCompanies.GetServiceLine(value)
|
||||
if service_line then
|
||||
return service_line.number, service_line
|
||||
end
|
||||
return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix), nil
|
||||
end
|
||||
|
||||
local function shared_contact(device, contact_id)
|
||||
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
|
||||
return nil, "invalid_contact"
|
||||
@@ -366,7 +374,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
local number = resolve_message_recipient(data.phoneNumber)
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
@@ -407,11 +415,7 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
|
||||
local numbers = {}
|
||||
local seen = {}
|
||||
for index = 1, #data.phoneNumbers do
|
||||
local number = SkyPhoneSimNumber.Normalize(
|
||||
data.phoneNumbers[index],
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
)
|
||||
local number = resolve_message_recipient(data.phoneNumbers[index])
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
@@ -483,7 +487,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
local number, service_line = resolve_message_recipient(data.phoneNumber)
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
@@ -536,6 +540,42 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
else
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
if service_line then
|
||||
if not service_line.canMessage then
|
||||
return { success = false, error = "messaging_unavailable" }
|
||||
end
|
||||
if message_type ~= "text" then
|
||||
return { success = false, error = "service_line_text_only" }
|
||||
end
|
||||
local id = uuid()
|
||||
local routed = SkyPhoneCompanies.RouteServiceLineMessage(source, {
|
||||
id = id,
|
||||
body = body,
|
||||
messageType = message_type,
|
||||
phoneNumber = number,
|
||||
})
|
||||
if not routed or not routed.success then
|
||||
return routed or { success = false, error = "request_failed" }
|
||||
end
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`,
|
||||
`media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`,
|
||||
`read_at`, `created_at`, 'sent' AS `direction`
|
||||
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
|
||||
]], { id })
|
||||
if not rows[1] then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Routed service-line SMS %s could not be read back.",
|
||||
tostring(id),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local message = format_message(rows[1])
|
||||
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
|
||||
return { success = true, data = message }
|
||||
end
|
||||
local recipients = Bridge.Database.Query([[
|
||||
SELECT s.`id`, s.`phone_number`
|
||||
FROM `sky_phone_sims` s
|
||||
|
||||
@@ -54,12 +54,28 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
|
||||
|
||||
SkyPhone = {}
|
||||
|
||||
function SkyPhone.IsAppEnabled(app_id)
|
||||
return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false
|
||||
end
|
||||
|
||||
function SkyPhone.GetDisabledApps()
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
return disabled
|
||||
end
|
||||
|
||||
local sessions = {}
|
||||
local preferred_device_imeis = {}
|
||||
local equipped_phone_numbers = {}
|
||||
local equipped_phone_identifiers = {}
|
||||
local equipped_phone_sources = {}
|
||||
local operation_attempts = {}
|
||||
local phone_open_in_progress = {}
|
||||
local character_device_cache = {}
|
||||
|
||||
local function trim(value)
|
||||
@@ -548,6 +564,7 @@ local function bootstrap(source, security, security_loaded)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
phoneNumberFormat = {
|
||||
length = Config.Sim.NumberLength,
|
||||
groups = Config.Sim.NumberGroups,
|
||||
@@ -839,7 +856,7 @@ function SkyPhone.RefreshDevice(imei)
|
||||
end
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
local function perform_phone_open(source, used_item)
|
||||
local opened_at = GetGameTimer()
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
@@ -926,6 +943,38 @@ local function open_phone(source, used_item)
|
||||
return true
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
if phone_open_in_progress[source] then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
|
||||
return false, "operation_in_progress"
|
||||
end
|
||||
|
||||
local request_limit = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20)
|
||||
)
|
||||
if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "rate_limited")
|
||||
return false, "rate_limited"
|
||||
end
|
||||
|
||||
phone_open_in_progress[source] = true
|
||||
local completed, success, error_code = pcall(perform_phone_open, source, used_item)
|
||||
phone_open_in_progress[source] = nil
|
||||
if not completed then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Phone open failed unexpectedly for source %s: %s",
|
||||
tostring(source),
|
||||
tostring(success),
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
|
||||
return false, "request_failed"
|
||||
end
|
||||
return success, error_code
|
||||
end
|
||||
|
||||
phone_open_handler = open_phone
|
||||
flush_pending_phone_opens()
|
||||
|
||||
@@ -1028,6 +1077,7 @@ AddEventHandler("playerDropped", function()
|
||||
sessions[source] = nil
|
||||
discard_equipped_phone_number(source)
|
||||
operation_attempts[source] = nil
|
||||
phone_open_in_progress[source] = nil
|
||||
character_device_cache[source] = nil
|
||||
preferred_device_imeis[source] = nil
|
||||
end)
|
||||
@@ -1036,6 +1086,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
sessions = {}
|
||||
operation_attempts = {}
|
||||
phone_open_in_progress = {}
|
||||
character_device_cache = {}
|
||||
preferred_device_imeis = {}
|
||||
equipped_phone_numbers = {}
|
||||
|
||||
@@ -17,9 +17,31 @@ local updated_at
|
||||
local updated_by_name
|
||||
local is_sequence
|
||||
|
||||
local FIXED_CONFIG_PATHS = {
|
||||
CommandPermissions = true,
|
||||
["AdminPanel.AdminGroups"] = true,
|
||||
["TestData.AdminGroups"] = true,
|
||||
["FlipTok.AdminGroups"] = true,
|
||||
["Picstagram.AdminGroups"] = true,
|
||||
}
|
||||
|
||||
if configurator_enabled then
|
||||
local border = "======================================================================"
|
||||
print(([[
|
||||
^1%s^0
|
||||
^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
|
||||
^1%s^0
|
||||
^1 The Phone Configurator is ENABLED.^0
|
||||
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
|
||||
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
|
||||
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
|
||||
^1%s^0]]):format(border, border, border))
|
||||
end
|
||||
|
||||
local CLIENT_CONFIG_KEYS = {
|
||||
AdminPanel = true,
|
||||
Animations = true,
|
||||
Apps = true,
|
||||
Banking = true,
|
||||
Billing = true,
|
||||
Bridge = true,
|
||||
@@ -252,7 +274,7 @@ local function upgrade_legacy_map(defaults, saved)
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local function merge_values(defaults, saved, path)
|
||||
local function merge_values(defaults, saved, path, excluded_paths)
|
||||
path = path or ""
|
||||
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
||||
return copy_value(saved)
|
||||
@@ -278,7 +300,12 @@ local function merge_values(defaults, saved, path)
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
value = saved_entry
|
||||
and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key))
|
||||
and merge_values(
|
||||
entry.value,
|
||||
saved_entry.value,
|
||||
path .. "." .. tostring(entry.key),
|
||||
excluded_paths
|
||||
)
|
||||
or copy_value(entry.value),
|
||||
}
|
||||
included[identity] = true
|
||||
@@ -298,7 +325,7 @@ local function merge_values(defaults, saved, path)
|
||||
local merged = copy_value(saved)
|
||||
for index, child in ipairs(defaults) do
|
||||
merged[index] = saved[index] ~= nil
|
||||
and merge_values(child, saved[index], path .. "." .. tostring(index))
|
||||
and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths)
|
||||
or copy_value(child)
|
||||
end
|
||||
return merged
|
||||
@@ -309,11 +336,13 @@ local function merge_values(defaults, saved, path)
|
||||
|
||||
local merged = copy_value(defaults)
|
||||
for key, child in pairs(saved) do
|
||||
if merged[key] ~= nil then
|
||||
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
|
||||
merged[key] = merge_values(merged[key], child, child_path)
|
||||
else
|
||||
merged[key] = copy_value(child)
|
||||
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
|
||||
if not excluded_paths or not excluded_paths[child_path] then
|
||||
if merged[key] ~= nil then
|
||||
merged[key] = merge_values(merged[key], child, child_path, excluded_paths)
|
||||
else
|
||||
merged[key] = copy_value(child)
|
||||
end
|
||||
end
|
||||
end
|
||||
return merged
|
||||
@@ -356,10 +385,12 @@ local function apply_runtime_configuration()
|
||||
|
||||
local runtime_config = deserialize_value(stored_config)
|
||||
for key, value in pairs(runtime_config) do
|
||||
if type(Config[key]) == "table" and type(value) == "table" then
|
||||
apply_runtime_table(Config[key], value)
|
||||
else
|
||||
Config[key] = value
|
||||
if key ~= "CommandPermissions" then
|
||||
if type(Config[key]) == "table" and type(value) == "table" then
|
||||
apply_runtime_table(Config[key], value)
|
||||
else
|
||||
Config[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
local runtime_media = deserialize_value(stored_media)
|
||||
@@ -532,6 +563,75 @@ local function empty_structure(scope, path)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function next_available_company_service_number(configuration)
|
||||
configuration = configuration or stored_config
|
||||
local maximum_length = configuration.Sim.NumberLength
|
||||
local service_length = math.max(1, math.min(3, maximum_length - 1))
|
||||
local first_candidate = service_length == 1 and 5 or 5 * (10 ^ (service_length - 1))
|
||||
local last_candidate = (10 ^ service_length) - 1
|
||||
local used = {}
|
||||
for _, definition in pairs(configuration.Companies.Definitions) do
|
||||
used[tostring(definition.ServiceLine.Number):gsub("%D", "")] = true
|
||||
end
|
||||
for offset = 0, last_candidate - 1 do
|
||||
local candidate = ((first_candidate - 1 + offset) % last_candidate) + 1
|
||||
local number = ("%0" .. tostring(service_length) .. "d"):format(candidate)
|
||||
if not used[number] then
|
||||
return number
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function company_definition_entry_default(company_id, configuration)
|
||||
configuration = configuration or stored_config
|
||||
local name = company_id and humanize(company_id) or ""
|
||||
local logo_seed = company_id or "new"
|
||||
return {
|
||||
AcceptsRequests = true,
|
||||
Address = "",
|
||||
Category = configuration.Companies.Categories[1] or default_config.Companies.Categories[1],
|
||||
DefaultAvailability = "closed",
|
||||
Description = "",
|
||||
District = "",
|
||||
Emergency = false,
|
||||
Icon = "building",
|
||||
Job = company_id or "",
|
||||
Location = { __skyType = "vector3", x = 0.0, y = 0.0, z = 0.0 },
|
||||
LocationLabel = "",
|
||||
LogoUrl = ("https://picsum.photos/seed/companies-%s-logo/180/180"):format(logo_seed),
|
||||
Name = name,
|
||||
Permissions = {
|
||||
Announcement = 0,
|
||||
Assign = 0,
|
||||
Availability = 0,
|
||||
Hours = 0,
|
||||
Profile = 0,
|
||||
Services = 0,
|
||||
WorkQueue = 0,
|
||||
},
|
||||
Public = true,
|
||||
ServiceLine = {
|
||||
AutoContact = true,
|
||||
CanCall = true,
|
||||
CanMessage = false,
|
||||
MinimumGrade = 0,
|
||||
Number = next_available_company_service_number(configuration),
|
||||
Routing = "round_robin",
|
||||
},
|
||||
Services = {
|
||||
{
|
||||
Description = "",
|
||||
Id = company_id or "",
|
||||
Price = "",
|
||||
RequestsEnabled = true,
|
||||
Title = name,
|
||||
},
|
||||
},
|
||||
Verified = false,
|
||||
}
|
||||
end
|
||||
|
||||
local function build_structure(value, scope, path)
|
||||
local value_type = type(value)
|
||||
if scope == "config" and path == "Phone.Keybind" then
|
||||
@@ -550,6 +650,7 @@ local function build_structure(value, scope, path)
|
||||
fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key))
|
||||
end
|
||||
return {
|
||||
entryDefault = company_definition_entry_default(),
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
@@ -1032,20 +1133,230 @@ local function read_stored_row()
|
||||
end
|
||||
|
||||
local function apply_stored_row(row)
|
||||
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "")
|
||||
stored_config = merge_values(
|
||||
default_config,
|
||||
decode_payload(row.config_payload, "config"),
|
||||
"",
|
||||
FIXED_CONFIG_PATHS
|
||||
)
|
||||
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "")
|
||||
revision = tonumber(row.revision) or 1
|
||||
updated_at = row.updated_at
|
||||
updated_by_name = row.updated_by_name
|
||||
end
|
||||
|
||||
local function migrate_blank_company_definitions()
|
||||
local migration_name = "sky-phone:configurator:company-definition-defaults:v1"
|
||||
local completed = Bridge.Database.Query(
|
||||
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
|
||||
{ migration_name }
|
||||
)
|
||||
if completed[1] then
|
||||
return
|
||||
end
|
||||
|
||||
local row = read_stored_row()
|
||||
local config_payload = decode_payload(row.config_payload, "config")
|
||||
local next_config = merge_values(default_config, config_payload, "", FIXED_CONFIG_PATHS)
|
||||
local migrated_companies = {}
|
||||
for company_id, definition in pairs(next_config.Companies.Definitions) do
|
||||
local line = type(definition) == "table" and definition.ServiceLine or nil
|
||||
if type(company_id) == "string"
|
||||
and company_id:match("^[a-z0-9_-]+$")
|
||||
and type(definition) == "table"
|
||||
and (definition.Job == "" or definition.Job == company_id)
|
||||
and definition.Name == ""
|
||||
and definition.Category == ""
|
||||
and definition.Icon == ""
|
||||
and definition.LogoUrl == ""
|
||||
and definition.DefaultAvailability == ""
|
||||
and type(line) == "table"
|
||||
and line.Number == ""
|
||||
and line.Routing == ""
|
||||
and type(definition.Services) == "table"
|
||||
and next(definition.Services) == nil
|
||||
then
|
||||
next_config.Companies.Definitions[company_id] = company_definition_entry_default(
|
||||
company_id,
|
||||
next_config
|
||||
)
|
||||
migrated_companies[#migrated_companies + 1] = company_id
|
||||
end
|
||||
end
|
||||
table.sort(migrated_companies)
|
||||
|
||||
local statements = {}
|
||||
if #migrated_companies > 0 then
|
||||
statements[#statements + 1] = {
|
||||
query = ([[
|
||||
UPDATE `%s`
|
||||
SET `config_payload` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ?
|
||||
]]):format(TABLE_NAME),
|
||||
params = { encode_payload(next_config, "config"), CONFIG_ROW_ID },
|
||||
}
|
||||
end
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
|
||||
VALUES (?, ?, ?)
|
||||
]],
|
||||
params = {
|
||||
migration_name,
|
||||
"sky-phone",
|
||||
json.encode({ companies = migrated_companies }),
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Could not migrate blank Phone Configurator company definitions.")
|
||||
end
|
||||
if #migrated_companies == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
apply_stored_row(read_stored_row())
|
||||
apply_runtime_configuration()
|
||||
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
|
||||
SkyPhoneConfigurator.Broadcast(-1)
|
||||
Bridge.Debug(
|
||||
"info",
|
||||
"[sky_phone] Migrated blank Phone Configurator company definitions: %s",
|
||||
table.concat(migrated_companies, ", "),
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
|
||||
local function migrate_police_request_defaults()
|
||||
local migration_name = "sky-phone:configurator:police-requests:v1"
|
||||
local completed = Bridge.Database.Query(
|
||||
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
|
||||
{ migration_name }
|
||||
)
|
||||
if completed[1] then
|
||||
return
|
||||
end
|
||||
|
||||
local row = read_stored_row()
|
||||
local config_payload = decode_payload(row.config_payload, "config")
|
||||
local police = config_payload.Companies
|
||||
and config_payload.Companies.Definitions
|
||||
and config_payload.Companies.Definitions.police
|
||||
local migrated = type(police) == "table"
|
||||
and police.Emergency == true
|
||||
and police.AcceptsRequests == false
|
||||
and type(police.Services) == "table"
|
||||
and next(police.Services) == nil
|
||||
local statements = {}
|
||||
if migrated then
|
||||
local defaults = default_config.Companies.Definitions.police
|
||||
police.AcceptsRequests = defaults.AcceptsRequests
|
||||
police.Services = copy_value(defaults.Services)
|
||||
statements[#statements + 1] = {
|
||||
query = ([[
|
||||
UPDATE `%s`
|
||||
SET `config_payload` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ?
|
||||
]]):format(TABLE_NAME),
|
||||
params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID },
|
||||
}
|
||||
end
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
|
||||
VALUES (?, ?, ?)
|
||||
]],
|
||||
params = {
|
||||
migration_name,
|
||||
"sky-phone",
|
||||
json.encode({ migrated = migrated }),
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Could not migrate Phone Configurator police request defaults.")
|
||||
end
|
||||
if not migrated then
|
||||
return
|
||||
end
|
||||
|
||||
apply_stored_row(read_stored_row())
|
||||
apply_runtime_configuration()
|
||||
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
|
||||
SkyPhoneConfigurator.Broadcast(-1)
|
||||
Bridge.Debug(
|
||||
"info",
|
||||
"[sky_phone] Migrated Phone Configurator police request defaults.",
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
|
||||
local function migrate_police_service_line_messaging()
|
||||
local migration_name = "sky-phone:configurator:service-line-messaging:v2"
|
||||
local completed = Bridge.Database.Query(
|
||||
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
|
||||
{ migration_name }
|
||||
)
|
||||
if completed[1] then
|
||||
return
|
||||
end
|
||||
|
||||
local row = read_stored_row()
|
||||
local config_payload = decode_payload(row.config_payload, "config")
|
||||
local police = config_payload.Companies
|
||||
and config_payload.Companies.Definitions
|
||||
and config_payload.Companies.Definitions.police
|
||||
local line = type(police) == "table" and police.ServiceLine or nil
|
||||
local migrated = type(line) == "table" and line.CanMessage ~= true
|
||||
if migrated then
|
||||
line.CanMessage = true
|
||||
end
|
||||
|
||||
local statements = {}
|
||||
if migrated then
|
||||
statements[#statements + 1] = {
|
||||
query = ([[
|
||||
UPDATE `%s`
|
||||
SET `config_payload` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ?
|
||||
]]):format(TABLE_NAME),
|
||||
params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID },
|
||||
}
|
||||
end
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
|
||||
VALUES (?, ?, ?)
|
||||
]],
|
||||
params = {
|
||||
migration_name,
|
||||
"sky-phone",
|
||||
json.encode({ police = migrated }),
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Could not enable Phone Configurator police service-line messaging.")
|
||||
end
|
||||
if not migrated then
|
||||
return
|
||||
end
|
||||
|
||||
apply_stored_row(read_stored_row())
|
||||
apply_runtime_configuration()
|
||||
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
|
||||
SkyPhoneConfigurator.Broadcast(-1)
|
||||
Bridge.Debug(
|
||||
"info",
|
||||
"[sky_phone] Enabled Phone Configurator police service-line messaging.",
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
|
||||
default_config = {}
|
||||
for key, value in pairs(Config) do
|
||||
if key ~= "Media" and key ~= "PhoneConfigurator" then
|
||||
for key, value in pairs(ConfigDefaults) do
|
||||
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
|
||||
default_config[key] = serialize_value(value)
|
||||
end
|
||||
end
|
||||
default_media = serialize_value(Config.Media)
|
||||
default_media = serialize_value(ConfigDefaults.Media)
|
||||
|
||||
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
|
||||
Bridge.Database.Query(([[
|
||||
@@ -1059,6 +1370,9 @@ Bridge.Database.Query(([[
|
||||
|
||||
apply_stored_row(read_stored_row())
|
||||
apply_runtime_configuration()
|
||||
Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)
|
||||
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
|
||||
Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)
|
||||
|
||||
function SkyPhoneConfigurator.GetAdminData()
|
||||
local data = build_admin_data()
|
||||
@@ -1108,6 +1422,18 @@ function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier,
|
||||
end
|
||||
end
|
||||
|
||||
local candidate_config = deserialize_value(next_config)
|
||||
local companies_valid, validation_error = SkyPhoneCompanies.ValidateConfiguration(candidate_config)
|
||||
if not companies_valid then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected invalid Phone Configurator Companies configuration: %s",
|
||||
validation_error,
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "invalid_company_configuration" }
|
||||
end
|
||||
|
||||
local config_encoded = encode_payload(next_config, "config")
|
||||
local media_encoded = encode_payload(next_media, "media")
|
||||
local result = Bridge.Database.Query(([[
|
||||
|
||||
@@ -319,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id)
|
||||
end
|
||||
|
||||
local function is_admin(source)
|
||||
return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups)
|
||||
return Bridge.Framework.HasPermission(source, "picstagramadmin")
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data)
|
||||
@@ -1464,7 +1464,7 @@ local function run_verify_command(source, args)
|
||||
notificationType = notification_type,
|
||||
})
|
||||
end
|
||||
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then
|
||||
if source ~= 0 and not Bridge.Framework.HasPermission(source, "picstagramverify") then
|
||||
send_command_feedback(command_locale.noPermission, "error")
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
|
||||
@@ -1836,16 +1836,21 @@ CreateThread(function()
|
||||
while true do
|
||||
local recovery_success, recovery_rows = pcall(
|
||||
Bridge.Database.Query,
|
||||
ride_select .. (([[
|
||||
WHERE r.`refund_status` IN ('pending','completed')
|
||||
AND (
|
||||
r.`status` = 'cancelled'
|
||||
OR (
|
||||
r.`status` = 'payment_pending'
|
||||
AND r.`updated_at` <= DATE_SUB(
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL %d SECOND
|
||||
)
|
||||
(([[
|
||||
SELECT r.`id`, r.`status`, r.`refund_status`, r.`price`,
|
||||
passenger.`owner_identifier` AS `passenger_identifier`
|
||||
FROM `sky_phone_skyride_rides` r
|
||||
INNER JOIN `sky_phone_skyride_profiles` passenger
|
||||
ON passenger.`id` = r.`passenger_profile_id`
|
||||
WHERE (
|
||||
r.`refund_status` = 'pending'
|
||||
AND r.`status` = 'cancelled'
|
||||
) OR (
|
||||
r.`refund_status` IN ('pending','completed')
|
||||
AND r.`status` = 'payment_pending'
|
||||
AND r.`updated_at` <= DATE_SUB(
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL %d SECOND
|
||||
)
|
||||
)
|
||||
ORDER BY r.`updated_at` ASC
|
||||
|
||||
@@ -1045,7 +1045,7 @@ local function run_test_data_command(source)
|
||||
Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.")
|
||||
return
|
||||
end
|
||||
if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then
|
||||
if Config.TestData.AdminOnly and not Bridge.Framework.HasPermission(source, "phonetestdata") then
|
||||
Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source))
|
||||
TriggerClientEvent("sky_phone:testdata:feedback", source, false)
|
||||
return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+63
-5
@@ -4,6 +4,20 @@ local camera_target = nil
|
||||
local camera_created = false
|
||||
local camera_destroyed = false
|
||||
local scripted_camera_rendering = false
|
||||
local event_handlers = {}
|
||||
local threads = {}
|
||||
local hold_to_look_pressed = false
|
||||
local disabled_controls = {}
|
||||
local disabled_pressed_controls = {}
|
||||
local disabled_control_normals = {}
|
||||
local triggered_events = {}
|
||||
local thread_stop = {}
|
||||
|
||||
SkyPhoneFocus = {
|
||||
IsHoldToLookPressed = function()
|
||||
return hold_to_look_pressed
|
||||
end,
|
||||
}
|
||||
|
||||
local vector_meta = {}
|
||||
vector_meta.__index = vector_meta
|
||||
@@ -32,12 +46,20 @@ function RegisterNUICallback(name, callback)
|
||||
end
|
||||
|
||||
function RegisterNetEvent() end
|
||||
function AddEventHandler() end
|
||||
function TriggerEvent() end
|
||||
function AddEventHandler(name, callback)
|
||||
event_handlers[name] = callback
|
||||
end
|
||||
function TriggerEvent(name, data)
|
||||
triggered_events[#triggered_events + 1] = { name = name, data = data }
|
||||
end
|
||||
function TriggerServerEvent() end
|
||||
function SendNUIMessage() end
|
||||
function CreateThread() end
|
||||
function Wait() end
|
||||
function CreateThread(callback)
|
||||
threads[#threads + 1] = callback
|
||||
end
|
||||
function Wait()
|
||||
error(thread_stop, 0)
|
||||
end
|
||||
function PlayerPedId() return 7 end
|
||||
function PlayerId() return 8 end
|
||||
function GetFollowPedCamViewMode() return 1 end
|
||||
@@ -47,10 +69,19 @@ function DisplayRadar() end
|
||||
function SetFollowPedCamViewMode() end
|
||||
function SetFollowVehicleCamViewMode() end
|
||||
function IsPedInAnyVehicle() return false end
|
||||
function DisableControlAction() end
|
||||
function DisableControlAction(group, control, disabled)
|
||||
assert(group == 0 and disabled, "camera controls must be disabled in the primary input group")
|
||||
disabled_controls[control] = true
|
||||
end
|
||||
function DisablePlayerFiring() end
|
||||
function HideHudAndRadarThisFrame() end
|
||||
function GetCurrentResourceName() return "sky_phone" end
|
||||
function IsDisabledControlJustPressed() return false end
|
||||
function IsDisabledControlPressed(group, control)
|
||||
assert(group == 0, "camera passthrough must read the primary input group")
|
||||
return disabled_pressed_controls[control] == true
|
||||
end
|
||||
function GetDisabledControlNormal(_, control) return disabled_control_normals[control] or 0.0 end
|
||||
|
||||
function GetEntityCoords()
|
||||
return vector3(10.0, 20.0, 1.0)
|
||||
@@ -120,6 +151,33 @@ assert(close_enough(camera_target.x, 10.0))
|
||||
assert(close_enough(camera_target.y, 20.0))
|
||||
assert(close_enough(camera_target.z, 2.73))
|
||||
|
||||
event_handlers["sky_phone:client:cameraFocusApplied"]({
|
||||
active = true,
|
||||
cursor = false,
|
||||
focused = true,
|
||||
gameInput = true,
|
||||
})
|
||||
disabled_pressed_controls[22] = true
|
||||
disabled_control_normals[1] = 0.2
|
||||
local watcher_ok, watcher_error = pcall(threads[2])
|
||||
assert(not watcher_ok and watcher_error == thread_stop, "camera watcher must run one controlled frame")
|
||||
local camera_focus_event = triggered_events[#triggered_events]
|
||||
assert(
|
||||
camera_focus_event.name == "sky_phone:client:setCameraFocus"
|
||||
and camera_focus_event.data.active
|
||||
and not camera_focus_event.data.nuiFocused,
|
||||
"holding Space must release the camera cursor for simultaneous look and movement"
|
||||
)
|
||||
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
|
||||
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
|
||||
end
|
||||
local camera_ok, camera_error = pcall(threads[1])
|
||||
assert(not camera_ok and camera_error == thread_stop, "selfie camera must render one controlled frame")
|
||||
assert(not close_enough(camera_coord.x, 10.0), "horizontal look input must orbit the selfie camera around the player")
|
||||
assert(camera_coord.y < 21.05, "selfie orbit must retain its configured distance from the player")
|
||||
disabled_pressed_controls[22] = false
|
||||
disabled_control_normals[1] = 0.0
|
||||
|
||||
assert(response_from("camera:setFacing", { front = false }).success)
|
||||
assert(camera_destroyed and not scripted_camera_rendering, "rear mode must release the selfie camera")
|
||||
|
||||
|
||||
+57
-3
@@ -5,6 +5,9 @@ local event_handlers = {}
|
||||
local nui_callbacks = {}
|
||||
local nui_focus = nil
|
||||
local nui_keep_input = nil
|
||||
local pressed_controls = {}
|
||||
local disabled_pressed_controls = {}
|
||||
local triggered_events = {}
|
||||
|
||||
Config = {
|
||||
Phone = {
|
||||
@@ -34,7 +37,19 @@ function SetNuiFocusKeepInput(keep_input)
|
||||
nui_keep_input = keep_input
|
||||
end
|
||||
|
||||
function TriggerEvent() end
|
||||
function TriggerEvent(name, data)
|
||||
triggered_events[#triggered_events + 1] = { name = name, data = data }
|
||||
end
|
||||
|
||||
function IsControlPressed(group, control)
|
||||
assert(group == 0, "HoldToLook must read the primary input group")
|
||||
return pressed_controls[control] == true
|
||||
end
|
||||
|
||||
function IsDisabledControlPressed(group, control)
|
||||
assert(group == 0, "HoldToLook must read disabled controls from the primary input group")
|
||||
return disabled_pressed_controls[control] == true
|
||||
end
|
||||
|
||||
function DisableControlAction(group, control, disabled)
|
||||
assert(group == 0 and disabled, "phone controls must be disabled in the primary input group")
|
||||
@@ -56,6 +71,25 @@ end
|
||||
|
||||
dofile("sky_phone/source/client/focus.lua")
|
||||
|
||||
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must be idle until its configured control is held")
|
||||
pressed_controls[19] = true
|
||||
assert(SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must read its configured active control")
|
||||
pressed_controls[19] = false
|
||||
Config.Phone.HoldToLook.Control = 38
|
||||
event_handlers["sky_phone:configurator:updated"]()
|
||||
disabled_pressed_controls[38] = true
|
||||
assert(
|
||||
SkyPhoneFocus.IsHoldToLookPressed(),
|
||||
"HoldToLook must read its configured control while NUI focus has disabled GTA input"
|
||||
)
|
||||
disabled_pressed_controls[38] = false
|
||||
Config.Phone.HoldToLook.Enabled = false
|
||||
event_handlers["sky_phone:configurator:updated"]()
|
||||
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "disabled HoldToLook must reject every control state")
|
||||
Config.Phone.HoldToLook.Enabled = true
|
||||
Config.Phone.HoldToLook.Control = 19
|
||||
event_handlers["sky_phone:configurator:updated"]()
|
||||
|
||||
local function resolve(overrides)
|
||||
local state = {
|
||||
activity_suspended = false,
|
||||
@@ -233,6 +267,9 @@ disabled_controls = {}
|
||||
firing_disabled = false
|
||||
SkyPhoneFocus.ApplyGameInputControls(false)
|
||||
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
|
||||
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
|
||||
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
|
||||
end
|
||||
assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
|
||||
|
||||
local movable_notification = resolve({ allow_movement = true, notification_focus = true })
|
||||
@@ -264,11 +301,28 @@ local focused_camera = resolve({
|
||||
assert(
|
||||
focused_camera.cursor
|
||||
and focused_camera.focused
|
||||
and not focused_camera.keep_input
|
||||
and focused_camera.keep_input
|
||||
and not focused_camera.game_input,
|
||||
"focused camera must override movement configuration until Space enables passthrough"
|
||||
"focused camera must forward readable input while blocking game actions until passthrough is held"
|
||||
)
|
||||
|
||||
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = true })
|
||||
local focused_camera_event = triggered_events[#triggered_events]
|
||||
assert(
|
||||
nui_focus.focused and nui_focus.cursor and nui_keep_input
|
||||
and focused_camera_event.name == "sky_phone:client:cameraFocusApplied"
|
||||
and not focused_camera_event.data.gameInput,
|
||||
"focused camera must keep controls readable without reporting movement passthrough"
|
||||
)
|
||||
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = false })
|
||||
local passthrough_camera_event = triggered_events[#triggered_events]
|
||||
assert(
|
||||
nui_focus.focused and not nui_focus.cursor and nui_keep_input
|
||||
and passthrough_camera_event.data.gameInput,
|
||||
"camera passthrough must apply keyboard focus without a cursor and keep GTA input enabled"
|
||||
)
|
||||
event_handlers["sky_phone:client:setCameraFocus"]({ active = false, nuiFocused = true })
|
||||
|
||||
local camera_interrupted_by_call = resolve({
|
||||
call_focus = true,
|
||||
camera_active = true,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
Config = {
|
||||
Bridge = {
|
||||
Framework = "qbox",
|
||||
},
|
||||
CommandPermissions = {
|
||||
phonepanel = { "god", "superadmin", "admin" },
|
||||
},
|
||||
}
|
||||
|
||||
Bridge = {
|
||||
Framework = {},
|
||||
}
|
||||
|
||||
function GetResourceState(resource)
|
||||
assert(resource == "qbx_core")
|
||||
return "started"
|
||||
end
|
||||
|
||||
function GetPlayers()
|
||||
return { "1" }
|
||||
end
|
||||
|
||||
local ace_permissions = {}
|
||||
local framework_permission = false
|
||||
|
||||
function IsPlayerAceAllowed(source, permission)
|
||||
assert(source == "1")
|
||||
return ace_permissions[permission] == true
|
||||
end
|
||||
|
||||
exports = {
|
||||
qbx_core = {
|
||||
GetPlayer = function(_, source)
|
||||
if tonumber(source) ~= 1 then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
PlayerData = {
|
||||
citizenid = "test-citizen",
|
||||
},
|
||||
}
|
||||
end,
|
||||
HasGroup = function(_, source, groups)
|
||||
assert(source == 1)
|
||||
assert(groups == Config.CommandPermissions.phonepanel)
|
||||
return framework_permission
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
dofile("sky_phone/source/bridge/server/framework.lua")
|
||||
dofile("sky_phone/source/bridge/server/frameworks/qbox.lua")
|
||||
|
||||
ace_permissions.admin = true
|
||||
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox ACE permissions must grant access")
|
||||
|
||||
ace_permissions.admin = false
|
||||
framework_permission = true
|
||||
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox framework groups must remain supported")
|
||||
|
||||
local success = pcall(Bridge.Framework.HasPermission, 1, "missing")
|
||||
assert(not success, "Missing fixed permission definitions must fail loudly")
|
||||
|
||||
print("framework permission tests passed")
|
||||
@@ -1,3 +1,9 @@
|
||||
local event_handlers = {}
|
||||
|
||||
AddEventHandler = function(event_name, callback)
|
||||
event_handlers[event_name] = callback
|
||||
end
|
||||
|
||||
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
|
||||
Config = {
|
||||
Bridge = {
|
||||
@@ -193,11 +199,18 @@ exports = {
|
||||
}
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
|
||||
|
||||
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function()
|
||||
end)
|
||||
assert(not ok)
|
||||
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
|
||||
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true))
|
||||
assert(Config.Phone.Unique == false)
|
||||
assert(Config.Sim.Enabled == false)
|
||||
assert(Bridge.Inventory.ConfigurationError == nil)
|
||||
|
||||
Config.Phone.Unique = true
|
||||
Config.Sim.Enabled = true
|
||||
event_handlers["sky_phone:configurator:serverUpdated"]()
|
||||
assert(Config.Phone.Unique == false)
|
||||
assert(Config.Sim.Enabled == false)
|
||||
|
||||
assert(Bridge.Inventory.RegisterUsableItem("phone", function()
|
||||
end))
|
||||
|
||||
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
|
||||
local manifest = manifest_file:read("*a")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
AddEventHandler = function()
|
||||
end
|
||||
|
||||
local function reset_bridge(inventory_name, resource_name, inventory_export, framework_name, extra_exports)
|
||||
Config = {
|
||||
Bridge = { Inventory = inventory_name },
|
||||
@@ -156,13 +159,16 @@ function one:GetSlot(_, slot)
|
||||
return one_items[slot]
|
||||
end
|
||||
|
||||
function one:SearchInventory()
|
||||
return one_items
|
||||
function one:GetSlotIdsWithItem(_, item_name, metadata)
|
||||
assert(item_name == "phone")
|
||||
if metadata and metadata.imei ~= one_items[7].metadata.imei then
|
||||
return {}
|
||||
end
|
||||
return { 7 }
|
||||
end
|
||||
|
||||
function one:SetItemMetadata(_, slot, metadata)
|
||||
one_items[slot].metadata = metadata
|
||||
return true
|
||||
end
|
||||
|
||||
function one:CanCarryItem()
|
||||
|
||||
@@ -25,7 +25,7 @@ Bridge = {
|
||||
Debug = function()
|
||||
end,
|
||||
Framework = {
|
||||
HasAdminGroup = function()
|
||||
HasPermission = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
@@ -42,7 +42,6 @@ Bridge = {
|
||||
|
||||
Config = {
|
||||
AdminPanel = {
|
||||
AdminGroups = { "admin" },
|
||||
Command = "phoneadmin",
|
||||
Enabled = true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user