Compare commits

..

4 Commits

Author SHA1 Message Date
Leon.Schmidt 1da9fee997 FIX - enforce configured app availability 2026-08-23 14:37:13 +02:00
Leon.Schmidt 49c5964f7e Merge remote-tracking branch 'origin/dev' into feat/prints-more
# Conflicts:
#	frontend/src/stores/admin.ts
2026-08-23 14:26:05 +02:00
Leon.Schmidt 3a0a9c0093 ENH - clarify Phone Configurator ownership 2026-08-23 14:23:19 +02:00
Leon.Schmidt 24ebe8c3ff ENH - add live admin dashboard statistics 2026-08-22 17:08:51 +02:00
57 changed files with 641 additions and 3224 deletions
-2
View File
@@ -1885,8 +1885,6 @@ 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,
}"
@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
PHONE_APPS,
isExternalPhoneApp,
isLaunchablePhoneApp,
} from '@/config/apps'
const repositoryRoot = join(import.meta.dirname, '../..')
const source = (path: string) =>
readFileSync(join(repositoryRoot, path), 'utf8')
const app = source('frontend/src/App.vue')
const appStore = source('frontend/src/stores/app-store.ts')
const appStoreView = source('frontend/src/views/apps/AppStoreApp.vue')
const appWindow = source('frontend/src/views/PhoneAppWindow.vue')
const client = source('sky_phone/source/client/main.lua')
const config = source('sky_phone/config/config.lua')
const configDefault = source('sky_phone/source/shared/config_default.lua')
const crypto = source('sky_phone/source/server/crypto.lua')
const phone = source('sky_phone/source/server/phone.lua')
const router = source('frontend/src/router/index.ts')
describe('configured app availability contract', () => {
it('keeps every built-in app in the file and runtime defaults', () => {
const builtInAppIds = PHONE_APPS.filter(
(app) =>
isLaunchablePhoneApp(app) && !app.adminOnly && !isExternalPhoneApp(app),
).map((app) => app.id)
for (const appId of builtInAppIds) {
const entry = appId.includes('-')
? `["${appId}"] = true`
: `${appId} = true`
expect(config).toContain(entry)
expect(configDefault).toContain(entry)
}
})
it('sends disabled apps and reapplies them to an open phone after live saves', () => {
expect(phone).toContain('disabledApps = SkyPhone.GetDisabledApps()')
expect(phone).toMatch(
/function SkyPhone\.GetDisabledApps\(\)[\s\S]*?enabled == false/,
)
expect(client).toMatch(
/configurator:updated[\s\S]*?apply_disabled_apps\(device_payload\)[\s\S]*?device:updated/,
)
expect(app).toContain(
'appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)',
)
})
it('removes disabled apps from every launch path without changing device installs', () => {
expect(appStore).toContain('disabledApps: [] as LaunchablePhoneAppId[]')
expect(appStore).toContain('if (!this.isAvailable(appId)) return false')
expect(appStoreView).toContain('appStore.isAvailable(app.id)')
expect(router).toContain('useAppStoreStore().isInstalled(to.params.appId)')
expect(appWindow).toContain('appStore.isInstalled(app.id)')
expect(app).toMatch(
/isPhoneAppId\(currentAppId\)[\s\S]*?!appStore\.isInstalled\(currentAppId\)[\s\S]*?router\.push\('\/'\)/,
)
})
it('rejects crypto operations after the app is disabled at runtime', () => {
expect(crypto).toMatch(
/local function require_phone\(source\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)/,
)
expect(crypto).toMatch(
/local function refresh_crypto_runtime\(\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)[\s\S]*?sessions = \{\}/,
)
})
})
-10
View File
@@ -460,7 +460,6 @@ button {
}
}
.phone-screen {
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
margin: auto;
@@ -471,12 +470,6 @@ 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;
@@ -3246,9 +3239,6 @@ button {
backface-visibility: hidden;
will-change: transform, border-radius, opacity;
}
.app-window--camera-landscape {
background: transparent;
}
.app-window--citywarn {
backface-visibility: visible;
filter: none;
@@ -1,147 +0,0 @@
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)',
)
})
})
@@ -1,133 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
/\r\n/g,
'\n',
)
}
function sourceBlock(
value: string,
startMarker: string,
endMarker: string,
): string {
const start = value.indexOf(startMarker)
const end = value.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return value.slice(start, end)
}
const companiesServer = source('../../sky_phone/source/server/companies.lua')
const messagesServer = source('../../sky_phone/source/server/messages.lua')
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
const configDefaults = source(
'../../sky_phone/source/shared/config_default.lua',
)
const messagesApp = source('./views/apps/MessagesApp.vue')
const phoneFallbacks = source('./stores/phone.ts')
const locales = ['en', 'de', 'es'].map((locale) =>
source(`../../sky_phone/config/locales/${locale}.lua`),
)
describe('Companies service-line message routing contract', () => {
it('enables the police service line and persists an indexed route channel', () => {
const police = sourceBlock(
configDefaults,
' police = {',
' ambulance = {',
)
const schema = sourceBlock(
databaseMigration,
' name = "sky_phone_company_requests",',
' name = "sky_phone_company_request_reads",',
)
expect(police).toContain('Number = "911"')
expect(police).toContain('CanMessage = true')
expect(schema).toContain(
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
)
expect(schema).toContain(
'name = "idx_sky_phone_company_requests_service_line"',
)
})
it('resolves short service numbers before the normal SIM-number path', () => {
const resolver = sourceBlock(
messagesServer,
'local function resolve_message_recipient(value)',
'\n\nlocal function shared_contact',
)
const send = sourceBlock(
messagesServer,
'Bridge.Callbacks.Register("sky_phone:messages:send"',
'\n\nend)',
)
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
expect(resolver).toContain('return service_line.number, service_line')
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
expect(send).toContain('service_line_text_only')
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
send.indexOf('SELECT s.`id`, s.`phone_number`'),
)
})
it('atomically mirrors inbound SMS into one active company request', () => {
const route = sourceBlock(
companiesServer,
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
)
expect(route).toContain('current_device(source, false)')
expect(route).toContain('service_line.canMessage')
expect(route).toContain('UPDATE `sky_phone_sims`')
expect(route).toContain("`channel` = 'service_line'")
expect(route).toContain('AND NOT EXISTS (')
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
expect(route).toContain('Bridge.Database.Transaction(statements)')
expect(route).toContain('emit_request_change(row, true, true, source)')
expect(route).toContain('"sky_phone:companies:notification"')
})
it('mirrors authorized company replies back to the customer SMS thread', () => {
const sendMessage = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
)
expect(sendMessage).toContain('access.row.channel == "service_line"')
expect(
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
).toBe(2)
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
expect(sendMessage).toContain("message.`sender_type` = 'company'")
expect(sendMessage).toContain('"sky_phone:messages:changed"')
expect(sendMessage).toContain('"sky_phone:messages:new"')
})
it('keeps service-line composition text-only in every shipped locale', () => {
expect(messagesApp).toContain(
"() => activeContact.value?.source === 'company'",
)
expect(messagesApp).toContain(
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
)
expect(messagesApp).toContain('v-if="!activeServiceLine"')
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
expect(messagesApp).toContain("'service_line_text_only'")
expect(phoneFallbacks).toContain('service_line_text_only:')
for (const locale of locales) {
expect(locale).toContain('service_line_text_only =')
}
})
})
@@ -1,139 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const companiesServer = readFileSync(
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const configuratorServer = readFileSync(
new URL(
'../../sky_phone/source/server/phone_configurator.lua',
import.meta.url,
),
'utf8',
).replace(/\r\n/g, '\n')
const englishLocale = readFileSync(
new URL('../../sky_phone/config/locales/en.lua', import.meta.url),
'utf8',
)
const germanLocale = readFileSync(
new URL('../../sky_phone/config/locales/de.lua', import.meta.url),
'utf8',
)
const spanishLocale = readFileSync(
new URL('../../sky_phone/config/locales/es.lua', import.meta.url),
'utf8',
)
describe('company configurator creation contract', () => {
it('publishes complete defaults for newly added company definitions', () => {
expect(configuratorServer).toContain(
'local function company_definition_entry_default(company_id, configuration)',
)
expect(configuratorServer).toContain(
'local function next_available_company_service_number(configuration)',
)
expect(configuratorServer).toContain(
'entryDefault = company_definition_entry_default()',
)
expect(configuratorServer).toContain('Routing = "round_robin"')
expect(configuratorServer).toContain('RequestsEnabled = true')
})
it('validates the candidate runtime config before encoding or writing SQL', () => {
const save = configuratorServer.slice(
configuratorServer.indexOf('function SkyPhoneConfigurator.Save('),
)
const validation = save.indexOf(
'SkyPhoneCompanies.ValidateConfiguration(candidate_config)',
)
const encoding = save.indexOf(
'local config_encoded = encode_payload(next_config, "config")',
)
const update = save.indexOf('UPDATE `%s`')
expect(validation).toBeGreaterThanOrEqual(0)
expect(validation).toBeLessThan(encoding)
expect(validation).toBeLessThan(update)
expect(save).toContain(
'return { success = false, error = "invalid_company_configuration" }',
)
})
it('validates into isolated registries before replacing the live company state', () => {
expect(companiesServer).toContain(
'function SkyPhoneCompanies.ValidateConfiguration(configuration)',
)
expect(companiesServer).toContain(
'local validated, validation_error = validate_configuration(configuration)',
)
expect(companiesServer).toContain('definitions = validated.definitions')
expect(companiesServer).toContain(
'definition_ids = validated.definition_ids',
)
})
it('localizes rejected company configurations in every shipped locale', () => {
for (const locale of [englishLocale, germanLocale, spanishLocale]) {
expect(locale).toContain('invalid_company_configuration =')
}
})
it('repairs company rows created by the legacy blank schema before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_blank_company_definitions()',
),
configuratorServer.indexOf(
'local function migrate_police_request_defaults()',
),
)
const blankRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)',
)
const policeRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
expect(migration).toContain(
'sky-phone:configurator:company-definition-defaults:v1',
)
expect(migration).toContain('company_definition_entry_default(')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(blankRegistration).toBeGreaterThanOrEqual(0)
expect(blankRegistration).toBeLessThan(policeRegistration)
})
it('enables persisted police service-line messaging before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_police_service_line_messaging()',
),
configuratorServer.indexOf('\n\ndefault_config = {}'),
)
const policeRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
const messageRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
)
expect(migration).toContain(
'sky-phone:configurator:service-line-messaging:v2',
)
expect(migration).toContain('line.CanMessage ~= true')
expect(migration).toContain('line.CanMessage = true')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(migration).toContain('apply_stored_row(read_stored_row())')
expect(migration).toContain('apply_runtime_configuration()')
expect(messageRegistration).toBeGreaterThan(policeRegistration)
expect(companiesServer).not.toContain(
'enables messaging without a virtual service-line message router',
)
})
})
@@ -10,10 +10,6 @@ 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 = {
@@ -346,6 +342,40 @@ 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
@@ -383,7 +413,7 @@ function addListRow(): void {
const value = rows.length
? blankLike(rows[0])
: listTemplate.value
? blankFromConfiguratorStructure(listTemplate.value)
? blankFromStructure(listTemplate.value)
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
@@ -446,21 +476,12 @@ function addTableField(): void {
const template = tableStructure.value?.mutableKeys
? tableStructure.value.template
: undefined
const structuredValue = tableStructure.value?.mutableKeys
? createMutableTableEntry(
tableStructure.value,
props.path,
key,
tableValue.value,
const value = template
? blankFromStructure(template)
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
: undefined
const value =
structuredValue !== undefined
? structuredValue
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
emit('update:modelValue', {
...tableValue.value,
[key]: value,
@@ -535,7 +556,7 @@ function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
const key = parsedNewMapKey.value
const value = mapTemplate.value
? blankFromConfiguratorStructure(mapTemplate.value)
? blankFromStructure(mapTemplate.value)
: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
@@ -196,7 +196,9 @@ 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[]')
@@ -497,8 +499,7 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure')
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
expect(configuratorValueEditor).toContain('function blankFromStructure(')
expect(source).toContain("'is-structured': field.type === 'json'")
expect(configuratorValueEditor).toContain('function isStructuredValue(')
expect(configuratorValueEditor).toContain(
@@ -26,10 +26,6 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const openedFolderAppBlocks =
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
const homeFolderIconBlocks =
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
describe('Home folder interaction contract', () => {
it('opens folders from edit mode and starts dragging only after movement', () => {
@@ -40,7 +36,7 @@ describe('Home folder interaction contract', () => {
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
})
it('keeps whole-folder dragging aligned at every phone scale', () => {
it('keeps folder app dragging aligned at every phone scale', () => {
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
expect(dragSource).toContain('viewportWidth: bounds.width')
expect(dragSource).not.toContain("getPropertyValue('zoom')")
@@ -51,60 +47,6 @@ describe('Home folder interaction contract', () => {
)
})
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
expect(homeFolderIconBlocks).toHaveLength(2)
for (const area of ['grid', 'dock'] as const) {
const block = homeFolderIconBlocks.find((candidate) =>
candidate.includes(`data-home-area="${area}"`),
)
expect(block).toBeDefined()
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
expect(block).toContain('@dragcancel="stopHomeDrag"')
expect(block).toContain('@dragend="finishHomeDrag"')
expect(block).toContain('@dragmove="moveHomeDrag"')
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
}
})
it('uses the external viewport visual for apps dragged from an opened folder', () => {
expect(openedFolderAppBlocks).toHaveLength(1)
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
expect(openedFolderAppBlocks[0]).toContain(
':external-drag-visual="externalDragVisual"',
)
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain('app-icon-item--drag-source')
})
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
expect(overlaySource).toMatch(
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
)
expect(overlaySource).toContain("emit('dragstart', index, event)")
expect(overlaySource).toContain("emit('dragmove', event)")
expect(overlaySource).toContain("emit('dragcancel')")
expect(openedFolderAppBlocks[0]).toContain(
'@dragstart="startFolderAppDrag(entry.index, $event)"',
)
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
expect(openedFolderAppBlocks[0]).toContain(
'@dragcancel="stopFolderAppDrag"',
)
})
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
expect(overlaySource).toContain('readPhoneViewportGeometry')
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
expect(overlaySource).toContain('folderDragViewportRect(panel)')
expect(overlaySource).toContain('folderDragViewportRect(element)')
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
expect(overlaySource).not.toContain(
'panelElement.value?.getBoundingClientRect()',
)
expect(overlaySource).not.toContain('document.elementsFromPoint')
})
it('edits the folder name inline with Sky UI controls', () => {
expect(overlaySource).toContain('<SkyField')
expect(overlaySource).toContain('home-folder-heading--editing')
@@ -121,9 +63,8 @@ describe('Home folder interaction contract', () => {
expect(springboardSource).toContain(
'@drag-outside-change="folderDraggingOutside = $event"',
)
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
expect(overlaySource).toContain(
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
'if (draggingIndex.value === null || draggingOutside.value) return',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
+26 -92
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Check, Pencil, X } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { usePhoneStore } from '@/stores/phone'
@@ -11,35 +11,20 @@ import {
HOME_FOLDER_PAGE_SIZE,
type HomeFolder,
} from '@/utils/homeLayout'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type FolderAppEntry = {
app: PhoneAppDefinition
index: number
}
const props = withDefaults(
defineProps<{
apps: FolderAppEntry[]
editMode: boolean
externalDragVisual?: boolean
folder: HomeFolder
renameOnOpen: boolean
}>(),
{
externalDragVisual: false,
},
)
const props = defineProps<{
apps: FolderAppEntry[]
editMode: boolean
folder: HomeFolder
renameOnOpen: boolean
}>()
const emit = defineEmits<{
close: []
dragcancel: []
dragmove: [event: PointerEvent]
dragstart: [index: number, event: PointerEvent]
'drag-outside-change': [outside: boolean]
edit: []
extract: [index: number, event: PointerEvent]
@@ -58,7 +43,6 @@ const renameOpened = ref(false)
const renameDraft = ref('')
let pagePointerStart = 0
let pagePointerId: number | null = null
let dragGeometry: PhoneViewportGeometry | null = null
const folderName = computed(
() => props.folder.name || phone.t('Home.folders.defaultName'),
@@ -129,48 +113,25 @@ function saveRename(): void {
renameOpened.value = false
}
function startFolderAppDrag(index: number, event: PointerEvent): void {
function startFolderAppDrag(index: number): void {
draggingIndex.value = index
dragGeometry = readPhoneViewportGeometry(panelElement.value)
setDraggingOutside(false)
emit('dragstart', index, event)
}
function fallbackViewportRect(element: Element): PhoneViewportRect {
const bounds = element.getBoundingClientRect()
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
}
}
function folderDragViewportRect(element: Element): PhoneViewportRect {
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
return geometry?.rect(element) ?? fallbackViewportRect(element)
}
function isPointerInsidePanel(event: PointerEvent): boolean {
const panel = panelElement.value
const panelBounds = panelElement.value?.getBoundingClientRect()
if (!panelBounds) return false
return (
panel !== null &&
phoneViewportRectContainsPoint(
folderDragViewportRect(panel),
event.clientX,
event.clientY,
)
event.clientX >= panelBounds.left &&
event.clientX <= panelBounds.right &&
event.clientY >= panelBounds.top &&
event.clientY <= panelBounds.bottom
)
}
function moveFolderAppDrag(event: PointerEvent): void {
if (draggingIndex.value === null) return
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
setDraggingOutside(true)
}
emit('dragmove', event)
if (draggingIndex.value === null || draggingOutside.value) return
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
}
function setDraggingOutside(outside: boolean): void {
@@ -184,52 +145,31 @@ function finishFolderAppDrag(event: PointerEvent): void {
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
draggingIndex.value = null
if (sourceIndex === null) {
dragGeometry = null
setDraggingOutside(false)
emit('dragcancel')
return
}
if (shouldExtract) {
emit('extract', sourceIndex, event)
dragGeometry = null
setDraggingOutside(false)
return
}
setDraggingOutside(false)
const target = Array.from(
panelElement.value?.querySelectorAll<HTMLElement>(
'[data-folder-app-index]',
) ?? [],
).find(
(element) =>
Number(element.dataset.folderAppIndex) !== sourceIndex &&
phoneViewportRectContainsPoint(
folderDragViewportRect(element),
event.clientX,
event.clientY,
),
)
if (!target) {
dragGeometry = null
emit('dragcancel')
return
}
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
.find(
(element) =>
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
)
if (!target) return
const targetIndex = Number(target.dataset.folderAppIndex)
dragGeometry = null
if (!Number.isInteger(targetIndex)) {
emit('dragcancel')
return
}
emit('move', sourceIndex, targetIndex)
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
}
function stopFolderAppDrag(): void {
const wasDragging = draggingIndex.value !== null
draggingIndex.value = null
dragGeometry = null
setDraggingOutside(false)
if (wasDragging) emit('dragcancel')
}
function goToPage(page: number): void {
@@ -255,11 +195,6 @@ function finishPageSwipe(event: PointerEvent): void {
}
pagePointerId = null
}
onBeforeUnmount(() => {
stopFolderAppDrag()
pagePointerId = null
})
</script>
<template>
@@ -355,11 +290,10 @@ onBeforeUnmount(() => {
:app="entry.app"
:data-folder-app-index="entry.index"
:edit-mode="editMode"
:external-drag-visual="externalDragVisual"
@dragcancel="stopFolderAppDrag"
@dragend="finishFolderAppDrag"
@dragmove="moveFolderAppDrag"
@dragstart="startFolderAppDrag(entry.index, $event)"
@dragstart="startFolderAppDrag(entry.index)"
@edit="emit('edit')"
/>
</div>
+6 -6
View File
@@ -286,19 +286,19 @@ describe('phone inventory contracts', () => {
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
})
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
it('opens from a configurable F1 mapping without client-provided device identity', () => {
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).toMatch(
/RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
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).not.toContain('sky_phone_toggle_config_')
expect(phoneClient).not.toContain('key_mapping_revision')
expect(phoneClient).toContain(
'request_phone_open("sky_phone:device:open-request")',
)
+4 -1
View File
@@ -5,6 +5,7 @@ import {
} from 'vue-router'
import { isPhoneAppId } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import PhoneAppWindow from '@/views/PhoneAppWindow.vue'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -34,7 +35,9 @@ export default createRouter({
},
{
beforeEnter: (to) =>
typeof to.params.appId === 'string' && isPhoneAppId(to.params.appId)
typeof to.params.appId === 'string' &&
isPhoneAppId(to.params.appId) &&
useAppStoreStore().isInstalled(to.params.appId)
? true
: '/',
component: PhoneAppWindow,
-1
View File
@@ -27,7 +27,6 @@ const EMPTY_STATS: AdminStats = {
online: 0,
simDevices: 0,
}
function disabledAppsFromConfigurator(
configurator: AdminConfigurator,
): string[] {
+25 -35
View File
@@ -276,6 +276,31 @@ describe('app store', () => {
expect(apps.isInstalled('phone')).toBe(true)
})
it('temporarily blocks server-disabled apps without changing their installation', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['crypto'] }, [
'crypto',
'citywarn',
'not-an-app',
])
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.disabledApps).toEqual(['crypto', 'citywarn'])
expect(apps.isAvailable('crypto')).toBe(false)
expect(apps.isInstalled('crypto')).toBe(false)
expect(apps.isInstalled('citywarn')).toBe(false)
expect(apps.claimedApps).toEqual(['crypto'])
apps.installApp('crypto')
expect(apps.installingApps).toEqual({})
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
apps.hydrate({ claimedApps: ['crypto'] })
expect(apps.isAvailable('crypto')).toBe(true)
expect(apps.isInstalled('crypto')).toBe(true)
expect(apps.isInstalled('citywarn')).toBe(true)
})
it('protects every default app from full uninstallation', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
@@ -474,41 +499,6 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
})
it('materializes a trailing page only for a successful folder extraction', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
settingsIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
const originalPageCount = apps.homeLayout.pageCount
const targetPage = originalPageCount + 1
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
expect(
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
).toBe(false)
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
true,
)
expect(apps.homeLayout.pageCount).toBe(targetPage)
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('does not commit an installation to a different phone', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
+2 -12
View File
@@ -19,7 +19,6 @@ import {
createDefaultHomeLayout,
deleteHomePage,
extractHomeFolderApp,
HOME_GRID_PAGE_SIZE,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
@@ -520,23 +519,14 @@ export const useAppStoreStore = defineStore('app-store', {
to: HomeArea,
targetIndex: number,
): boolean {
let sourceLayout = this.homeLayout
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
while (sourceLayout.pageCount < targetPage) {
const expanded = addHomePage(sourceLayout)
if (expanded === sourceLayout) return false
sourceLayout = expanded
}
}
const next = extractHomeFolderApp(
sourceLayout,
this.homeLayout,
folderId,
sourceIndex,
to,
targetIndex,
)
if (next === sourceLayout) return false
if (next === this.homeLayout) return false
this.homeLayout = next
this.persist()
return true
+2 -4
View File
@@ -2712,8 +2712,6 @@ const defaultLocales: LocaleTree = {
recipient_not_found: 'That number is unavailable.',
blocked: 'This contact has blocked calls and messages from your SIM.',
messaging_unavailable: 'This company contact does not accept messages.',
service_line_text_only:
'Company service lines currently accept text messages only.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
request_failed: 'Messages are temporarily unavailable.',
@@ -4525,8 +4523,8 @@ const defaultLocales: LocaleTree = {
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Hold the look key to look around',
returnHelp: 'Release the look key for controls',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
uploading: '{count} uploading',
saving: 'Saving video...',
openGallery: 'Open Photos',
-1
View File
@@ -138,7 +138,6 @@ export type AdminConfiguratorStructure =
template?: AdminConfiguratorStructure
}
| {
entryDefault?: unknown
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
mutableKeys?: boolean
-11
View File
@@ -43,17 +43,6 @@ 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, {
-1
View File
@@ -168,7 +168,6 @@
.sky-navbar__title {
min-width: 0;
max-width: 100%;
margin: 0;
padding: 0 var(--sky-space-1);
overflow: hidden;
@@ -1,114 +0,0 @@
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,
)
})
})
@@ -1,142 +0,0 @@
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
}
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
@@ -64,78 +63,7 @@ function createGeometryFixture(options: {
}
}
const PHONE_BASE_ZOOM = 0.69 * 1.2
const PHONE_HEIGHT = 844
const PHONE_WIDTH = 390
const REFERENCE_VIEWPORT_HEIGHT = 1080
const REFERENCE_VIEWPORT_WIDTH = 1920
const displayCases = [
{ height: 1080, label: '1080p 16:9', width: 1920 },
{ height: 2160, label: '4K 16:9', width: 3840 },
{ height: 1440, label: '21:9', width: 3440 },
{ height: 1440, label: '32:9', width: 5120 },
{ height: 1600, label: '16:10', width: 2560 },
] as const
const phoneScales = [80, 100, 120] as const
function productionPhoneZoom(
viewportWidth: number,
viewportHeight: number,
phoneScale: number,
): number {
const viewportScale = Math.min(
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
)
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
const edgeGap = 24 * viewportScale
const viewportMaximum = Math.max(
0,
Math.min(
(viewportWidth - edgeGap) / PHONE_WIDTH,
(viewportHeight - edgeGap) / PHONE_HEIGHT,
),
)
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
}
const cefDisplayMatrix = displayCases.flatMap((display) =>
phoneScales.map((phoneScale) => ({ display, phoneScale })),
)
describe('phone viewport geometry', () => {
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
const rect: PhoneViewportRect = {
bottom: 260,
height: 160,
left: 120,
right: 360,
top: 100,
width: 240,
}
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
true,
)
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
false,
)
})
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
@@ -196,76 +124,6 @@ describe('phone viewport geometry', () => {
},
)
it.each(cefDisplayMatrix)(
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
({ display, phoneScale }) => {
const viewportScale = Math.min(
display.width / REFERENCE_VIEWPORT_WIDTH,
display.height / REFERENCE_VIEWPORT_HEIGHT,
)
const zoom = productionPhoneZoom(
display.width,
display.height,
phoneScale,
)
const edgeGap = 24 * viewportScale
const wrapper = measuredRect(
display.width - edgeGap - PHONE_WIDTH * zoom,
display.height - edgeGap - PHONE_HEIGHT * zoom,
PHONE_WIDTH * zoom,
PHONE_HEIGHT * zoom,
)
// Live CEF 103 can report the zoomed canvas in a different coordinate
// space from pointer events. Preserve that mismatch in this fixture.
const rawCanvas = measuredRect(
display.width + 117.375,
83.625,
PHONE_WIDTH,
PHONE_HEIGHT,
)
const rawPanel = measuredRect(
rawCanvas.left + 23.25,
rawCanvas.top + 246.75,
343.5,
324.25,
)
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
const expected = {
bottom: wrapper.top + (246.75 + 324.25) * zoom,
height: 324.25 * zoom,
left: wrapper.left + 23.25 * zoom,
right: wrapper.left + (23.25 + 343.5) * zoom,
top: wrapper.top + 246.75 * zoom,
width: 343.5 * zoom,
}
expectRectClose(panel, expected)
expect(
phoneViewportRectContainsPoint(
panel,
panel.left + panel.width / 2,
panel.top + panel.height / 2,
),
).toBe(true)
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
true,
)
expect(
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
).toBe(true)
expect(
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
).toBe(false)
expect(
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
).toBe(false)
expect(panel.left).toBeGreaterThanOrEqual(0)
expect(panel.right).toBeLessThanOrEqual(display.width)
expect(panel.top).toBeGreaterThanOrEqual(0)
expect(panel.bottom).toBeLessThanOrEqual(display.height)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
@@ -7,14 +7,6 @@ export type PhoneViewportRect = {
width: number
}
export function phoneViewportRectContainsPoint(
rect: PhoneViewportRect,
x: number,
y: number,
): boolean {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
+4 -6
View File
@@ -4,11 +4,13 @@ import { useRoute } from 'vue-router'
import CustomAppFrame from '@/components/CustomAppFrame.vue'
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import { getCustomAppFrameKey } from '@/utils/customAppLifecycle'
import AppStoreApp from '@/views/apps/AppStoreApp.vue'
const route = useRoute()
const appStore = useAppStoreStore()
const phone = usePhoneStore()
const app = computed(() => getPhoneApp(route.params.appId))
const builtinAppComponent = computed(() =>
@@ -28,13 +30,9 @@ const launchStyle = computed(() => {
<template>
<div
v-if="app && !app.adminOnly"
v-if="app && !app.adminOnly && appStore.isInstalled(app.id)"
class="app-window"
:class="{
'app-window--camera-landscape':
app.id === 'camera' && phone.cameraLandscape,
'app-window--citywarn': app.id === 'citywarn',
}"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
>
<CustomAppFrame
@@ -31,22 +31,6 @@ const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
const folderOverlayBlock =
viewSource.match(/<HomeFolderOverlay\b[\s\S]*?\/>/)?.[0] ?? ''
function handlerName(block: string, event: string): string {
return (
block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
)
}
function functionSource(name: string): string {
if (!name) return ''
const start = viewSource.indexOf(`function ${name}(`)
if (start < 0) return ''
const next = viewSource.indexOf('\nfunction ', start + 1)
return viewSource.slice(start, next < 0 ? viewSource.length : next)
}
describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
@@ -152,7 +136,7 @@ describe('Springboard page swipe contract', () => {
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(5)
).toHaveLength(4)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
@@ -176,65 +160,6 @@ describe('Springboard page swipe contract', () => {
)
})
it('owns one external drag session for an app leaving an opened folder', () => {
expect(folderOverlayBlock).toContain(
':external-drag-visual="homeDragVisualActive"',
)
const startSource = functionSource(
handlerName(folderOverlayBlock, 'dragstart'),
)
const moveSource = functionSource(
handlerName(folderOverlayBlock, 'dragmove'),
)
const cancelSource = functionSource(
handlerName(folderOverlayBlock, 'dragcancel'),
)
expect(startSource).toContain('createHomeDragGhost(event)')
expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
expect(moveSource).toContain('updateHomeDragGhost(event)')
expect(moveSource).toContain('folderDraggingOutside.value')
expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
expect(viewSource).toContain(
'(draggingHomeApp.value || draggingFolderApp.value)',
)
expect(cancelSource).toContain('clearHomeDragGhost()')
expect(cancelSource).toContain('clearTemporaryHomePage()')
})
it('settles an extracted folder app from the tracked viewport preview', () => {
const extractSource = functionSource('extractOpenedFolderApp')
const targetSource = functionSource('folderExtractionDropTarget')
expect(extractSource).toContain('const dropGhost = homeDragGhost')
expect(extractSource).toContain('homeDragPreviewBounds')
expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
expect(extractSource).not.toContain('sourceElement')
expect(extractSource).not.toContain('getBoundingClientRect()')
expect(extractSource).toMatch(
/animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
)
expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
expect(extractSource).toMatch(
/if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
const moveSource = functionSource('moveOpenedFolderApp')
const cancelSource = functionSource(
handlerName(folderOverlayBlock, 'dragcancel'),
)
expect(moveSource).toContain('stopOpenedFolderAppDrag()')
expect(cancelSource).toContain('clearHomeDragGhost()')
expect(viewSource).toMatch(
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
+58 -236
View File
@@ -42,7 +42,6 @@ import {
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
@@ -112,11 +111,6 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const draggingFolderApp = ref<{
appId: LaunchablePhoneAppId
folderId: string
sourceIndex: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
@@ -616,11 +610,7 @@ function queueEdgePageTurn(
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = dragType === 'widget'
if (
dragType === 'app' &&
lastHomePointer &&
(draggingHomeApp.value || draggingFolderApp.value)
) {
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
@@ -824,7 +814,7 @@ function updateHomeDragGhost(event: {
)
}
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
@@ -837,7 +827,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
const geometry = readPhoneViewportGeometry(source ?? null)
if (!source || !layer || !springboard || !portal || !geometry) return null
if (!source || !layer || !springboard || !portal || !geometry) return
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
@@ -848,7 +838,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return null
return
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
@@ -875,12 +865,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (
attribute.startsWith('data-home-') ||
attribute.startsWith('data-folder-')
) {
element.removeAttribute(attribute)
}
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
@@ -909,7 +894,6 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
return position
}
function startHomeDrag(
@@ -1206,98 +1190,6 @@ function nearestDockDropTarget(event: {
return closestIndex === null ? null : slots[closestIndex]
}
function folderExtractionDropTarget(event: {
clientX: number
clientY: number
}):
| { area: 'dock'; index: number }
| { area: 'grid'; index: number; page: number }
| null {
const geometry = homeDragGeometry
if (!geometry) return null
const dock = document.querySelector<HTMLElement>('.app-dock')
if (dock) {
const dockBounds = homeDragViewportRect(dock, geometry)
if (
phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
) {
const slots = Array.from(
dock.querySelectorAll<HTMLElement>(
'[data-home-area="dock"][data-home-index]',
),
).filter((slot) => {
const index = Number(slot.dataset.homeIndex)
return (
Number.isInteger(index) && appStore.homeLayout.dock[index] === null
)
})
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => homeDragViewportRect(slot, geometry)),
)
if (closestIndex === null) return null
const index = Number(slots[closestIndex]?.dataset.homeIndex)
return Number.isInteger(index) ? { area: 'dock', index } : null
}
}
const page = phone.currentPage
if (page < 1 || page > appPages.value.length) return null
const grid = document.querySelector<HTMLElement>(
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
const rawGridBounds = homeDragViewportRect(grid, geometry)
const gridBounds = viewportRectAt(
springboardBounds.left + (rawGridBounds.left - pageBounds.left),
springboardBounds.top + (rawGridBounds.top - pageBounds.top),
rawGridBounds.width,
rawGridBounds.height,
)
if (
!phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
) {
return null
}
const slots = Array.from(
grid.querySelectorAll<HTMLElement>(
'[data-home-area="grid"][data-home-index]',
),
).filter((slot) => {
const index = Number(slot.dataset.homeIndex)
const item = appStore.homeLayout.grid[index]
return (
Number.isInteger(index) &&
(item === null ||
(item === undefined && page > appStore.homeLayout.pageCount))
)
})
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => {
const bounds = homeDragViewportRect(slot, geometry)
return {
height: bounds.height,
left: springboardBounds.left + (bounds.left - pageBounds.left),
top: springboardBounds.top + (bounds.top - pageBounds.top),
width: bounds.width,
}
}),
)
if (closestIndex === null) return null
const index = Number(slots[closestIndex]?.dataset.homeIndex)
return Number.isInteger(index) ? { area: 'grid', index, page } : null
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
@@ -1432,83 +1324,16 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
return apps
}
function currentOpenedFolderDragSession(sourceIndex?: number) {
const session = draggingFolderApp.value
const folder = openedFolder.value
if (
!session ||
!folder ||
folder.id !== session.folderId ||
(sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
folder.apps[session.sourceIndex] !== session.appId
) {
return null
}
return session
}
function startOpenedFolderAppDrag(
sourceIndex: number,
event: PointerEvent,
): void {
stopOpenedFolderAppDrag()
const folder = openedFolder.value
if (
!folder ||
!Number.isInteger(sourceIndex) ||
sourceIndex < 0 ||
sourceIndex >= folder.apps.length
) {
return
}
const appId = folder.apps[sourceIndex]
if (!appId || !installedAppsById.value.has(appId)) return
if (!createHomeDragGhost(event)) {
clearHomeDragGhost()
return
}
draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
}
function moveOpenedFolderAppDrag(event: PointerEvent): void {
if (!currentOpenedFolderDragSession()) {
stopOpenedFolderAppDrag()
return
}
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
queueEdgePageTurn(event, 'app')
return
}
clearEdgePageTurn()
}
function stopOpenedFolderAppDrag(): void {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
function resetOpenedFolderState(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
function openFolder(folderId: string): void {
stopOpenedFolderAppDrag()
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
stopOpenedFolderAppDrag()
resetOpenedFolderState()
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
function renameOpenedFolder(name: string): void {
@@ -1517,68 +1342,71 @@ function renameOpenedFolder(name: string): void {
}
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
const session = currentOpenedFolderDragSession(sourceIndex)
stopOpenedFolderAppDrag()
if (!session) return
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
if (!openedFolderId.value) return
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
}
function extractOpenedFolderApp(
sourceIndex: number,
event: PointerEvent,
): void {
const session = currentOpenedFolderDragSession(sourceIndex)
if (!session) {
closeFolder()
return
}
updateHomeDragGhost(event)
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
if (!dropGhost || !dropOrigin) {
closeFolder()
return
}
const target = folderExtractionDropTarget(event)
const folder = openedFolder.value
if (!folder) return
const appId = folder.apps[sourceIndex]
const sourceElement = document
.querySelector<HTMLElement>(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
const area = element.dataset.homeArea as HomeArea
const index = Number(element.dataset.homeIndex)
return (
(area === 'grid' || area === 'dock') &&
Number.isInteger(index) &&
appStore.homeLayout[area][index] === null
)
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
)
const closestDistance = Math.hypot(
event.clientX - (closestBounds.left + closestBounds.width / 2),
event.clientY - (closestBounds.top + closestBounds.height / 2),
)
return distance < closestDistance ? candidate : closest
}, null)
if (!target) {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
resetOpenedFolderState()
closeFolder()
return
}
const { area, index: targetIndex } = target
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
if (
!appStore.extractHomeFolderApp(
session.folderId,
session.sourceIndex,
area,
targetIndex,
)
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
) {
closeFolder()
return
}
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearTemporaryHomePage(area === 'grid')
resetOpenedFolderState()
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
if (extractedIndex === null) {
clearHomeDragGhost(dropGhost)
return
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
}
}
void animateHomeItemDrop(
session.appId,
area,
extractedIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
}
function clearSearch(): void {
@@ -1598,12 +1426,11 @@ watch(isEditablePage, (visible) => {
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopOpenedFolderAppDrag()
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
if (!folder && openedFolderId.value !== null) closeFolder()
if (!folder) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
@@ -1618,7 +1445,6 @@ onBeforeUnmount(() => {
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingFolderApp.value = null
draggingWidgetId.value = null
})
</script>
@@ -2032,13 +1858,9 @@ onBeforeUnmount(() => {
v-if="openedFolder"
:apps="openedFolderApps"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
@dragcancel="stopOpenedFolderAppDrag"
@dragmove="moveOpenedFolderAppDrag"
@dragstart="startOpenedFolderAppDrag"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"
@@ -6,15 +6,6 @@ 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',
@@ -56,99 +47,6 @@ 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"')
@@ -179,8 +77,7 @@ 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.lookKey')
expect(cameraView).not.toContain('Apps.camera.spaceKey')
expect(cameraView).toContain('Apps.camera.spaceKey')
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
expect(cameraClient).toContain('INPUT_LOOK_LR')
expect(cameraClient).toContain('INPUT_LOOK_UD')
@@ -198,26 +95,10 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
})
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()',
it('keeps the selfie camera stable while Space still allows movement', () => {
expect(cameraView).toMatch(
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/,
)
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',
)
@@ -225,22 +106,14 @@ 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('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(
'local dot = (to_camera.x * forward_vector.x)',
)
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(')
@@ -248,6 +121,10 @@ 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', () => {
+66 -68
View File
@@ -56,6 +56,7 @@ 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)
@@ -77,16 +78,6 @@ 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()}`
}
@@ -247,6 +238,10 @@ 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 })
}
@@ -345,6 +340,26 @@ 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 {
@@ -427,6 +442,8 @@ 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) => {
@@ -442,7 +459,13 @@ 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()
@@ -463,7 +486,6 @@ 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
@@ -537,7 +559,10 @@ onBeforeUnmount(() => {
</template>
</sky-fab>
</div>
<span v-if="noticeText" class="camera-notice-text">
<span
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
>
{{ noticeText }}
</span>
<span v-else-if="pendingCount" class="camera-upload-pill">
@@ -562,7 +587,7 @@ onBeforeUnmount(() => {
>
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</SkyButton>
<sky-fab
component="button"
@@ -593,16 +618,16 @@ onBeforeUnmount(() => {
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
variant="plain"
class="camera-zoom-pill"
:class="{ active: zoomPresetIsActive(zoom) }"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="zoomPresetIsActive(zoom)"
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
@click="setZoom(zoom)"
>
{{ zoomPresetLabel(zoom) }}
{{ zoom }}x
</SkyButton>
</div>
</div>
@@ -693,15 +718,10 @@ onBeforeUnmount(() => {
<style scoped>
.camera-page {
--sky-bg: #000;
--sky-text: #fff;
position: relative;
overflow: clip;
background: var(--sky-bg);
color: var(--sky-text);
}
.camera-page--landscape {
--sky-bg: rgba(0, 0, 0, 0.42);
background: #000;
color: #fff;
}
.camera-viewport {
position: absolute;
@@ -713,11 +733,11 @@ onBeforeUnmount(() => {
transform: translateY(-50%);
}
.camera-page--landscape .camera-viewport {
top: 50%;
top: 46%;
left: 50%;
width: calc(100% * var(--phone-screen-portrait-ratio));
width: calc(100% * 16 / 9);
height: auto;
aspect-ratio: var(--phone-screen-portrait-ratio);
aspect-ratio: 16 / 9;
transform: translate(-50%, -50%) rotate(90deg);
}
.camera-game-view,
@@ -775,16 +795,6 @@ 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;
@@ -845,22 +855,25 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-upload-pill {
padding: 7px 10px;
border-radius: 999px;
background: #0006;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-notice-text,
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
color: #ffd60a;
padding: 7px 10px;
overflow: hidden;
border-radius: 999px;
background: #0006;
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;
@@ -911,7 +924,7 @@ onBeforeUnmount(() => {
.camera-zoom-control {
position: absolute;
z-index: 4;
bottom: 216px;
bottom: 196px;
left: 50%;
width: auto;
transform: translateX(-50%);
@@ -919,19 +932,16 @@ onBeforeUnmount(() => {
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 10px;
gap: 8px;
}
.camera-zoom-pill {
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 0;
color: #fff;
background: transparent;
font-size: 12px;
font-weight: 500;
font-size: 10px;
text-align: center;
transition:
background-color 0.2s ease,
@@ -939,20 +949,8 @@ 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,10 +40,6 @@ 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,
)
@@ -60,51 +56,6 @@ 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)')
+17 -34
View File
@@ -1026,9 +1026,7 @@ onMounted(async () => {
height: 18px;
}
.citywarn-scroll {
min-height: 0;
height: auto;
flex: 1 1 0;
height: 100%;
padding: 12px 13px calc(28px + env(safe-area-inset-bottom));
overflow-y: auto;
}
@@ -1047,11 +1045,11 @@ onMounted(async () => {
}
.citywarn-overview {
display: flex;
padding: var(--sky-space-3);
padding: 15px;
align-items: center;
gap: var(--sky-space-3);
gap: 12px;
border: 1px solid #bbf7d0;
border-radius: var(--sky-radius-card);
border-radius: 18px;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
}
.citywarn-overview--active {
@@ -1064,7 +1062,7 @@ onMounted(async () => {
height: 46px;
flex: 0 0 auto;
place-items: center;
border-radius: var(--sky-radius-control);
border-radius: 15px;
color: #15803d;
background: #dcfce7;
}
@@ -1088,17 +1086,12 @@ 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: var(--sky-space-3) var(--sky-space-3) var(--sky-space-3)
var(--sky-space-4);
padding: 13px 13px 12px 17px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: var(--sky-radius-card);
border-radius: 17px;
background: #fff;
box-shadow: 0 4px 15px rgb(15 23 42 / 5%);
cursor: pointer;
@@ -1122,7 +1115,7 @@ onMounted(async () => {
width: 32px;
height: 32px;
place-items: center;
border-radius: var(--sky-radius-control);
border-radius: 10px;
color: var(--severity);
background: color-mix(in srgb, var(--severity) 12%, white);
}
@@ -1146,7 +1139,7 @@ onMounted(async () => {
color: #9ca3af;
}
.citywarn-alert-card h3 {
margin: var(--sky-space-2) 0 var(--sky-space-1);
margin: 10px 0 5px;
font-size: 16px;
line-height: 1.18;
}
@@ -1162,10 +1155,10 @@ onMounted(async () => {
}
.citywarn-alert-card footer {
display: flex;
margin-top: var(--sky-space-2);
margin-top: 11px;
align-items: center;
justify-content: space-between;
gap: var(--sky-space-2);
gap: 8px;
color: #6b7280;
font-size: 10.5px;
}
@@ -1179,13 +1172,12 @@ onMounted(async () => {
white-space: nowrap;
}
.citywarn-publisher-card {
margin: var(--sky-space-3) 0 0;
border-radius: var(--sky-radius-card);
margin-top: 12px;
}
.citywarn-publisher-card :deep(.sky-card__content) {
display: flex;
padding: var(--sky-space-3);
gap: var(--sky-space-3);
padding: 12px;
gap: 10px;
}
.citywarn-publisher-icon {
display: grid;
@@ -1193,7 +1185,7 @@ onMounted(async () => {
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: var(--sky-radius-control);
border-radius: 11px;
color: #1d4ed8;
background: #dbeafe;
}
@@ -1531,20 +1523,11 @@ onMounted(async () => {
.citywarn-settings {
display: flex;
flex-direction: column;
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;
gap: 15px;
}
.citywarn-settings-hint {
display: flex;
margin: 0 var(--sky-space-1);
margin: 0 8px;
align-items: flex-start;
gap: 7px;
color: #6b7280;
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
':opened="activeCanMessage && attachmentPicker !== null"',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
+12 -57
View File
@@ -215,9 +215,6 @@ const activeContact = computed(() =>
const activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const activeServiceLine = computed(
() => activeContact.value?.source === 'company',
)
const activeContactEmail = computed(() =>
normalizeMailAddress(activeContact.value?.email ?? ''),
)
@@ -258,15 +255,12 @@ const inboxMenuItems = computed(() => [
},
])
const attachmentPanelOpen = computed(
() =>
emojiOpen.value ||
(!activeServiceLine.value && attachmentPicker.value !== null),
() => emojiOpen.value || attachmentPicker.value !== null,
)
const composerHasContent = computed(
() =>
Boolean(draft.value.trim()) ||
(!activeServiceLine.value &&
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
Boolean(draft.value.trim() || shareDraft.value) ||
pendingAttachments.value.length > 0,
)
function contactName(number: string): string {
return (
@@ -423,8 +417,6 @@ function errorText(error?: string): string {
'gif_provider_failed',
'self_message',
'recipient_not_found',
'messaging_unavailable',
'service_line_text_only',
'no_sim',
'rate_limited',
'blocked',
@@ -622,7 +614,6 @@ async function callActiveContact(): Promise<void> {
}
function toggleAttachmentMenu(): void {
if (activeServiceLine.value) return
attachmentMenuOpen.value = !attachmentMenuOpen.value
emojiOpen.value = false
attachmentPicker.value = null
@@ -655,7 +646,7 @@ function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber || activeServiceLine.value) return
if (!messages.activeNumber) return
const remainingSlots =
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
if (remainingSlots < 1) {
@@ -716,14 +707,7 @@ async function sendAttachment(
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (
!messages.activeNumber ||
!activeCanMessage.value ||
activeServiceLine.value ||
sending.value
) {
return
}
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -738,7 +722,7 @@ async function sendAttachment(
}
async function sendContact(contact: PhoneContact): Promise<void> {
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
if (!messages.activeNumber || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -901,12 +885,7 @@ function sampleMicrophone(): void {
}
async function startVoiceRecording(): Promise<void> {
if (
!activeCanMessage.value ||
activeServiceLine.value ||
recording.value ||
recordingStarting.value
) {
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
return
}
emojiOpen.value = false
@@ -1062,27 +1041,6 @@ watch(
},
)
watch(activeServiceLine, (serviceLine) => {
if (!serviceLine) return
const discarded = Boolean(
shareDraft.value ||
pendingAttachments.value.length ||
recording.value ||
recordingStarting.value,
)
attachmentMenuOpen.value = false
attachmentPicker.value = null
shareDraft.value = null
pendingAttachments.value = []
if (recording.value) {
discardRecording = true
cancelVoiceRecording()
} else if (recordingStarting.value) {
cleanupRecorder()
}
if (discarded) showToast(errorText('service_line_text_only'))
})
onBeforeUnmount(() => {
discardRecording = true
cleanupRecorder()
@@ -1656,7 +1614,7 @@ onBeforeUnmount(() => {
</SkyScrollArea>
<section
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
v-if="activeCanMessage && attachmentMenuOpen"
class="messages-attachment-menu"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1701,9 +1659,7 @@ onBeforeUnmount(() => {
<SkySheet
class="messages-media-picker-sheet"
:opened="
activeCanMessage && !activeServiceLine && attachmentPicker !== null
"
:opened="activeCanMessage && attachmentPicker !== null"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
@@ -1838,7 +1794,7 @@ onBeforeUnmount(() => {
/>
<div
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
v-if="activeCanMessage && shareDraft && !recording"
class="shared-composer-preview"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1854,7 +1810,7 @@ onBeforeUnmount(() => {
</div>
<section
v-if="activeCanMessage && !activeServiceLine && recording"
v-if="activeCanMessage && recording"
class="messages-recorder"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1942,7 +1898,6 @@ onBeforeUnmount(() => {
<div class="messages-sky-composer-row">
<SkyGlass
v-if="!activeServiceLine"
component="button"
type="button"
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
@@ -1979,7 +1934,7 @@ onBeforeUnmount(() => {
<ArrowUpCircle :size="29" :stroke-width="2.4" />
</SkyLink>
<SkyLink
v-else-if="!activeServiceLine"
v-else
icon-only
class="messages-sky-messagebar__send"
:disabled="sending || recordingStarting"
@@ -63,21 +63,6 @@ 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 () => {'),
-57
View File
@@ -3858,63 +3858,6 @@ 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,52 +32,6 @@ 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')
+25 -47
View File
@@ -622,26 +622,26 @@ onBeforeUnmount(() => {
</k-card>
<template v-else>
<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>
<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">
<template v-if="activeTab === 'home'">
<section v-if="mode === 'rider'" class="skyride-home-panel">
<template v-if="!skyride.activeRide">
@@ -1816,11 +1816,6 @@ 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;
@@ -1858,9 +1853,7 @@ onBeforeUnmount(() => {
.skyride-scroll {
position: absolute;
z-index: 2;
box-sizing: border-box;
inset: 114px 0 0;
padding: 0 0 116px;
inset: 148px 0 116px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
@@ -2260,10 +2253,6 @@ onBeforeUnmount(() => {
padding: 16px;
}
.skyride-person-card {
margin-bottom: 12px;
}
.skyride-avatar,
.skyride-profile-avatar {
display: grid;
@@ -2340,19 +2329,13 @@ onBeforeUnmount(() => {
}
.skyride-route-stop {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
display: flex;
align-items: center;
column-gap: 11px;
}
.skyride-route-stop > .skyride-dot {
justify-self: center;
gap: 11px;
}
.skyride-route-stop > svg {
width: 18px;
height: 18px;
flex: 0 0 18px;
color: var(--ride-accent-strong);
}
@@ -2383,7 +2366,7 @@ onBeforeUnmount(() => {
display: block;
width: 1px;
height: 17px;
margin: 1px 0 1px 8px;
margin: 1px 0 1px 5px;
border-left: 1px dashed var(--ride-muted);
}
@@ -2708,11 +2691,6 @@ 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;
@@ -2991,7 +2969,7 @@ button.skyride-profile-avatar--editable {
background-color: var(--ride-accent);
}
.skyride-app :deep(.sky-button--primary:not(.sky-button--outline)) {
.skyride-app :deep(.sky-button--primary) {
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': 1,
'CameraApp.vue': 2,
'FeatherApp.vue': 2,
'FlareApp.vue': 1,
'FlipTokApp.vue': 2,
@@ -476,66 +476,6 @@ 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' }
@@ -555,7 +495,6 @@ function buildStructure(value, scope, path) {
]),
)
return {
entryDefault: companyDefinitionEntryDefault(value),
fields,
kind: 'table',
mutableKeys: true,
@@ -12,7 +12,6 @@ type ConfiguratorField = {
}
type ConfiguratorStructure = {
entryDefault?: unknown
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
@@ -123,22 +122,6 @@ 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' },
@@ -152,47 +135,6 @@ 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,
@@ -241,7 +183,7 @@ describe('admin configurator fixture', () => {
template: { kind: 'value', valueType: 'string' },
})
expect(
root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
root('Companies.Definitions')?.fields?.police.fields?.Services,
).toMatchObject({
items: [],
kind: 'list',
+17 -9
View File
@@ -1978,7 +1978,7 @@ const contacts = [
},
{
canCall: true,
canMessage: true,
canMessage: false,
companyId: 'police',
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
id: 'company:police',
@@ -4178,7 +4178,7 @@ const companyCategories = [
let companyCallAvailable = false
const companyProfiles = [
{
acceptsRequests: true,
acceptsRequests: false,
announcement: {
body: 'Community traffic unit active around Legion Square.',
expiresAt: isoTime(6 * 60 * 60 * 1000),
@@ -4187,7 +4187,7 @@ const companyProfiles = [
availability: 'available',
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
canCall: true,
canMessage: true,
canMessage: false,
categoryId: 'public_services',
categoryName: 'Public Services',
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
@@ -4202,20 +4202,28 @@ const companyProfiles = [
label: 'Mission Row Police Station',
},
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
name: 'Los Santos Police Department',
name: 'Los Santos Police',
phoneNumber: '911',
revision: 3,
services: [
{
acceptsRequests: true,
acceptsRequests: false,
active: true,
description: 'Request non-emergency police assistance.',
id: 'police-assistance',
description: 'Immediate police response through the service line.',
id: 'emergency-response',
priceText: null,
title: 'Police Assistance',
title: 'Emergency Response',
},
{
acceptsRequests: false,
active: true,
description: 'General information and non-emergency assistance.',
id: 'public-assistance',
priceText: null,
title: 'Public Assistance',
},
],
serviceSummary: 'Non-emergency police assistance',
serviceSummary: 'Emergency response and public assistance',
verified: true,
},
{
+3 -11
View File
@@ -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 = true,
AcceptsRequests = false,
District = "Mission Row",
LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station",
@@ -1190,7 +1190,7 @@ if IsDuplicityVersion() then
Number = "911",
AutoContact = true,
CanCall = true,
CanMessage = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
@@ -1203,15 +1203,7 @@ if IsDuplicityVersion() then
Services = 3,
Announcement = 3,
},
Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
Services = {},
},
ambulance = {
Job = "ambulance",
+4 -5
View File
@@ -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.", 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." },
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." },
},
Apps = {
health = {
@@ -695,7 +695,6 @@ Locales["de"] = {
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
},
@@ -1229,7 +1228,7 @@ Locales["de"] = {
},
skyride = {
name = "SkyRide", loading = "Laden SkyRide", unavailable = "SkyRide nicht verfügbar", tryAgain = "Erneut versuchen",
mode = { rider = "Passagier", driver = "Fahrer" },
mode = { rider = "Reiten", driver = "Antrieb" },
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" },
@@ -1450,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 Umschautaste gedrückt, um dich umzusehen", returnHelp = "Umschautaste loslassen für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", lookKey = "Umschauen", uploading = "Upload: {count}",
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}",
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
zoom = "Kamerazoom auf {zoom} einstellen",
+3 -4
View File
@@ -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.", 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." },
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." },
},
Apps = {
health = {
@@ -695,7 +695,6 @@ Locales["en"] = {
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
messaging_unavailable = "This company contact does not accept messages.",
service_line_text_only = "Company service lines currently accept text messages only.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
},
@@ -1450,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 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",
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", spaceKey = "Space", 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}",
+3 -4
View File
@@ -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.", 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." },
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." },
},
Apps = {
health = {
@@ -695,7 +695,6 @@ Locales["es"] = {
gif_provider_rate_limited = "GIPHY está ocupado. Inténtalo de nuevo en un momento.", gif_provider_failed = "La búsqueda de GIFs está temporalmente inaccesible.",
self_message = "No puedes enviar mensajes a tu propio número.", recipient_not_found = "Ese número no está disponible.", blocked = "Este contacto ha bloqueado las llamadas y los mensajes de esta SIM.",
messaging_unavailable = "Este contacto de empresa no acepta mensajes.",
service_line_text_only = "Las líneas de servicio de empresa solo aceptan mensajes de texto por ahora.",
no_sim = "Este teléfono no tiene tarjeta SIM.", rate_limited = "Demasiados mensajes, inténtalo de nuevo en un minuto.",
request_failed = "Los mensajes están temporalmente inaccesibles.", default = "El mensaje no se pudo enviar.",
},
@@ -1450,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 = "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}",
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}",
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}",
+4 -2
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.3.1'
version '0.2.8'
provide 'lb-phone'
provide '17mov_Phone'
@@ -30,7 +30,9 @@ shared_scripts {
client_scripts {
'config/config.lua',
'config/locales/*.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'config/locales/es.lua',
'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua',
+15 -57
View File
@@ -9,13 +9,9 @@ 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
camera_passthrough_control,
22, -- INPUT_JUMP
24, -- INPUT_ATTACK
25, -- INPUT_AIM
37, -- INPUT_SELECT_WEAPON
@@ -49,8 +45,6 @@ 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,
@@ -95,39 +89,20 @@ 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 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 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 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)
@@ -223,18 +198,7 @@ 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)
@@ -250,6 +214,9 @@ 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
@@ -276,7 +243,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 then
if data.active and data.gameInput then
watch_camera_controls()
end
end)
@@ -289,8 +256,6 @@ 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
@@ -325,8 +290,6 @@ 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
@@ -351,8 +314,6 @@ 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()
@@ -444,9 +405,6 @@ 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)
+4 -17
View File
@@ -58,16 +58,6 @@ 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)
@@ -106,10 +96,6 @@ 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
@@ -151,7 +137,7 @@ function SkyPhoneFocus.Reapply()
active = state.camera_active,
cursor = focus.cursor,
focused = focus.focused,
gameInput = focus.game_input,
gameInput = focus.keep_input,
})
end
@@ -248,9 +234,10 @@ end
CreateThread(function()
while true do
if game_input or block_game then
local look_passthrough = game_input
local look_passthrough = hold_to_look_enabled
and game_input
and not state.cursor_disabled
and SkyPhoneFocus.IsHoldToLookPressed()
and IsControlPressed(0, hold_to_look_control)
if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply()
+20 -16
View File
@@ -13,7 +13,9 @@ local suggested_admin_command = nil
local suggested_test_data_command = nil
local active_development_command = nil
local registered_development_commands = {}
local phone_key_mapping_registered = false
local active_key_mapping_command = nil
local active_key_mapping_key = nil
local key_mapping_revision = 0
local refresh_development_command
local refresh_phone_key_mapping
local refresh_test_data_command_suggestion
@@ -310,30 +312,32 @@ RegisterCommand("sky_phone_live_activity_open", function()
end
end, false)
RegisterCommand("sky_phone_toggle", function()
if not Config.Phone.Keybind then
return
end
run_phone_toggle()
end, false)
RegisterCommand("sky_phone_toggle", run_phone_toggle, 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 phone_key_mapping_registered or not key_name then
if key_name == active_key_mapping_key then
return
end
-- 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
)
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)
end
refresh_phone_key_mapping()
+86 -377
View File
@@ -156,18 +156,12 @@ local function permission_grade(definition, permission)
return grade and math.max(0, math.floor(grade)) or nil
end
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."
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.")
end
if type(company_config.Enabled) ~= "boolean" then
return nil, "[sky_phone] Config.Companies.Enabled must be a boolean."
if type(Config.Companies.Enabled) ~= "boolean" then
error("[sky_phone] Config.Companies.Enabled must be a boolean.")
end
for _, field in ipairs({
{ "PageSize", 1000 },
@@ -190,26 +184,26 @@ local function validate_configuration(configuration)
{ "AnnouncementMaximumSeconds", 31536000 },
{ "RetentionDays", 36500 },
}) do
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])
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]))
end
end
if company_config.PageSize > company_config.MaximumPageSize then
return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize."
if Config.Companies.PageSize > Config.Companies.MaximumPageSize then
error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.")
end
if type(company_config.RateLimits) ~= "table" then
return nil, "[sky_phone] Config.Companies.RateLimits must be configured."
if type(Config.Companies.RateLimits) ~= "table" then
error("[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(company_config.RateLimits[name], 1, 100000) then
return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name)
if not valid_integer(Config.Companies.RateLimits[name], 1, 100000) then
error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name))
end
end
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)
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)
then
return nil, "[sky_phone] Config.Companies.CallRouting is invalid."
error("[sky_phone] Config.Companies.CallRouting is invalid.")
end
local configured_statuses = {
new = true,
@@ -219,48 +213,48 @@ local function validate_configuration(configuration)
completed = true,
cancelled = true,
}
if type(company_config.Statuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.Statuses must be configured."
if type(Config.Companies.Statuses) ~= "table" then
error("[sky_phone] Config.Companies.Statuses must be configured.")
end
for status in pairs(configured_statuses) do
if company_config.Statuses[status] ~= true then
return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status)
if Config.Companies.Statuses[status] ~= true then
error(("[sky_phone] Companies status '%s' must be enabled."):format(status))
end
end
for status, enabled in pairs(company_config.Statuses) do
for status, enabled in pairs(Config.Companies.Statuses) do
if not configured_statuses[status] or enabled ~= true then
return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))
error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status)))
end
end
if type(company_config.AvailabilityStatuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.AvailabilityStatuses must be configured."
if type(Config.Companies.AvailabilityStatuses) ~= "table" then
error("[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 company_config.AvailabilityStatuses[status] ~= true then
return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status)
if Config.Companies.AvailabilityStatuses[status] ~= true then
error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status))
end
end
for status, enabled in pairs(company_config.AvailabilityStatuses) do
for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do
if not configured_availability[status] or enabled ~= true then
return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))
error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status)))
end
end
if not valid_array(company_config.Categories, 100) then
return nil, "[sky_phone] Config.Companies.Categories must be a bounded array."
if not valid_array(Config.Companies.Categories, 100) then
error("[sky_phone] Config.Companies.Categories must be a bounded array.")
end
local category_ids = {}
local configured_service_ids = {}
for _, category_id in ipairs(company_config.Categories) do
for _, category_id in ipairs(Config.Companies.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
return nil, "[sky_phone] Companies contains an invalid category ID."
error("[sky_phone] Companies contains an invalid category ID.")
end
category_ids[category_id] = true
end
for company_id, definition in pairs(company_config.Definitions) do
for company_id, definition in pairs(Config.Companies.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
@@ -268,25 +262,26 @@ local function validate_configuration(configuration)
or not valid_text(definition.Name, 120, false)
or not category_ids[definition.Category]
then
return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))
error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id)))
end
local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true)
local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true)
local location_label = valid_text(
definition.LocationLabel or definition.Address or "",
company_config.DistrictMaxLength,
Config.Companies.DistrictMaxLength,
true
)
local address = valid_text(definition.Address or "", company_config.AddressMaxLength, true)
local address = valid_text(definition.Address or "", Config.Companies.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 company_config.AvailabilityStatuses[definition.DefaultAvailability]
or not Config.Companies.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
return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)
error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id))
end
definition.Name = trim(definition.Name)
definition.Description = description
@@ -297,7 +292,7 @@ local function validate_configuration(configuration)
if definition.Location ~= nil then
local location_type = type(definition.Location)
if location_type ~= "table" and location_type ~= "vector3" then
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
error(("[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)
@@ -305,46 +300,46 @@ local function validate_configuration(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
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
end
end
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)
if definitions_by_job[definition.Job] then
error(("[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
return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id)
error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id))
end
local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength)
local number = SkyPhoneSimNumber.NormalizeService(line.Number, Config.Sim.NumberLength)
if not number then
return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id)
error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id))
end
if validated_service_lines_by_number[number] then
return nil, ("[sky_phone] Service number '%s' is assigned more than once."):format(number)
if service_lines_by_number[number] then
error(("[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
return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)
error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id))
end
if line.AutoContact and not definition.Public then
return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)
error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id))
end
if line.Routing ~= "round_robin" then
return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(
company_id,
tostring(line.Routing)
)
error(("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(company_id, tostring(line.Routing)))
end
if line.CanMessage then
error(("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(company_id))
end
line.Number = number
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
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
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
return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)
error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission))
end
end
local default_services = definition.Services
@@ -352,54 +347,40 @@ local function validate_configuration(configuration)
default_services = {}
definition.Services = default_services
end
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)
if not valid_array(default_services, Config.Companies.MaximumServices) then
error(("[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
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
end
local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false)
local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false)
local service_description = valid_text(
service.Description or "",
company_config.ServiceDescriptionMaxLength,
Config.Companies.ServiceDescriptionMaxLength,
true
)
local price = valid_text(service.Price or "", company_config.ServicePriceMaxLength, true)
local price = valid_text(service.Price or "", Config.Companies.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
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
error(("[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
return nil, ("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)
error(("[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(validated_definition_ids, function(left, right)
local left_name = validated_definitions[left].Name:lower()
local right_name = validated_definitions[right].Name:lower()
table.sort(definition_ids, function(left, right)
local left_name = definitions[left].Name:lower()
local right_name = 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()
@@ -485,48 +466,6 @@ 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`
@@ -887,7 +826,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,
acceptsRequests = tonumber(row.accepts_requests) == 1 and not definition.Emergency,
phoneNumber = line and line.Number or nil,
canCall = line and line.CanCall == true or false,
canMessage = line and line.CanMessage == true or false,
@@ -967,16 +906,12 @@ local function cleanup_retained_data()
end
local function refresh_runtime_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
definitions = {}
definition_ids = {}
definitions_by_job = {}
service_lines_by_number = {}
validate_configuration()
seed_companies()
migrate_requestable_emergency_companies()
tombstone_removed_companies()
end
@@ -1188,7 +1123,7 @@ end
local function request_row(request_id)
local rows = Bridge.Database.Query([[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
@@ -1890,175 +1825,6 @@ local function notification_payload(kind, area, row)
}
end
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
if not Config.Companies.Enabled or type(data) ~= "table"
or data.messageType ~= "text" or not valid_uuid(data.id)
then
return { success = false, error = "invalid_request" }
end
local device, device_error = current_device(source, false)
if not device then
return device_error
end
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
if not service_line or not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
local body = valid_text(
data.body,
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
false
)
if not body then
return { success = false, error = "invalid_message" }
end
local request_id = uuid()
local created_event_id = uuid()
local status_event_id = uuid()
local mutation_token = uuid()
local statements = {
{
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
params = { device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_requests`
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
`company_activity_revision`)
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
WHERE sim.`id` = ? AND NOT EXISTS (
SELECT 1 FROM `sky_phone_company_requests` existing
WHERE existing.`company_id` = profile.`company_id`
AND existing.`customer_sim_id` = sim.`id`
AND existing.`channel` = 'service_line'
AND existing.`status` NOT IN ('completed', 'cancelled')
)
]],
params = {
request_id,
body,
service_line.companyId,
device.sim_id,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { created_event_id, request_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
FROM `sky_phone_company_requests`
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { status_event_id, service_line.companyId, device.sim_id },
},
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
`company_activity_revision` = `company_activity_revision` + 1,
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line'
AND `status` NOT IN ('completed', 'cancelled')
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { mutation_token, service_line.companyId, device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
FROM `sky_phone_company_requests`
WHERE `mutation_token` = ? LIMIT 1
]],
params = { data.id, body, mutation_token },
},
{
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
WHERE sim.`id` = ?
]],
params = {
data.id,
service_line.number,
body,
data.id,
device.sim_id,
},
},
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local routed = Bridge.Database.Query([[
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
FROM `sky_phone_sms_messages` sms
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
LEFT JOIN `sky_phone_company_request_events` created
ON created.`id` = ? AND created.`request_id` = request.`id`
WHERE sms.`id` = ? LIMIT 1
]], { created_event_id, data.id })
if not routed[1] then
Bridge.Debug(
"error",
"[sky_phone] Service-line message %s committed without a complete route.",
tostring(data.id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(routed[1].request_id)
if not row then
return { success = false, error = "request_failed" }
end
emit_request_change(row, true, true, source)
if routed[1].created_event_id then
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
"newRequest",
"work",
row
), source)
elseif row.assigned_identifier then
notify_identifier(
row.assigned_identifier,
row.company_id,
"sky_phone:companies:notification",
notification_payload("newMessage", "work", row)
)
end
return {
success = true,
data = {
messageId = data.id,
requestId = row.id,
},
}
end
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
if not allowed then
@@ -2073,7 +1839,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 then
if not definition or not definition.Public or definition.Emergency then
return { success = false, error = "company_not_found" }
end
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false)
@@ -2324,11 +2090,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
then
return { success = false, error = "invalid_status" }
end
local service_line = access.row.channel == "service_line"
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
return { success = false, error = "messaging_unavailable" }
end
local message_id = uuid()
local mutation_token = uuid()
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
@@ -2368,37 +2129,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
}
end
if service_line then
if access.audience == "customer" then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
]],
params = { service_line.number, message_id },
}
else
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'company'
]],
params = { service_line.number, message_id },
}
end
end
if new_status ~= access.row.status then
statements[#statements + 1] = {
query = [[
@@ -2413,32 +2143,16 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local inserted = Bridge.Database.Query([[
SELECT message.`id`, sms.`id` AS `sms_id`
FROM `sky_phone_company_request_messages` message
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
WHERE message.`id` = ? LIMIT 1
]], { message_id })
local inserted = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
{ message_id }
)
if not inserted[1] then
return { success = false, error = "revision_conflict" }
end
if service_line and not inserted[1].sms_id then
Bridge.Debug(
"error",
"[sky_phone] Company request message %s committed without its service-line SMS.",
tostring(message_id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
if access.audience == "customer" then
if service_line then
TriggerClientEvent("sky_phone:messages:changed", source, {
phoneNumber = service_line.number,
})
end
local notification = notification_payload("newMessage", "work", row)
if row.assigned_identifier then
notify_identifier(
@@ -2448,12 +2162,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
notification
)
end
elseif service_line then
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
phoneNumber = service_line.number,
sender = service_line.name,
voice = false,
})
else
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"newMessage",
@@ -2928,6 +2636,7 @@ 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
+70 -205
View File
@@ -6,12 +6,7 @@ local exchange_lock = false
local markets = {}
local market_order = {}
local market_dynamics = {}
local market_state = {}
local market_history = {}
local market_daily_buckets = {}
local market_cursor = 1
local market_daily_bucket_seconds = 5 * 60
local market_persistence_interval = market_daily_bucket_seconds * 1000
local global_market_trend = 0
local global_market_cycle = {
direction = 0,
@@ -375,145 +370,10 @@ local function initialize_markets()
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
end
local function add_market_daily_price(buckets, price, timestamp)
local bucket_id = math.floor(timestamp / market_daily_bucket_seconds)
local bucket = buckets[#buckets]
if bucket and bucket.bucket_id == bucket_id then
bucket.low = math.min(bucket.low, price)
bucket.high = math.max(bucket.high, price)
return
end
buckets[#buckets + 1] = {
bucket_id = bucket_id,
low = price,
high = price,
}
end
local function market_daily_range(market_id, price, timestamp)
local buckets = market_daily_buckets[market_id]
local cutoff_bucket = math.floor((timestamp - 24 * 60 * 60) / market_daily_bucket_seconds)
while buckets[1] and buckets[1].bucket_id < cutoff_bucket do
table.remove(buckets, 1)
end
local low = price
local high = price
for index = 1, #buckets do
local bucket = buckets[index]
low = math.min(low, bucket.low)
high = math.max(high, bucket.high)
end
return low, high
end
local function load_market_cache()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
FROM `sky_phone_crypto_markets`
]], {})
local next_market_state = {}
local next_market_history = {}
local next_market_daily_buckets = {}
local history_limit = math.min(Config.Crypto.HistoryRetentionTicks, Config.Crypto.SparklinePoints)
local timestamp = os.time()
for _, row in ipairs(rows) do
if markets[row.id] then
next_market_state[row.id] = {
price = tonumber(row.price) or markets[row.id].InitialPrice,
version = tonumber(row.version) or 1,
status = row.status,
updated_at = tonumber(row.updated_at) or timestamp,
dirty = false,
}
end
end
for _, market_id in ipairs(market_order) do
local state = next_market_state[market_id]
if not state then
error(("[sky_phone] Crypto market state is missing after initialization: %s"):format(market_id))
end
local rows_for_market = Bridge.Database.Query([[
SELECT `price`
FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `created_at` DESC, `id` DESC LIMIT ?
]], { market_id, history_limit })
local history = {}
for index = #rows_for_market, 1, -1 do
history[#history + 1] = tonumber(rows_for_market[index].price) or state.price
end
if #history == 0 then
history[1] = state.price
end
next_market_history[market_id] = history
local daily_rows = Bridge.Database.Query([[
SELECT FLOOR(UNIX_TIMESTAMP(`created_at`) / ?) AS `bucket_id`,
MIN(`price`) AS `low_price`, MAX(`price`) AS `high_price`
FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ?
AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `bucket_id` ORDER BY `bucket_id`
]], { market_daily_bucket_seconds, market_id })
local daily_buckets = {}
for _, daily_row in ipairs(daily_rows) do
daily_buckets[#daily_buckets + 1] = {
bucket_id = tonumber(daily_row.bucket_id),
low = tonumber(daily_row.low_price) or state.price,
high = tonumber(daily_row.high_price) or state.price,
}
end
add_market_daily_price(daily_buckets, state.price, timestamp)
next_market_daily_buckets[market_id] = daily_buckets
end
market_state = next_market_state
market_history = next_market_history
market_daily_buckets = next_market_daily_buckets
end
local function persist_market_cache()
local queries = {}
local persisted_markets = {}
for _, market_id in ipairs(market_order) do
local state = market_state[market_id]
if state and state.dirty then
queries[#queries + 1] = {
query = [[
UPDATE `sky_phone_crypto_markets`
SET `price` = ?, `version` = ?, `status` = ?, `updated_at` = FROM_UNIXTIME(?)
WHERE `id` = ?
]],
params = { state.price, state.version, state.status, state.updated_at, market_id },
}
queries[#queries + 1] = {
query = [[
INSERT INTO `sky_phone_crypto_market_ticks`
(`market_id`,`version`,`price`,`created_at`) VALUES (?, ?, ?, FROM_UNIXTIME(?))
]],
params = { market_id, state.version, state.price, state.updated_at },
}
persisted_markets[#persisted_markets + 1] = market_id
end
end
if #queries == 0 then
return true
end
queries[#queries + 1] = {
query = [[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `created_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
]],
params = {},
}
if not Bridge.Database.Transaction(queries) then
print("[sky_phone] Failed to persist the crypto market cache.")
return false
end
for _, market_id in ipairs(persisted_markets) do
market_state[market_id].dirty = false
end
return true
end
local function require_phone(source)
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
return nil, nil, { success = false, error = "service_unavailable" }
end
local phone_session, error_response = SkyPhone.RequireSession(source)
if not phone_session then
return nil, nil, error_response
@@ -594,7 +454,20 @@ local function balance(account, asset)
row and (tonumber(row.version) or 0) or 0
end
local function market_rows()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
FROM `sky_phone_crypto_markets`
]], {})
local indexed = {}
for _, row in ipairs(rows) do
indexed[row.id] = row
end
return indexed
end
local function market_dtos(selected_market_ids)
local current = market_rows()
local selected = nil
if selected_market_ids then
selected = {}
@@ -602,17 +475,28 @@ local function market_dtos(selected_market_ids)
selected[market_id] = true
end
end
local daily_rows = Bridge.Database.Query([[
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
FROM `sky_phone_crypto_market_ticks`
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `market_id`
]], {})
local daily = {}
for _, daily_row in ipairs(daily_rows) do
daily[daily_row.market_id] = daily_row
end
local result = {}
local timestamp = os.time()
for _, market_id in ipairs(market_order) do
if not selected or selected[market_id] then
local config = markets[market_id]
local row = market_state[market_id]
local history = market_history[market_id]
local row = current[market_id]
local ticks = Bridge.Database.Query([[
SELECT `price` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
]], { market_id, Config.Crypto.SparklinePoints })
local prices = {}
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
for index = first_history_index, #history do
prices[#prices + 1] = history[index]
for index = #ticks, 1, -1 do
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
end
if #prices == 0 then
prices[1] = tonumber(row.price)
@@ -628,7 +512,7 @@ local function market_dtos(selected_market_ids)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
local daily_range = daily[market_id]
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
@@ -638,13 +522,13 @@ local function market_dtos(selected_market_ids)
price = decimal_string(price, Config.Crypto.PriceScale),
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
enabled = row.status == "active",
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
}
end
end
@@ -686,12 +570,13 @@ end
local function bootstrap(profile)
local cash = balance(account_id(profile.id), "CASH")
local current_markets = market_rows()
local holdings = {}
local portfolio = cash
for _, market_id in ipairs(market_order) do
local available = balance(account_id(profile.id), market_id)
if available > 0 then
local price = tonumber(market_state[market_id].price) or 0
local price = tonumber(current_markets[market_id].price) or 0
local value = math.floor(available * price / Config.Crypto.AssetScale)
local fill = Bridge.Database.Query([[
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
@@ -779,7 +664,7 @@ local function with_exchange_lock(callback)
end
Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source)
if not Config.Crypto.Enabled then
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
return { success = false, error = "service_unavailable" }
end
local profile, error_response = authenticated_profile(source)
@@ -1162,7 +1047,10 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
if not config or not side or not quantity then
return { success = false, error = "invalid_quantity" }
end
local market = market_state[config.Id]
local market = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ config.Id }
)[1]
if not market or market.status ~= "active" then
return { success = false, error = "market_unavailable" }
end
@@ -1242,8 +1130,9 @@ local function execute_trade(profile, data)
or { success = false, error = "duplicate_request" }
end
local quote = Bridge.Database.Query([[
SELECT quote.*
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
FROM `sky_phone_crypto_quotes` quote
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
]], { data.quoteId, profile.id })[1]
if not quote or quote.consumed_operation_id then
@@ -1256,10 +1145,7 @@ local function execute_trade(profile, data)
if not expiry or tonumber(expiry.expires_at) < os.time() then
return { success = false, error = "quote_expired" }
end
local current_market = market_state[quote.market_id]
if not current_market or current_market.status ~= "active"
or current_market.version ~= tonumber(quote.market_version)
then
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
return { success = false, error = "quote_expired" }
end
local quantity = tonumber(quote.quantity)
@@ -1484,7 +1370,6 @@ end)
ensure_schema()
migrate_crypto_keys()
initialize_markets()
load_market_cache()
local function reconcile_settlements(include_recent)
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
@@ -1625,16 +1510,6 @@ local function start_crypto_schedulers()
local generation = scheduler_generation
reconcile_settlements(true)
CreateThread(function()
while scheduler_generation == generation and Config.Crypto.Enabled == true do
Wait(market_persistence_interval)
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
break
end
with_exchange_lock(persist_market_cache)
end
end)
CreateThread(function()
while scheduler_generation == generation and Config.Crypto.Enabled == true do
Wait(5 * 60 * 1000)
@@ -1664,17 +1539,16 @@ local function start_crypto_schedulers()
)
advance_global_market_cycle()
local changed_markets = {}
local history_limit = math.min(
Config.Crypto.HistoryRetentionTicks,
Config.Crypto.SparklinePoints
)
market_count = math.min(market_count, #market_order)
for offset = 0, market_count - 1 do
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
local market_id = market_order[order_index]
local config = markets[market_id]
local row = market_state[market_id]
local row = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id }
)[1]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = crypto_random_int(
@@ -1743,19 +1617,22 @@ local function start_crypto_schedulers()
next_price = price + (movement > 0 and 1 or -1)
end
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
local next_version = row.version + 1
local updated_at = os.time()
row.price = next_price
row.version = next_version
row.updated_at = updated_at
row.dirty = true
local history = market_history[market_id]
history[#history + 1] = next_price
if #history > history_limit then
table.remove(history, 1)
local next_version = (tonumber(row.version) or 0) + 1
if Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
}) then
changed_markets[#changed_markets + 1] = market_id
Bridge.Database.Query([[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? AND `id` NOT IN (
SELECT `id` FROM (
SELECT `id` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
) retained
)
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
end
add_market_daily_price(market_daily_buckets[market_id], next_price, updated_at)
changed_markets[#changed_markets + 1] = market_id
end
end
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
@@ -1771,26 +1648,14 @@ local function start_crypto_schedulers()
end
local function refresh_crypto_runtime()
if exchange_lock then
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
return
initialize_markets()
start_crypto_schedulers()
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
sessions = {}
end
with_exchange_lock(function()
if not persist_market_cache() then
return
end
initialize_markets()
load_market_cache()
start_crypto_schedulers()
end)
end
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
persist_market_cache()
end
end)
start_crypto_schedulers()
end)
-2
View File
@@ -2710,7 +2710,6 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "channel", type = "ENUM('app','service_line') NOT NULL DEFAULT 'app'" },
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
@@ -2730,7 +2729,6 @@ local schema = {
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
{ name = "idx_sky_phone_company_requests_service_line", columns = "(`company_id`, `customer_sim_id`, `channel`, `status`, `updated_at`, `id`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
+7 -47
View File
@@ -97,14 +97,6 @@ local function current_device(source)
return device
end
local function resolve_message_recipient(value)
local service_line = SkyPhoneCompanies.GetServiceLine(value)
if service_line then
return service_line.number, service_line
end
return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix), nil
end
local function shared_contact(device, contact_id)
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
return nil, "invalid_contact"
@@ -374,7 +366,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if not device then
return error_response
end
local number = resolve_message_recipient(data.phoneNumber)
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -415,7 +407,11 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = resolve_message_recipient(data.phoneNumbers[index])
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -487,7 +483,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not device then
return error_response
end
local number, service_line = resolve_message_recipient(data.phoneNumber)
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -540,42 +536,6 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
else
return { success = false, error = "invalid_request" }
end
if service_line then
if not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
if message_type ~= "text" then
return { success = false, error = "service_line_text_only" }
end
local id = uuid()
local routed = SkyPhoneCompanies.RouteServiceLineMessage(source, {
id = id,
body = body,
messageType = message_type,
phoneNumber = number,
})
if not routed or not routed.success then
return routed or { success = false, error = "request_failed" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`,
`media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`,
`read_at`, `created_at`, 'sent' AS `direction`
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
]], { id })
if not rows[1] then
Bridge.Debug(
"error",
"[sky_phone] Routed service-line SMS %s could not be read back.",
tostring(id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local message = format_message(rows[1])
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
return { success = true, data = message }
end
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
@@ -563,75 +563,6 @@ 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
@@ -650,7 +581,6 @@ 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,
@@ -1145,211 +1075,6 @@ 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_police_service_line_messaging()
local migration_name = "sky-phone:configurator:service-line-messaging:v2"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local row = read_stored_row()
local config_payload = decode_payload(row.config_payload, "config")
local police = config_payload.Companies
and config_payload.Companies.Definitions
and config_payload.Companies.Definitions.police
local line = type(police) == "table" and police.ServiceLine or nil
local migrated = type(line) == "table" and line.CanMessage ~= true
if migrated then
line.CanMessage = true
end
local statements = {}
if migrated then
statements[#statements + 1] = {
query = ([[
UPDATE `%s`
SET `config_payload` = ?, `revision` = `revision` + 1
WHERE `id` = ?
]]):format(TABLE_NAME),
params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID },
}
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ police = migrated }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not enable Phone Configurator police service-line messaging.")
end
if not migrated then
return
end
apply_stored_row(read_stored_row())
apply_runtime_configuration()
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Enabled Phone Configurator police service-line messaging.",
{ always = true }
)
end
default_config = {}
for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
@@ -1370,9 +1095,6 @@ Bridge.Database.Query(([[
apply_stored_row(read_stored_row())
apply_runtime_configuration()
Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)
function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data()
@@ -1422,18 +1144,6 @@ 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(([[
+10 -15
View File
@@ -1836,21 +1836,16 @@ CreateThread(function()
while true do
local recovery_success, recovery_rows = pcall(
Bridge.Database.Query,
(([[
SELECT r.`id`, r.`status`, r.`refund_status`, r.`price`,
passenger.`owner_identifier` AS `passenger_identifier`
FROM `sky_phone_skyride_rides` r
INNER JOIN `sky_phone_skyride_profiles` passenger
ON passenger.`id` = r.`passenger_profile_id`
WHERE (
r.`refund_status` = 'pending'
AND r.`status` = 'cancelled'
) OR (
r.`refund_status` IN ('pending','completed')
AND r.`status` = 'payment_pending'
AND r.`updated_at` <= DATE_SUB(
CURRENT_TIMESTAMP,
INTERVAL %d SECOND
ride_select .. (([[
WHERE r.`refund_status` IN ('pending','completed')
AND (
r.`status` = 'cancelled'
OR (
r.`status` = 'payment_pending'
AND r.`updated_at` <= DATE_SUB(
CURRENT_TIMESTAMP,
INTERVAL %d SECOND
)
)
)
ORDER BY r.`updated_at` ASC
+3 -11
View File
@@ -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 = true,
AcceptsRequests = false,
District = "Mission Row",
LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station",
@@ -1175,7 +1175,7 @@ if IsDuplicityVersion() then
Number = "911",
AutoContact = true,
CanCall = true,
CanMessage = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
@@ -1188,15 +1188,7 @@ if IsDuplicityVersion() then
Services = 3,
Announcement = 3,
},
Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
Services = {},
},
ambulance = {
Job = "ambulance",
+5 -63
View File
@@ -4,20 +4,6 @@ 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
@@ -46,20 +32,12 @@ function RegisterNUICallback(name, callback)
end
function RegisterNetEvent() 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 AddEventHandler() end
function TriggerEvent() end
function TriggerServerEvent() end
function SendNUIMessage() end
function CreateThread(callback)
threads[#threads + 1] = callback
end
function Wait()
error(thread_stop, 0)
end
function CreateThread() end
function Wait() end
function PlayerPedId() return 7 end
function PlayerId() return 8 end
function GetFollowPedCamViewMode() return 1 end
@@ -69,19 +47,10 @@ function DisplayRadar() end
function SetFollowPedCamViewMode() end
function SetFollowVehicleCamViewMode() end
function IsPedInAnyVehicle() return false 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 DisableControlAction() 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)
@@ -151,33 +120,6 @@ 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")
+3 -57
View File
@@ -5,9 +5,6 @@ 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 = {
@@ -37,19 +34,7 @@ function SetNuiFocusKeepInput(keep_input)
nui_keep_input = keep_input
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 TriggerEvent() end
function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "phone controls must be disabled in the primary input group")
@@ -71,25 +56,6 @@ 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,
@@ -267,9 +233,6 @@ 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 })
@@ -301,28 +264,11 @@ local focused_camera = resolve({
assert(
focused_camera.cursor
and focused_camera.focused
and focused_camera.keep_input
and not focused_camera.keep_input
and not focused_camera.game_input,
"focused camera must forward readable input while blocking game actions until passthrough is held"
"focused camera must override movement configuration until Space enables passthrough"
)
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,