mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4809931ec9 | |||
| 9efd92e6b6 | |||
| 829f8da82a | |||
| 7ca39ef4b9 | |||
| b673b15b97 | |||
| a12e43b624 | |||
| 312ae8a3a3 | |||
| 435320e8fd | |||
| ea959f59f2 |
@@ -1885,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,136 @@
|
||||
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('repairs persisted service-line messaging before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_unsupported_company_message_defaults()',
|
||||
),
|
||||
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_unsupported_company_message_defaults)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:unsupported-company-message-defaults:v1',
|
||||
)
|
||||
expect(migration).toContain('line.CanMessage == true')
|
||||
expect(migration).toContain('line.CanMessage = false')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -196,9 +196,7 @@ describe('standalone admin panel contracts', () => {
|
||||
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(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[]')
|
||||
@@ -499,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(
|
||||
|
||||
@@ -46,10 +46,16 @@ describe('fixed server permissions', () => {
|
||||
expect(configurator).toContain(`["${path}"] = true`)
|
||||
}
|
||||
expect(configurator).toContain(
|
||||
'Phone Configurator enabled: file-based settings from config.lua',
|
||||
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'(except Config.CommandPermissions) and media.lua are disabled',
|
||||
'^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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -286,19 +286,19 @@ 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).toContain(
|
||||
'if active_key_mapping_command == command_name then',
|
||||
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(
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
)
|
||||
|
||||
@@ -4523,8 +4523,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',
|
||||
|
||||
@@ -138,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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,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 () => {'),
|
||||
|
||||
@@ -3858,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ type ConfiguratorField = {
|
||||
}
|
||||
|
||||
type ConfiguratorStructure = {
|
||||
entryDefault?: unknown
|
||||
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||
fields?: Record<string, ConfiguratorStructure>
|
||||
items?: ConfiguratorStructure[]
|
||||
@@ -122,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' },
|
||||
@@ -135,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,
|
||||
@@ -183,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',
|
||||
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1181,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",
|
||||
@@ -1203,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",
|
||||
|
||||
@@ -222,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 = {
|
||||
@@ -1228,7 +1228,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" },
|
||||
@@ -1449,8 +1449,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",
|
||||
|
||||
@@ -222,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 = {
|
||||
@@ -1449,8 +1449,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}",
|
||||
|
||||
@@ -222,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 = {
|
||||
@@ -1449,8 +1449,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}",
|
||||
|
||||
@@ -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
|
||||
@@ -312,32 +310,30 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
||||
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()
|
||||
|
||||
@@ -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,51 @@ 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)))
|
||||
return nil, ("[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' enables messaging without a virtual service-line message router."):format(
|
||||
company_id
|
||||
)
|
||||
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 +357,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 +490,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 +892,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 +972,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
|
||||
|
||||
@@ -1839,7 +1909,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)
|
||||
@@ -2636,7 +2706,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
|
||||
|
||||
@@ -26,10 +26,16 @@ local FIXED_CONFIG_PATHS = {
|
||||
}
|
||||
|
||||
if configurator_enabled then
|
||||
print(
|
||||
"[sky_phone] Phone Configurator enabled: file-based settings from config.lua " ..
|
||||
"(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active."
|
||||
)
|
||||
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 = {
|
||||
@@ -557,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
|
||||
@@ -575,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,
|
||||
@@ -1069,6 +1145,217 @@ local function apply_stored_row(row)
|
||||
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_unsupported_company_message_defaults()
|
||||
local migration_name = "sky-phone:configurator:unsupported-company-message-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 definitions = config_payload.Companies
|
||||
and config_payload.Companies.Definitions
|
||||
local migrated_companies = {}
|
||||
if type(definitions) == "table" then
|
||||
for company_id, definition in pairs(definitions) do
|
||||
local line = type(definition) == "table" and definition.ServiceLine or nil
|
||||
if type(line) == "table" and line.CanMessage == true then
|
||||
line.CanMessage = false
|
||||
migrated_companies[#migrated_companies + 1] = tostring(company_id)
|
||||
end
|
||||
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(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({ companies = migrated_companies }),
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Could not migrate unsupported Phone Configurator service-line messaging defaults.")
|
||||
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] Disabled unsupported Phone Configurator service-line messaging for: %s",
|
||||
table.concat(migrated_companies, ", "),
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
|
||||
default_config = {}
|
||||
for key, value in pairs(ConfigDefaults) do
|
||||
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
|
||||
@@ -1089,6 +1376,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_unsupported_company_message_defaults)
|
||||
|
||||
function SkyPhoneConfigurator.GetAdminData()
|
||||
local data = build_admin_data()
|
||||
@@ -1138,6 +1428,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(([[
|
||||
|
||||
@@ -1166,7 +1166,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",
|
||||
@@ -1188,7 +1188,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",
|
||||
|
||||
+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,
|
||||
|
||||
Reference in New Issue
Block a user