Compare commits

...

17 Commits

Author SHA1 Message Date
Leon.Schmidt b65710bef0 PERF - bound crypto market history queries 2026-08-25 21:21:29 +02:00
Alec Schitzkat 2388ba9b4e BLD - Bump sky_phone version to 0.3.1 2026-08-24 23:45:07 +02:00
Leon.Schmidt c6394fda66 PERF - streamline SkyRide refund recovery (#43) 2026-08-24 20:44:37 +02:00
Leon.Schmidt 4516469c33 PERF - cache crypto market ticks server-side (#42) 2026-08-24 15:20:13 +02:00
DerEchteAlec 7708ed725b FIX - support folder dragging across scaling (#41) 2026-08-24 01:39:43 +02:00
Alec Schitzkat 00067543cb BLD - release sky_phone 0.3.0 2026-08-24 01:14:08 +02:00
Alec Schitzkat fd24c8b272 FIX - route company service-line messages 2026-08-24 01:14:08 +02:00
Leon.Schmidt aac916efc6 Update fxmanifest.lua (#40) 2026-08-24 01:08:43 +02:00
DerEchteAlec 603f4de54e FIX - repair open Phone ticket regressions (#38)
* FIX - repair persisted company messaging defaults

* FIX - unify CityWarn spacing and scrolling
2026-08-24 00:50:01 +02:00
DerEchteAlec ef2d805079 FIX - unify CityWarn spacing and radii (#36)
Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
2026-08-24 00:49:48 +02:00
DerEchteAlec 829f8da82a FIX - polish camera controls and landscape layout (#37)
Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
2026-08-23 22:12:22 +02:00
DerEchteAlec 7ca39ef4b9 FIX - refine SkyRide navigation and trip layout (#35)
Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
2026-08-23 22:09:47 +02:00
DerEchteAlec b673b15b97 FIX - restore contact profile light mode (#34) 2026-08-23 22:06:40 +02:00
Leon.Schmidt a12e43b624 FIX - allow configurator company creation (#33)
The generic mutable-table blank template persisted incomplete company definitions before runtime domain validation. Seed valid company defaults, validate before SQL writes, and repair legacy blank entries.
2026-08-23 19:20:37 +02:00
Leon.Schmidt 312ae8a3a3 FIX - repair company actions and key rebindings (#32)
* FIX - repair company detail actions

Constrain compact navbar titles to their grid column so long company names cannot overlap the back action. Allow configured public emergency companies to accept normal service requests, and migrate existing police profile and Phone Configurator defaults exactly once.

* FIX - preserve phone key rebindings

Use the stable sky_phone_toggle command as the RegisterKeyMapping identifier. The previous revisioned command names detached FiveM's persisted keyboard settings from the active handler whenever the mapping identity changed.
2026-08-23 18:23:09 +02:00
Leon.Schmidt 435320e8fd ENH - Fix SkyRide German mode labels (#31)
Correct the German SkyRide mode labels so rider and driver text matches the intended roles and improves clarity in the app.
2026-08-23 16:19:18 +02:00
Leon.Schmidt ea959f59f2 FIX - restore camera input and configurator warning (#30)
* FIX - honor configured camera look control

Camera focus was owned by hard-coded Space DOM handlers, bypassing Config.Phone.HoldToLook. Move focus input to the FiveM client and add a bounded scripted-camera orbit so selfie mode can rotate while the configured control is held.

* FIX - preserve camera movement passthrough

* FIX - restore Phone Configurator startup warning

* FIX - keep camera modifier input readable
2026-08-23 15:50:25 +02:00
55 changed files with 3238 additions and 533 deletions
+2
View File
@@ -1885,6 +1885,8 @@ onBeforeUnmount(() => {
class="phone-screen"
:class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-screen--camera-landscape':
activeAppId === 'camera' && phone.cameraLandscape,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
+10
View File
@@ -460,6 +460,7 @@ button {
}
}
.phone-screen {
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
margin: auto;
@@ -470,6 +471,12 @@ button {
background: #08080a;
border-radius: 40px;
}
.phone-screen--camera-landscape {
background: transparent;
}
.phone-screen--camera-landscape .springboard {
visibility: hidden;
}
.phone-app {
position: absolute !important;
inset: 0 !important;
@@ -3239,6 +3246,9 @@ button {
backface-visibility: hidden;
will-change: transform, border-radius, opacity;
}
.app-window--camera-landscape {
background: transparent;
}
.app-window--citywarn {
backface-visibility: visible;
filter: none;
@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const config = readFileSync(
new URL('../../sky_phone/config/config.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const companiesServer = readFileSync(
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const configuratorServer = readFileSync(
new URL(
'../../sky_phone/source/server/phone_configurator.lua',
import.meta.url,
),
'utf8',
).replace(/\r\n/g, '\n')
const testServer = readFileSync(
new URL('../testserver/index.cjs', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
function sourceBlock(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('Companies emergency request contract', () => {
it('ships non-emergency police assistance as a requestable service', () => {
const police = sourceBlock(
config,
' police = {',
' ambulance = {',
)
const mockPolice = sourceBlock(
testServer,
'const companyProfiles = [',
' {\n acceptsRequests: false,\n announcement: null,',
)
expect(police).toContain('Emergency = true')
expect(police).toContain('AcceptsRequests = true')
expect(police).toContain('Id = "police-assistance"')
expect(police).toContain('RequestsEnabled = true')
expect(mockPolice).toContain('acceptsRequests: true')
expect(mockPolice).toContain("name: 'Los Santos Police Department'")
expect(mockPolice).toContain("id: 'police-assistance'")
})
it('authorizes configured emergency companies through the normal request gates', () => {
const validation = sourceBlock(
companiesServer,
'local function validate_configuration(configuration)',
'local function seed_companies()',
)
const payload = sourceBlock(
companiesServer,
'local function company_payload(',
'local function public_company(',
)
const createRequest = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
'Bridge.Callbacks.Register("sky_phone:companies:cancel-request"',
)
const updateProfile = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:update-profile"',
'Bridge.Callbacks.Register("sky_phone:companies:update-hours"',
)
expect(validation).not.toContain(
'definition.Emergency and definition.AcceptsRequests',
)
expect(payload).toContain(
'acceptsRequests = tonumber(row.accepts_requests) == 1,',
)
expect(payload).not.toContain('not definition.Emergency')
expect(createRequest).toContain(
'if not definition or not definition.Public then',
)
expect(createRequest).not.toContain('definition.Emergency')
expect(createRequest).toContain('SELECT `accepts_requests`')
expect(createRequest).toContain('AND `requests_enabled` = 1')
expect(updateProfile).not.toContain('member.definition.Emergency')
})
it('migrates existing requestable emergency profiles exactly once', () => {
const migration = sourceBlock(
companiesServer,
'local function migrate_requestable_emergency_companies()',
'local function tombstone_removed_companies()',
)
const refresh = sourceBlock(
companiesServer,
'local function refresh_runtime_configuration()',
'\n\nrefresh_runtime_configuration()',
)
expect(migration).toContain('sky-phone:companies:requestable-emergency:v1')
expect(migration).toContain(
'if definition.Emergency and definition.AcceptsRequests then',
)
expect(migration).toContain('SET `accepts_requests` = 1')
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(refresh.indexOf('seed_companies()')).toBeLessThan(
refresh.indexOf('migrate_requestable_emergency_companies()'),
)
expect(
refresh.indexOf('migrate_requestable_emergency_companies()'),
).toBeLessThan(refresh.indexOf('tombstone_removed_companies()'))
})
it('migrates the existing Phone Configurator police defaults', () => {
const migration = sourceBlock(
configuratorServer,
'local function migrate_police_request_defaults()',
'\n\ndefault_config = {}',
)
expect(migration).toContain('sky-phone:configurator:police-requests:v1')
expect(migration).toContain('police.AcceptsRequests == false')
expect(migration).toContain('next(police.Services) == nil')
expect(migration).toContain(
'police.AcceptsRequests = defaults.AcceptsRequests',
)
expect(migration).toContain(
'police.Services = copy_value(defaults.Services)',
)
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('apply_stored_row(read_stored_row())')
expect(migration).toContain('apply_runtime_configuration()')
expect(configuratorServer).toContain(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
})
})
@@ -0,0 +1,133 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
/\r\n/g,
'\n',
)
}
function sourceBlock(
value: string,
startMarker: string,
endMarker: string,
): string {
const start = value.indexOf(startMarker)
const end = value.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return value.slice(start, end)
}
const companiesServer = source('../../sky_phone/source/server/companies.lua')
const messagesServer = source('../../sky_phone/source/server/messages.lua')
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
const configDefaults = source(
'../../sky_phone/source/shared/config_default.lua',
)
const messagesApp = source('./views/apps/MessagesApp.vue')
const phoneFallbacks = source('./stores/phone.ts')
const locales = ['en', 'de', 'es'].map((locale) =>
source(`../../sky_phone/config/locales/${locale}.lua`),
)
describe('Companies service-line message routing contract', () => {
it('enables the police service line and persists an indexed route channel', () => {
const police = sourceBlock(
configDefaults,
' police = {',
' ambulance = {',
)
const schema = sourceBlock(
databaseMigration,
' name = "sky_phone_company_requests",',
' name = "sky_phone_company_request_reads",',
)
expect(police).toContain('Number = "911"')
expect(police).toContain('CanMessage = true')
expect(schema).toContain(
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
)
expect(schema).toContain(
'name = "idx_sky_phone_company_requests_service_line"',
)
})
it('resolves short service numbers before the normal SIM-number path', () => {
const resolver = sourceBlock(
messagesServer,
'local function resolve_message_recipient(value)',
'\n\nlocal function shared_contact',
)
const send = sourceBlock(
messagesServer,
'Bridge.Callbacks.Register("sky_phone:messages:send"',
'\n\nend)',
)
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
expect(resolver).toContain('return service_line.number, service_line')
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
expect(send).toContain('service_line_text_only')
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
send.indexOf('SELECT s.`id`, s.`phone_number`'),
)
})
it('atomically mirrors inbound SMS into one active company request', () => {
const route = sourceBlock(
companiesServer,
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
)
expect(route).toContain('current_device(source, false)')
expect(route).toContain('service_line.canMessage')
expect(route).toContain('UPDATE `sky_phone_sims`')
expect(route).toContain("`channel` = 'service_line'")
expect(route).toContain('AND NOT EXISTS (')
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
expect(route).toContain('Bridge.Database.Transaction(statements)')
expect(route).toContain('emit_request_change(row, true, true, source)')
expect(route).toContain('"sky_phone:companies:notification"')
})
it('mirrors authorized company replies back to the customer SMS thread', () => {
const sendMessage = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
)
expect(sendMessage).toContain('access.row.channel == "service_line"')
expect(
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
).toBe(2)
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
expect(sendMessage).toContain("message.`sender_type` = 'company'")
expect(sendMessage).toContain('"sky_phone:messages:changed"')
expect(sendMessage).toContain('"sky_phone:messages:new"')
})
it('keeps service-line composition text-only in every shipped locale', () => {
expect(messagesApp).toContain(
"() => activeContact.value?.source === 'company'",
)
expect(messagesApp).toContain(
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
)
expect(messagesApp).toContain('v-if="!activeServiceLine"')
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
expect(messagesApp).toContain("'service_line_text_only'")
expect(phoneFallbacks).toContain('service_line_text_only:')
for (const locale of locales) {
expect(locale).toContain('service_line_text_only =')
}
})
})
@@ -0,0 +1,139 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const companiesServer = readFileSync(
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const configuratorServer = readFileSync(
new URL(
'../../sky_phone/source/server/phone_configurator.lua',
import.meta.url,
),
'utf8',
).replace(/\r\n/g, '\n')
const englishLocale = readFileSync(
new URL('../../sky_phone/config/locales/en.lua', import.meta.url),
'utf8',
)
const germanLocale = readFileSync(
new URL('../../sky_phone/config/locales/de.lua', import.meta.url),
'utf8',
)
const spanishLocale = readFileSync(
new URL('../../sky_phone/config/locales/es.lua', import.meta.url),
'utf8',
)
describe('company configurator creation contract', () => {
it('publishes complete defaults for newly added company definitions', () => {
expect(configuratorServer).toContain(
'local function company_definition_entry_default(company_id, configuration)',
)
expect(configuratorServer).toContain(
'local function next_available_company_service_number(configuration)',
)
expect(configuratorServer).toContain(
'entryDefault = company_definition_entry_default()',
)
expect(configuratorServer).toContain('Routing = "round_robin"')
expect(configuratorServer).toContain('RequestsEnabled = true')
})
it('validates the candidate runtime config before encoding or writing SQL', () => {
const save = configuratorServer.slice(
configuratorServer.indexOf('function SkyPhoneConfigurator.Save('),
)
const validation = save.indexOf(
'SkyPhoneCompanies.ValidateConfiguration(candidate_config)',
)
const encoding = save.indexOf(
'local config_encoded = encode_payload(next_config, "config")',
)
const update = save.indexOf('UPDATE `%s`')
expect(validation).toBeGreaterThanOrEqual(0)
expect(validation).toBeLessThan(encoding)
expect(validation).toBeLessThan(update)
expect(save).toContain(
'return { success = false, error = "invalid_company_configuration" }',
)
})
it('validates into isolated registries before replacing the live company state', () => {
expect(companiesServer).toContain(
'function SkyPhoneCompanies.ValidateConfiguration(configuration)',
)
expect(companiesServer).toContain(
'local validated, validation_error = validate_configuration(configuration)',
)
expect(companiesServer).toContain('definitions = validated.definitions')
expect(companiesServer).toContain(
'definition_ids = validated.definition_ids',
)
})
it('localizes rejected company configurations in every shipped locale', () => {
for (const locale of [englishLocale, germanLocale, spanishLocale]) {
expect(locale).toContain('invalid_company_configuration =')
}
})
it('repairs company rows created by the legacy blank schema before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_blank_company_definitions()',
),
configuratorServer.indexOf(
'local function migrate_police_request_defaults()',
),
)
const blankRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)',
)
const policeRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
expect(migration).toContain(
'sky-phone:configurator:company-definition-defaults:v1',
)
expect(migration).toContain('company_definition_entry_default(')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(blankRegistration).toBeGreaterThanOrEqual(0)
expect(blankRegistration).toBeLessThan(policeRegistration)
})
it('enables persisted police service-line messaging before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_police_service_line_messaging()',
),
configuratorServer.indexOf('\n\ndefault_config = {}'),
)
const policeRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
const messageRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
)
expect(migration).toContain(
'sky-phone:configurator:service-line-messaging:v2',
)
expect(migration).toContain('line.CanMessage ~= true')
expect(migration).toContain('line.CanMessage = true')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(migration).toContain('apply_stored_row(read_stored_row())')
expect(migration).toContain('apply_runtime_configuration()')
expect(messageRegistration).toBeGreaterThan(policeRegistration)
expect(companiesServer).not.toContain(
'enables messaging without a virtual service-line message router',
)
})
})
@@ -10,6 +10,10 @@ import { computed, ref } from 'vue'
import { vConfigInputWidth } from '@/directives/configInputWidth'
import type { AdminConfiguratorStructure } from '@/types/admin'
import {
blankFromConfiguratorStructure,
createMutableTableEntry,
} from '@/utils/adminConfiguratorDefaults'
import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription'
export type AdminConfigEditorLabels = {
@@ -342,40 +346,6 @@ function mapValuePath(entry: SerializedMapEntry): string {
return props.path ? `${props.path}.${entry.key}` : String(entry.key)
}
function blankFromStructure(structure: AdminConfiguratorStructure): unknown {
if (structure.kind === 'optionalString') return ''
if (structure.kind === 'value') return blankValue(structure.valueType)
if (structure.kind === 'vector') {
const axes = ['x', 'y', 'z', 'w'].slice(
0,
Number(structure.vectorType.slice(-1)),
)
return Object.fromEntries([
['__skyType', structure.vectorType],
...axes.map((axis) => [axis, 0]),
])
}
if (structure.kind === 'list') {
return structure.items.map(blankFromStructure)
}
if (structure.kind === 'map') {
return {
__skyType: 'map',
entries: structure.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: blankFromStructure(entry.structure),
})),
}
}
return Object.fromEntries(
Object.entries(structure.fields).map(([key, field]) => [
key,
blankFromStructure(field),
]),
)
}
function updateScalar(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -413,7 +383,7 @@ function addListRow(): void {
const value = rows.length
? blankLike(rows[0])
: listTemplate.value
? blankFromStructure(listTemplate.value)
? blankFromConfiguratorStructure(listTemplate.value)
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
@@ -476,12 +446,21 @@ function addTableField(): void {
const template = tableStructure.value?.mutableKeys
? tableStructure.value.template
: undefined
const value = template
? blankFromStructure(template)
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
const structuredValue = tableStructure.value?.mutableKeys
? createMutableTableEntry(
tableStructure.value,
props.path,
key,
tableValue.value,
)
: undefined
const value =
structuredValue !== undefined
? structuredValue
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
emit('update:modelValue', {
...tableValue.value,
[key]: value,
@@ -556,7 +535,7 @@ function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
const key = parsedNewMapKey.value
const value = mapTemplate.value
? blankFromStructure(mapTemplate.value)
? blankFromConfiguratorStructure(mapTemplate.value)
: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
@@ -196,9 +196,7 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('Apps = true,')
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
expect(phoneServer).toContain(
'disabledApps = SkyPhone.GetDisabledApps()',
)
expect(phoneServer).toContain('disabledApps = SkyPhone.GetDisabledApps()')
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
expect(store).toContain('disabledApps: [] as string[]')
@@ -499,7 +497,8 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).toContain('function blankFromStructure(')
expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure')
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
expect(source).toContain("'is-structured': field.type === 'json'")
expect(configuratorValueEditor).toContain('function isStructuredValue(')
expect(configuratorValueEditor).toContain(
@@ -26,6 +26,10 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const openedFolderAppBlocks =
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
const homeFolderIconBlocks =
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
describe('Home folder interaction contract', () => {
it('opens folders from edit mode and starts dragging only after movement', () => {
@@ -36,7 +40,7 @@ describe('Home folder interaction contract', () => {
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
})
it('keeps folder app dragging aligned at every phone scale', () => {
it('keeps whole-folder dragging aligned at every phone scale', () => {
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
expect(dragSource).toContain('viewportWidth: bounds.width')
expect(dragSource).not.toContain("getPropertyValue('zoom')")
@@ -47,6 +51,60 @@ describe('Home folder interaction contract', () => {
)
})
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
expect(homeFolderIconBlocks).toHaveLength(2)
for (const area of ['grid', 'dock'] as const) {
const block = homeFolderIconBlocks.find((candidate) =>
candidate.includes(`data-home-area="${area}"`),
)
expect(block).toBeDefined()
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
expect(block).toContain('@dragcancel="stopHomeDrag"')
expect(block).toContain('@dragend="finishHomeDrag"')
expect(block).toContain('@dragmove="moveHomeDrag"')
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
}
})
it('uses the external viewport visual for apps dragged from an opened folder', () => {
expect(openedFolderAppBlocks).toHaveLength(1)
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
expect(openedFolderAppBlocks[0]).toContain(
':external-drag-visual="externalDragVisual"',
)
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain('app-icon-item--drag-source')
})
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
expect(overlaySource).toMatch(
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
)
expect(overlaySource).toContain("emit('dragstart', index, event)")
expect(overlaySource).toContain("emit('dragmove', event)")
expect(overlaySource).toContain("emit('dragcancel')")
expect(openedFolderAppBlocks[0]).toContain(
'@dragstart="startFolderAppDrag(entry.index, $event)"',
)
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
expect(openedFolderAppBlocks[0]).toContain(
'@dragcancel="stopFolderAppDrag"',
)
})
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
expect(overlaySource).toContain('readPhoneViewportGeometry')
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
expect(overlaySource).toContain('folderDragViewportRect(panel)')
expect(overlaySource).toContain('folderDragViewportRect(element)')
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
expect(overlaySource).not.toContain(
'panelElement.value?.getBoundingClientRect()',
)
expect(overlaySource).not.toContain('document.elementsFromPoint')
})
it('edits the folder name inline with Sky UI controls', () => {
expect(overlaySource).toContain('<SkyField')
expect(overlaySource).toContain('home-folder-heading--editing')
@@ -63,8 +121,9 @@ describe('Home folder interaction contract', () => {
expect(springboardSource).toContain(
'@drag-outside-change="folderDraggingOutside = $event"',
)
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
expect(overlaySource).toContain(
'if (draggingIndex.value === null || draggingOutside.value) return',
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
+92 -26
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Check, Pencil, X } from 'lucide-vue-next'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { usePhoneStore } from '@/stores/phone'
@@ -11,20 +11,35 @@ import {
HOME_FOLDER_PAGE_SIZE,
type HomeFolder,
} from '@/utils/homeLayout'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type FolderAppEntry = {
app: PhoneAppDefinition
index: number
}
const props = defineProps<{
apps: FolderAppEntry[]
editMode: boolean
folder: HomeFolder
renameOnOpen: boolean
}>()
const props = withDefaults(
defineProps<{
apps: FolderAppEntry[]
editMode: boolean
externalDragVisual?: boolean
folder: HomeFolder
renameOnOpen: boolean
}>(),
{
externalDragVisual: false,
},
)
const emit = defineEmits<{
close: []
dragcancel: []
dragmove: [event: PointerEvent]
dragstart: [index: number, event: PointerEvent]
'drag-outside-change': [outside: boolean]
edit: []
extract: [index: number, event: PointerEvent]
@@ -43,6 +58,7 @@ const renameOpened = ref(false)
const renameDraft = ref('')
let pagePointerStart = 0
let pagePointerId: number | null = null
let dragGeometry: PhoneViewportGeometry | null = null
const folderName = computed(
() => props.folder.name || phone.t('Home.folders.defaultName'),
@@ -113,25 +129,48 @@ function saveRename(): void {
renameOpened.value = false
}
function startFolderAppDrag(index: number): void {
function startFolderAppDrag(index: number, event: PointerEvent): void {
draggingIndex.value = index
dragGeometry = readPhoneViewportGeometry(panelElement.value)
setDraggingOutside(false)
emit('dragstart', index, event)
}
function fallbackViewportRect(element: Element): PhoneViewportRect {
const bounds = element.getBoundingClientRect()
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
}
}
function folderDragViewportRect(element: Element): PhoneViewportRect {
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
return geometry?.rect(element) ?? fallbackViewportRect(element)
}
function isPointerInsidePanel(event: PointerEvent): boolean {
const panelBounds = panelElement.value?.getBoundingClientRect()
if (!panelBounds) return false
const panel = panelElement.value
return (
event.clientX >= panelBounds.left &&
event.clientX <= panelBounds.right &&
event.clientY >= panelBounds.top &&
event.clientY <= panelBounds.bottom
panel !== null &&
phoneViewportRectContainsPoint(
folderDragViewportRect(panel),
event.clientX,
event.clientY,
)
)
}
function moveFolderAppDrag(event: PointerEvent): void {
if (draggingIndex.value === null || draggingOutside.value) return
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
if (draggingIndex.value === null) return
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
setDraggingOutside(true)
}
emit('dragmove', event)
}
function setDraggingOutside(outside: boolean): void {
@@ -145,31 +184,52 @@ function finishFolderAppDrag(event: PointerEvent): void {
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
draggingIndex.value = null
if (sourceIndex === null) {
dragGeometry = null
setDraggingOutside(false)
emit('dragcancel')
return
}
if (shouldExtract) {
emit('extract', sourceIndex, event)
dragGeometry = null
setDraggingOutside(false)
return
}
setDraggingOutside(false)
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
.find(
(element) =>
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
)
if (!target) return
const target = Array.from(
panelElement.value?.querySelectorAll<HTMLElement>(
'[data-folder-app-index]',
) ?? [],
).find(
(element) =>
Number(element.dataset.folderAppIndex) !== sourceIndex &&
phoneViewportRectContainsPoint(
folderDragViewportRect(element),
event.clientX,
event.clientY,
),
)
if (!target) {
dragGeometry = null
emit('dragcancel')
return
}
const targetIndex = Number(target.dataset.folderAppIndex)
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
dragGeometry = null
if (!Number.isInteger(targetIndex)) {
emit('dragcancel')
return
}
emit('move', sourceIndex, targetIndex)
}
function stopFolderAppDrag(): void {
const wasDragging = draggingIndex.value !== null
draggingIndex.value = null
dragGeometry = null
setDraggingOutside(false)
if (wasDragging) emit('dragcancel')
}
function goToPage(page: number): void {
@@ -195,6 +255,11 @@ function finishPageSwipe(event: PointerEvent): void {
}
pagePointerId = null
}
onBeforeUnmount(() => {
stopFolderAppDrag()
pagePointerId = null
})
</script>
<template>
@@ -290,10 +355,11 @@ function finishPageSwipe(event: PointerEvent): void {
:app="entry.app"
:data-folder-app-index="entry.index"
:edit-mode="editMode"
:external-drag-visual="externalDragVisual"
@dragcancel="stopFolderAppDrag"
@dragend="finishFolderAppDrag"
@dragmove="moveFolderAppDrag"
@dragstart="startFolderAppDrag(entry.index)"
@dragstart="startFolderAppDrag(entry.index, $event)"
@edit="emit('edit')"
/>
</div>
+8 -2
View File
@@ -46,10 +46,16 @@ describe('fixed server permissions', () => {
expect(configurator).toContain(`["${path}"] = true`)
}
expect(configurator).toContain(
'Phone Configurator enabled: file-based settings from config.lua',
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
)
expect(configurator).toContain(
'(except Config.CommandPermissions) and media.lua are disabled',
'^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
)
expect(configurator).toContain(
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
)
expect(configurator).toContain(
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
)
})
+6 -6
View File
@@ -286,19 +286,19 @@ describe('phone inventory contracts', () => {
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
})
it('opens from a configurable F1 mapping without client-provided device identity', () => {
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
const config = readResourceFile('config/config.lua')
const phoneClient = readResourceFile('source/client/main.lua')
const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"')
expect(phoneClient).toContain('local phone_key_mapping_registered = false')
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
expect(phoneClient).toContain(
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
)
expect(phoneClient).toContain(
'if active_key_mapping_command == command_name then',
expect(phoneClient).toMatch(
/RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
)
expect(phoneClient).not.toContain('sky_phone_toggle_config_')
expect(phoneClient).not.toContain('key_mapping_revision')
expect(phoneClient).toContain(
'request_phone_open("sky_phone:device:open-request")',
)
+35
View File
@@ -474,6 +474,41 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
})
it('materializes a trailing page only for a successful folder extraction', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
settingsIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
const originalPageCount = apps.homeLayout.pageCount
const targetPage = originalPageCount + 1
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
expect(
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
).toBe(false)
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
true,
)
expect(apps.homeLayout.pageCount).toBe(targetPage)
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('does not commit an installation to a different phone', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
+12 -2
View File
@@ -19,6 +19,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
extractHomeFolderApp,
HOME_GRID_PAGE_SIZE,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
@@ -519,14 +520,23 @@ export const useAppStoreStore = defineStore('app-store', {
to: HomeArea,
targetIndex: number,
): boolean {
let sourceLayout = this.homeLayout
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
while (sourceLayout.pageCount < targetPage) {
const expanded = addHomePage(sourceLayout)
if (expanded === sourceLayout) return false
sourceLayout = expanded
}
}
const next = extractHomeFolderApp(
this.homeLayout,
sourceLayout,
folderId,
sourceIndex,
to,
targetIndex,
)
if (next === this.homeLayout) return false
if (next === sourceLayout) return false
this.homeLayout = next
this.persist()
return true
+4 -2
View File
@@ -2712,6 +2712,8 @@ const defaultLocales: LocaleTree = {
recipient_not_found: 'That number is unavailable.',
blocked: 'This contact has blocked calls and messages from your SIM.',
messaging_unavailable: 'This company contact does not accept messages.',
service_line_text_only:
'Company service lines currently accept text messages only.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
request_failed: 'Messages are temporarily unavailable.',
@@ -4523,8 +4525,8 @@ const defaultLocales: LocaleTree = {
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
focusHelp: 'Hold the look key to look around',
returnHelp: 'Release the look key for controls',
uploading: '{count} uploading',
saving: 'Saving video...',
openGallery: 'Open Photos',
+1
View File
@@ -138,6 +138,7 @@ export type AdminConfiguratorStructure =
template?: AdminConfiguratorStructure
}
| {
entryDefault?: unknown
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
mutableKeys?: boolean
+11
View File
@@ -43,6 +43,17 @@ describe('SkyNavbar', () => {
expect(html).toContain('Account')
})
it('constrains long compact titles to the center column', () => {
const titleRule = foundationStyles.match(
/\.sky-navbar__title\s*\{([^}]*)\}/s,
)?.[1]
expect(titleRule).toContain('max-width: 100%')
expect(titleRule).toContain('overflow: hidden')
expect(titleRule).toContain('text-overflow: ellipsis')
expect(titleRule).toContain('white-space: nowrap')
})
it('exposes the large-title header without changing heading semantics', async () => {
const html = await renderToString(
createSSRApp(SkyNavbar, {
+1
View File
@@ -168,6 +168,7 @@
.sky-navbar__title {
min-width: 0;
max-width: 100%;
margin: 0;
padding: 0 var(--sky-space-1);
overflow: hidden;
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import type { AdminConfiguratorStructure } from '@/types/admin'
import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
describe('admin configurator defaults', () => {
it('creates a usable company draft from its key and the server defaults', () => {
const entryDefault = {
AcceptsRequests: true,
Category: 'public_services',
Job: '',
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
Name: '',
ServiceLine: {
Number: '500',
Routing: 'round_robin',
},
Services: [
{
Description: '',
Id: '',
Price: '',
RequestsEnabled: true,
Title: '',
},
],
}
const structure: AdminConfiguratorStructure = {
entryDefault,
fields: {},
kind: 'table',
mutableKeys: true,
template: {
fields: {
Job: { kind: 'value', valueType: 'string' },
Name: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
expect(
createMutableTableEntry(
structure,
'Companies.Definitions',
'pizza_palace',
),
).toEqual({
AcceptsRequests: true,
Category: 'public_services',
Job: 'pizza_palace',
LogoUrl: 'https://picsum.photos/seed/companies-pizza_palace-logo/180/180',
Name: 'Pizza Palace',
ServiceLine: {
Number: '500',
Routing: 'round_robin',
},
Services: [
{
Description: '',
Id: 'pizza_palace',
Price: '',
RequestsEnabled: true,
Title: 'Pizza Palace',
},
],
})
expect(entryDefault).toMatchObject({
Job: '',
Name: '',
Services: [{ Id: '', Title: '' }],
})
})
it('allocates distinct service numbers for multiple unsaved companies', () => {
const structure: AdminConfiguratorStructure = {
entryDefault: {
Job: '',
LogoUrl: '',
Name: '',
ServiceLine: { Number: '500' },
},
fields: {},
kind: 'table',
mutableKeys: true,
}
const existing = {
pizzeria: { ServiceLine: { Number: '500' } },
restaurant: { ServiceLine: { Number: '501' } },
}
expect(
createMutableTableEntry(
structure,
'Companies.Definitions',
'bakery',
existing,
),
).toMatchObject({ ServiceLine: { Number: '502' } })
})
it('keeps generic mutable tables on their schema-derived blank value', () => {
const structure: AdminConfiguratorStructure = {
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
expect(createMutableTableEntry(structure, 'FeatureFlags', 'example')).toBe(
false,
)
})
})
@@ -0,0 +1,142 @@
import type { AdminConfiguratorStructure } from '@/types/admin'
function cloneConfiguratorValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneConfiguratorValue)
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
cloneConfiguratorValue(child),
]),
)
}
return value
}
function blankScalar(valueType: 'boolean' | 'number' | 'string'): unknown {
if (valueType === 'boolean') return false
if (valueType === 'number') return 0
return ''
}
export function blankFromConfiguratorStructure(
structure: AdminConfiguratorStructure,
): unknown {
if (structure.kind === 'optionalString') return ''
if (structure.kind === 'value') return blankScalar(structure.valueType)
if (structure.kind === 'vector') {
const axes = ['x', 'y', 'z', 'w'].slice(
0,
Number(structure.vectorType.slice(-1)),
)
return Object.fromEntries([
['__skyType', structure.vectorType],
...axes.map((axis) => [axis, 0]),
])
}
if (structure.kind === 'list') {
return structure.items.map(blankFromConfiguratorStructure)
}
if (structure.kind === 'map') {
return {
__skyType: 'map',
entries: structure.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: blankFromConfiguratorStructure(entry.structure),
})),
}
}
return Object.fromEntries(
Object.entries(structure.fields).map(([key, field]) => [
key,
blankFromConfiguratorStructure(field),
]),
)
}
export function createMutableTableEntry(
structure: Extract<AdminConfiguratorStructure, { kind: 'table' }>,
path: string,
key: string,
entries: Record<string, unknown> = {},
): unknown {
const value =
structure.entryDefault !== undefined
? cloneConfiguratorValue(structure.entryDefault)
: structure.template
? blankFromConfiguratorStructure(structure.template)
: undefined
if (
path !== 'Companies.Definitions' ||
value === null ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return value
}
const company = value as Record<string, unknown>
company.Job = key
company.Name = key
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
company.LogoUrl = `https://picsum.photos/seed/companies-${key}-logo/180/180`
if (Array.isArray(company.Services)) {
if (company.Services.length === 0) {
company.Services.push({
Description: '',
Id: key,
Price: '',
RequestsEnabled: true,
Title: company.Name,
})
} else {
const service = company.Services[0]
if (
service !== null &&
typeof service === 'object' &&
!Array.isArray(service) &&
!(service as Record<string, unknown>).Id
) {
const mutableService = service as Record<string, unknown>
mutableService.Id = key
mutableService.Title = company.Name
}
}
}
const serviceLine = company.ServiceLine
if (
serviceLine !== null &&
typeof serviceLine === 'object' &&
!Array.isArray(serviceLine)
) {
const mutableServiceLine = serviceLine as Record<string, unknown>
const number = String(mutableServiceLine.Number ?? '')
const numeric = Number(number)
if (/^\d+$/.test(number) && Number.isSafeInteger(numeric) && numeric > 0) {
const used = new Set(
Object.values(entries).map((entry) => {
if (entry === null || typeof entry !== 'object') return ''
const line = (entry as Record<string, unknown>).ServiceLine
if (line === null || typeof line !== 'object') return ''
return String((line as Record<string, unknown>).Number ?? '').replace(
/\D/g,
'',
)
}),
)
const maximum = 10 ** number.length - 1
for (let offset = 0; offset < maximum; offset += 1) {
const candidate = ((numeric - 1 + offset) % maximum) + 1
const formatted = String(candidate).padStart(number.length, '0')
if (!used.has(formatted)) {
mutableServiceLine.Number = formatted
break
}
}
}
}
return company
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
@@ -63,7 +64,78 @@ function createGeometryFixture(options: {
}
}
const PHONE_BASE_ZOOM = 0.69 * 1.2
const PHONE_HEIGHT = 844
const PHONE_WIDTH = 390
const REFERENCE_VIEWPORT_HEIGHT = 1080
const REFERENCE_VIEWPORT_WIDTH = 1920
const displayCases = [
{ height: 1080, label: '1080p 16:9', width: 1920 },
{ height: 2160, label: '4K 16:9', width: 3840 },
{ height: 1440, label: '21:9', width: 3440 },
{ height: 1440, label: '32:9', width: 5120 },
{ height: 1600, label: '16:10', width: 2560 },
] as const
const phoneScales = [80, 100, 120] as const
function productionPhoneZoom(
viewportWidth: number,
viewportHeight: number,
phoneScale: number,
): number {
const viewportScale = Math.min(
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
)
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
const edgeGap = 24 * viewportScale
const viewportMaximum = Math.max(
0,
Math.min(
(viewportWidth - edgeGap) / PHONE_WIDTH,
(viewportHeight - edgeGap) / PHONE_HEIGHT,
),
)
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
}
const cefDisplayMatrix = displayCases.flatMap((display) =>
phoneScales.map((phoneScale) => ({ display, phoneScale })),
)
describe('phone viewport geometry', () => {
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
const rect: PhoneViewportRect = {
bottom: 260,
height: 160,
left: 120,
right: 360,
top: 100,
width: 240,
}
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
true,
)
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
false,
)
})
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
@@ -124,6 +196,76 @@ describe('phone viewport geometry', () => {
},
)
it.each(cefDisplayMatrix)(
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
({ display, phoneScale }) => {
const viewportScale = Math.min(
display.width / REFERENCE_VIEWPORT_WIDTH,
display.height / REFERENCE_VIEWPORT_HEIGHT,
)
const zoom = productionPhoneZoom(
display.width,
display.height,
phoneScale,
)
const edgeGap = 24 * viewportScale
const wrapper = measuredRect(
display.width - edgeGap - PHONE_WIDTH * zoom,
display.height - edgeGap - PHONE_HEIGHT * zoom,
PHONE_WIDTH * zoom,
PHONE_HEIGHT * zoom,
)
// Live CEF 103 can report the zoomed canvas in a different coordinate
// space from pointer events. Preserve that mismatch in this fixture.
const rawCanvas = measuredRect(
display.width + 117.375,
83.625,
PHONE_WIDTH,
PHONE_HEIGHT,
)
const rawPanel = measuredRect(
rawCanvas.left + 23.25,
rawCanvas.top + 246.75,
343.5,
324.25,
)
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
const expected = {
bottom: wrapper.top + (246.75 + 324.25) * zoom,
height: 324.25 * zoom,
left: wrapper.left + 23.25 * zoom,
right: wrapper.left + (23.25 + 343.5) * zoom,
top: wrapper.top + 246.75 * zoom,
width: 343.5 * zoom,
}
expectRectClose(panel, expected)
expect(
phoneViewportRectContainsPoint(
panel,
panel.left + panel.width / 2,
panel.top + panel.height / 2,
),
).toBe(true)
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
true,
)
expect(
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
).toBe(true)
expect(
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
).toBe(false)
expect(
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
).toBe(false)
expect(panel.left).toBeGreaterThanOrEqual(0)
expect(panel.right).toBeLessThanOrEqual(display.width)
expect(panel.top).toBeGreaterThanOrEqual(0)
expect(panel.bottom).toBeLessThanOrEqual(display.height)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
@@ -7,6 +7,14 @@ export type PhoneViewportRect = {
width: number
}
export function phoneViewportRectContainsPoint(
rect: PhoneViewportRect,
x: number,
y: number,
): boolean {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
+5 -1
View File
@@ -30,7 +30,11 @@ const launchStyle = computed(() => {
<div
v-if="app && !app.adminOnly"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:class="{
'app-window--camera-landscape':
app.id === 'camera' && phone.cameraLandscape,
'app-window--citywarn': app.id === 'citywarn',
}"
:style="launchStyle"
>
<CustomAppFrame
@@ -31,6 +31,22 @@ const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
const folderOverlayBlock =
viewSource.match(/<HomeFolderOverlay\b[\s\S]*?\/>/)?.[0] ?? ''
function handlerName(block: string, event: string): string {
return (
block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
)
}
function functionSource(name: string): string {
if (!name) return ''
const start = viewSource.indexOf(`function ${name}(`)
if (start < 0) return ''
const next = viewSource.indexOf('\nfunction ', start + 1)
return viewSource.slice(start, next < 0 ? viewSource.length : next)
}
describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
@@ -136,7 +152,7 @@ describe('Springboard page swipe contract', () => {
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(4)
).toHaveLength(5)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
@@ -160,6 +176,65 @@ describe('Springboard page swipe contract', () => {
)
})
it('owns one external drag session for an app leaving an opened folder', () => {
expect(folderOverlayBlock).toContain(
':external-drag-visual="homeDragVisualActive"',
)
const startSource = functionSource(
handlerName(folderOverlayBlock, 'dragstart'),
)
const moveSource = functionSource(
handlerName(folderOverlayBlock, 'dragmove'),
)
const cancelSource = functionSource(
handlerName(folderOverlayBlock, 'dragcancel'),
)
expect(startSource).toContain('createHomeDragGhost(event)')
expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
expect(moveSource).toContain('updateHomeDragGhost(event)')
expect(moveSource).toContain('folderDraggingOutside.value')
expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
expect(viewSource).toContain(
'(draggingHomeApp.value || draggingFolderApp.value)',
)
expect(cancelSource).toContain('clearHomeDragGhost()')
expect(cancelSource).toContain('clearTemporaryHomePage()')
})
it('settles an extracted folder app from the tracked viewport preview', () => {
const extractSource = functionSource('extractOpenedFolderApp')
const targetSource = functionSource('folderExtractionDropTarget')
expect(extractSource).toContain('const dropGhost = homeDragGhost')
expect(extractSource).toContain('homeDragPreviewBounds')
expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
expect(extractSource).not.toContain('sourceElement')
expect(extractSource).not.toContain('getBoundingClientRect()')
expect(extractSource).toMatch(
/animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
)
expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
expect(extractSource).toMatch(
/if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
const moveSource = functionSource('moveOpenedFolderApp')
const cancelSource = functionSource(
handlerName(folderOverlayBlock, 'dragcancel'),
)
expect(moveSource).toContain('stopOpenedFolderAppDrag()')
expect(cancelSource).toContain('clearHomeDragGhost()')
expect(viewSource).toMatch(
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
+236 -58
View File
@@ -42,6 +42,7 @@ import {
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
@@ -111,6 +112,11 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const draggingFolderApp = ref<{
appId: LaunchablePhoneAppId
folderId: string
sourceIndex: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
@@ -610,7 +616,11 @@ function queueEdgePageTurn(
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = dragType === 'widget'
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
if (
dragType === 'app' &&
lastHomePointer &&
(draggingHomeApp.value || draggingFolderApp.value)
) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
@@ -814,7 +824,7 @@ function updateHomeDragGhost(event: {
)
}
function createHomeDragGhost(event: PointerEvent): void {
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
@@ -827,7 +837,7 @@ function createHomeDragGhost(event: PointerEvent): void {
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
const geometry = readPhoneViewportGeometry(source ?? null)
if (!source || !layer || !springboard || !portal || !geometry) return
if (!source || !layer || !springboard || !portal || !geometry) return null
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
@@ -838,7 +848,7 @@ function createHomeDragGhost(event: PointerEvent): void {
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return
return null
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
@@ -865,7 +875,12 @@ function createHomeDragGhost(event: PointerEvent): void {
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
if (
attribute.startsWith('data-home-') ||
attribute.startsWith('data-folder-')
) {
element.removeAttribute(attribute)
}
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
@@ -894,6 +909,7 @@ function createHomeDragGhost(event: PointerEvent): void {
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
return position
}
function startHomeDrag(
@@ -1190,6 +1206,98 @@ function nearestDockDropTarget(event: {
return closestIndex === null ? null : slots[closestIndex]
}
function folderExtractionDropTarget(event: {
clientX: number
clientY: number
}):
| { area: 'dock'; index: number }
| { area: 'grid'; index: number; page: number }
| null {
const geometry = homeDragGeometry
if (!geometry) return null
const dock = document.querySelector<HTMLElement>('.app-dock')
if (dock) {
const dockBounds = homeDragViewportRect(dock, geometry)
if (
phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
) {
const slots = Array.from(
dock.querySelectorAll<HTMLElement>(
'[data-home-area="dock"][data-home-index]',
),
).filter((slot) => {
const index = Number(slot.dataset.homeIndex)
return (
Number.isInteger(index) && appStore.homeLayout.dock[index] === null
)
})
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => homeDragViewportRect(slot, geometry)),
)
if (closestIndex === null) return null
const index = Number(slots[closestIndex]?.dataset.homeIndex)
return Number.isInteger(index) ? { area: 'dock', index } : null
}
}
const page = phone.currentPage
if (page < 1 || page > appPages.value.length) return null
const grid = document.querySelector<HTMLElement>(
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
const rawGridBounds = homeDragViewportRect(grid, geometry)
const gridBounds = viewportRectAt(
springboardBounds.left + (rawGridBounds.left - pageBounds.left),
springboardBounds.top + (rawGridBounds.top - pageBounds.top),
rawGridBounds.width,
rawGridBounds.height,
)
if (
!phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
) {
return null
}
const slots = Array.from(
grid.querySelectorAll<HTMLElement>(
'[data-home-area="grid"][data-home-index]',
),
).filter((slot) => {
const index = Number(slot.dataset.homeIndex)
const item = appStore.homeLayout.grid[index]
return (
Number.isInteger(index) &&
(item === null ||
(item === undefined && page > appStore.homeLayout.pageCount))
)
})
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => {
const bounds = homeDragViewportRect(slot, geometry)
return {
height: bounds.height,
left: springboardBounds.left + (bounds.left - pageBounds.left),
top: springboardBounds.top + (bounds.top - pageBounds.top),
width: bounds.width,
}
}),
)
if (closestIndex === null) return null
const index = Number(slots[closestIndex]?.dataset.homeIndex)
return Number.isInteger(index) ? { area: 'grid', index, page } : null
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
@@ -1324,16 +1432,83 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
return apps
}
function currentOpenedFolderDragSession(sourceIndex?: number) {
const session = draggingFolderApp.value
const folder = openedFolder.value
if (
!session ||
!folder ||
folder.id !== session.folderId ||
(sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
folder.apps[session.sourceIndex] !== session.appId
) {
return null
}
return session
}
function startOpenedFolderAppDrag(
sourceIndex: number,
event: PointerEvent,
): void {
stopOpenedFolderAppDrag()
const folder = openedFolder.value
if (
!folder ||
!Number.isInteger(sourceIndex) ||
sourceIndex < 0 ||
sourceIndex >= folder.apps.length
) {
return
}
const appId = folder.apps[sourceIndex]
if (!appId || !installedAppsById.value.has(appId)) return
if (!createHomeDragGhost(event)) {
clearHomeDragGhost()
return
}
draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
}
function moveOpenedFolderAppDrag(event: PointerEvent): void {
if (!currentOpenedFolderDragSession()) {
stopOpenedFolderAppDrag()
return
}
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
queueEdgePageTurn(event, 'app')
return
}
clearEdgePageTurn()
}
function stopOpenedFolderAppDrag(): void {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
function resetOpenedFolderState(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
function openFolder(folderId: string): void {
stopOpenedFolderAppDrag()
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
stopOpenedFolderAppDrag()
resetOpenedFolderState()
}
function renameOpenedFolder(name: string): void {
@@ -1342,71 +1517,68 @@ function renameOpenedFolder(name: string): void {
}
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
if (!openedFolderId.value) return
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
const session = currentOpenedFolderDragSession(sourceIndex)
stopOpenedFolderAppDrag()
if (!session) return
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
}
function extractOpenedFolderApp(
sourceIndex: number,
event: PointerEvent,
): void {
const folder = openedFolder.value
if (!folder) return
const appId = folder.apps[sourceIndex]
const sourceElement = document
.querySelector<HTMLElement>(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
const area = element.dataset.homeArea as HomeArea
const index = Number(element.dataset.homeIndex)
return (
(area === 'grid' || area === 'dock') &&
Number.isInteger(index) &&
appStore.homeLayout[area][index] === null
)
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
)
const closestDistance = Math.hypot(
event.clientX - (closestBounds.left + closestBounds.width / 2),
event.clientY - (closestBounds.top + closestBounds.height / 2),
)
return distance < closestDistance ? candidate : closest
}, null)
if (!target) {
const session = currentOpenedFolderDragSession(sourceIndex)
if (!session) {
closeFolder()
return
}
updateHomeDragGhost(event)
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
if (!dropGhost || !dropOrigin) {
closeFolder()
return
}
const target = folderExtractionDropTarget(event)
if (!target) {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
resetOpenedFolderState()
return
}
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
const { area, index: targetIndex } = target
if (
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
!appStore.extractHomeFolderApp(
session.folderId,
session.sourceIndex,
area,
targetIndex,
)
) {
closeFolder()
return
}
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
}
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearTemporaryHomePage(area === 'grid')
resetOpenedFolderState()
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
if (extractedIndex === null) {
clearHomeDragGhost(dropGhost)
return
}
void animateHomeItemDrop(
session.appId,
area,
extractedIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
}
function clearSearch(): void {
@@ -1426,11 +1598,12 @@ watch(isEditablePage, (visible) => {
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopOpenedFolderAppDrag()
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
if (!folder) closeFolder()
if (!folder && openedFolderId.value !== null) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
@@ -1445,6 +1618,7 @@ onBeforeUnmount(() => {
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingFolderApp.value = null
draggingWidgetId.value = null
})
</script>
@@ -1858,9 +2032,13 @@ onBeforeUnmount(() => {
v-if="openedFolder"
:apps="openedFolderApps"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
@dragcancel="stopOpenedFolderAppDrag"
@dragmove="moveOpenedFolderAppDrag"
@dragstart="startOpenedFolderAppDrag"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"
@@ -6,6 +6,15 @@ const cameraView = readFileSync(
new URL('./CameraApp.vue', import.meta.url),
'utf8',
)
const appShell = readFileSync(new URL('../../App.vue', import.meta.url), 'utf8')
const appWindow = readFileSync(
new URL('../PhoneAppWindow.vue', import.meta.url),
'utf8',
)
const shellStyles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync(
new URL('../../components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
@@ -47,6 +56,99 @@ describe('Camera app controls', () => {
expect(cameraView).not.toContain('k-navbar')
})
it('stays dark when the global phone appearance is light', () => {
const cameraPageTag = cameraView.match(/<sky-app-page\b[^>]*>/s)?.[0]
expect(cameraPageTag).toBeDefined()
expect(cameraPageTag).toMatch(/\bdark\b/)
expect(cameraView).toMatch(
/\.camera-page\s*\{[^}]*--sky-bg:\s*#000;[^}]*--sky-text:\s*#fff;[^}]*background:\s*var\(--sky-bg\);[^}]*color:\s*var\(--sky-text\);/s,
)
})
it('fills the translucent landscape shell without letterbox gaps', () => {
const landscapeViewport = cameraView.match(
/\.camera-page--landscape \.camera-viewport\s*\{([^}]*)\}/s,
)?.[1]
expect(cameraView).toMatch(
/\.camera-page--landscape\s*\{[^}]*--sky-bg:\s*rgba\(0, 0, 0, 0\.42\);/s,
)
expect(landscapeViewport).toBeDefined()
expect(landscapeViewport).toContain('top: 50%')
expect(landscapeViewport).toContain(
'width: calc(100% * var(--phone-screen-portrait-ratio))',
)
expect(landscapeViewport).toContain(
'aspect-ratio: var(--phone-screen-portrait-ratio)',
)
expect(landscapeViewport).not.toContain('16 / 9')
expect(cameraView).toMatch(
/\.camera-page--landscape \.camera-shade\s*\{[^}]*linear-gradient\([^)]*90deg,[^)]*rgba\(0, 0, 0, 0\.42\) 0 18%,[^)]*transparent 18% 75%,[^)]*rgba\(0, 0, 0, 0\.42\) 75% 100%/s,
)
expect(appShell).toMatch(
/'phone-screen--camera-landscape':\s*activeAppId === 'camera' && phone\.cameraLandscape/,
)
expect(appWindow).toMatch(
/'app-window--camera-landscape':\s*app\.id === 'camera' && phone\.cameraLandscape/,
)
expect(shellStyles).toMatch(
/\.phone-screen\s*\{[^}]*--phone-screen-portrait-ratio:\s*2\.30951;/s,
)
expect(shellStyles).toMatch(
/\.phone-screen--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
)
expect(shellStyles).toMatch(
/\.phone-screen--camera-landscape \.springboard\s*\{[^}]*visibility:\s*hidden;/s,
)
expect(shellStyles).toMatch(
/\.app-window--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
)
})
it('renders camera notices as plain text without a glass pill', () => {
const noticeMarkup = cameraView.slice(
cameraView.indexOf('<span v-if="noticeText"'),
cameraView.indexOf('<span v-else-if="pendingCount"'),
)
const noticeStyles = cameraView.match(
/\.camera-notice-text\s*,[\s\S]*?\{([^}]*)\}/,
)?.[1]
expect(noticeMarkup).toContain('class="camera-notice-text"')
expect(noticeMarkup).not.toContain('camera-focus-pill')
expect(noticeStyles).toBeDefined()
expect(noticeStyles).toContain('color: #ffd60a')
expect(noticeStyles).not.toMatch(
/(?:background|backdrop-filter|border-radius|padding)\s*:/,
)
})
it('uses the current compact lens selector treatment', () => {
const zoomControl = cameraView.slice(
cameraView.indexOf('<div class="camera-zoom-control">'),
cameraView.indexOf('<footer class="camera-controls">'),
)
expect(zoomControl).toContain('variant="plain"')
expect(zoomControl).not.toContain('glass')
expect(zoomControl).toContain('{{ zoomPresetLabel(zoom) }}')
expect(cameraView).toContain("return zoom === 0.5 ? '.5' : `${zoom}`")
expect(cameraView).toMatch(
/\.camera-zoom-control\s*\{[^}]*bottom:\s*216px;/s,
)
expect(cameraView).toMatch(/\.camera-zoom-row\s*\{[^}]*gap:\s*10px;/s)
expect(cameraView).toMatch(
/\.camera-zoom-pill\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*min-height:\s*36px;[^}]*padding:\s*0;/s,
)
expect(cameraView).toMatch(
/\.camera-zoom-pill::before\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*inset-block:\s*-4px;/s,
)
expect(cameraView).toMatch(
/\.camera-zoom-pill\.active\s*\{[^}]*color:\s*#ffd60a;[^}]*background:\s*rgba\(28, 28, 30, 0\.78\);/s,
)
})
it('keeps continuous wheel zoom without an extra slider bar', () => {
expect(cameraView).not.toContain('camera-zoom-slider')
expect(cameraView).not.toContain('type="range"')
@@ -77,7 +179,8 @@ describe('Camera app controls', () => {
it('locks look controls without changing the global gameplay camera', () => {
expect(cameraView).toContain("nuiCall('camera:setLocked'")
expect(cameraView).toContain('cameraLocked.value')
expect(cameraView).toContain('Apps.camera.spaceKey')
expect(cameraView).toContain('Apps.camera.lookKey')
expect(cameraView).not.toContain('Apps.camera.spaceKey')
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
expect(cameraClient).toContain('INPUT_LOOK_LR')
expect(cameraClient).toContain('INPUT_LOOK_UD')
@@ -95,10 +198,26 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
})
it('keeps the selfie camera stable while Space still allows movement', () => {
expect(cameraView).toMatch(
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/,
it('uses the configured HoldToLook control and Space in camera modes', () => {
expect(cameraView).not.toContain("event.code !== 'Space'")
expect(cameraView).not.toContain("window.addEventListener('keydown'")
expect(focusClient).toContain(
'function SkyPhoneFocus.IsHoldToLookPressed()',
)
expect(focusClient).toContain(
'IsDisabledControlPressed(0, hold_to_look_control)',
)
expect(cameraClient).toContain('SkyPhoneFocus.IsHoldToLookPressed()')
expect(cameraClient).toContain(
'IsDisabledControlPressed(0, camera_passthrough_control)',
)
expect(cameraClient).toMatch(
/if data\.active then\s+watch_camera_controls\(\)/,
)
expect(cameraClient).not.toContain('IsDisabledControlJustReleased(0, 22)')
})
it('orbits the stable selfie camera while HoldToLook allows movement', () => {
expect(cameraClient).toContain(
'if camera_state.locked or camera_state.front_camera then',
)
@@ -106,14 +225,22 @@ describe('Camera app controls', () => {
expect(cameraClient).toContain('local front_camera_view_mode = 0')
expect(cameraClient).toContain('local front_camera_fov = 32.0')
expect(cameraClient).toContain('local front_camera_distance = 1.05')
expect(cameraClient).toContain('local front_camera_horizontal_limit = 75.0')
expect(cameraClient).toContain('local front_camera_vertical_limit = 35.0')
expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
expect(cameraClient).toContain(
'local dot = (to_camera.x * forward_vector.x)',
)
expect(cameraClient).toContain('GetDisabledControlNormal(0, 1)')
expect(cameraClient).toContain('GetDisabledControlNormal(0, 2)')
expect(cameraClient).toContain('update_front_camera_orbit()')
expect(cameraClient).toContain('camera_state.front_camera_yaw')
expect(cameraClient).toContain('camera_state.front_camera_pitch')
expect(cameraClient).toContain('front_camera_target_height')
expect(focusClient).toContain(
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
)
expect(focusClient).toContain(
'return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }',
)
expect(focusClient).toContain('gameInput = focus.game_input')
expect(cameraClient).toContain('SetCamCoord(')
expect(cameraClient).toContain('PointCamAtCoord(')
expect(cameraClient).not.toContain('SetCamRot(')
@@ -121,10 +248,6 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('front_camera_position')
expect(cameraClient).not.toContain('AttachCamToEntity(')
expect(cameraClient).not.toContain('PointCamAtEntity(')
expect(cameraView).toContain("window.addEventListener('keyup', onKeyup)")
expect(cameraView).toContain(
"nuiCall('camera:setFocus', { focused: true })",
)
})
it('uses a looping camera-hold pose instead of the old selfie dance', () => {
+67 -65
View File
@@ -56,7 +56,6 @@ const microphoneEnabled = ref(true)
const frontCamera = ref(false)
const shutterActive = ref(false)
const cameraLocked = ref(false)
const movementEnabled = ref(false)
const recording = ref(false)
const savingVideo = ref(false)
const recordingStartedAt = ref(0)
@@ -78,6 +77,16 @@ const pendingCount = computed(
() =>
captures.value.filter((capture) => capture.status === 'uploading').length,
)
function zoomPresetIsActive(zoom: (typeof zoomLevels)[number]): boolean {
return Math.abs(selectedZoom.value - zoom) < 0.03
}
function zoomPresetLabel(zoom: (typeof zoomLevels)[number]): string {
if (zoomPresetIsActive(zoom)) return `${zoom}x`
return zoom === 0.5 ? '.5' : `${zoom}`
}
function correlationId(): string {
return `${Date.now()}-${crypto.randomUUID()}`
}
@@ -238,10 +247,6 @@ async function toggleFacing(): Promise<void> {
async function toggleCameraLock(): Promise<void> {
cameraLocked.value = !cameraLocked.value
if (cameraLocked.value && movementEnabled.value) {
movementEnabled.value = false
await nuiCall('camera:setFocus', { focused: true })
}
await nuiCall('camera:setLocked', { locked: cameraLocked.value })
}
@@ -340,26 +345,6 @@ function updateRecordingTimer(): void {
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
}
function onKeydown(event: KeyboardEvent): void {
if (
event.code !== 'Space' ||
event.repeat ||
cameraLocked.value ||
movementEnabled.value
)
return
event.preventDefault()
movementEnabled.value = true
void nuiCall('camera:setFocus', { focused: false })
}
function onKeyup(event: KeyboardEvent): void {
if (event.code !== 'Space' || !movementEnabled.value) return
event.preventDefault()
movementEnabled.value = false
void nuiCall('camera:setFocus', { focused: true })
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as {
@@ -442,8 +427,6 @@ onMounted(() => {
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
'*',
)
window.addEventListener('keydown', onKeydown)
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
void nuiCall<MediaConfig>('media:config').then((response) => {
@@ -459,13 +442,7 @@ onBeforeUnmount(() => {
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('keyup', onKeyup)
window.removeEventListener('message', onMessage)
if (movementEnabled.value) {
movementEnabled.value = false
void nuiCall('camera:setFocus', { focused: true })
}
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
resizeObserver?.disconnect()
gameView?.dispose()
@@ -486,6 +463,7 @@ onBeforeUnmount(() => {
class="camera-page"
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
:aria-label="phone.t('Apps.camera.name')"
dark
>
<div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel">
<canvas
@@ -559,10 +537,7 @@ onBeforeUnmount(() => {
</template>
</sky-fab>
</div>
<span
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
>
<span v-if="noticeText" class="camera-notice-text">
{{ noticeText }}
</span>
<span v-else-if="pendingCount" class="camera-upload-pill">
@@ -587,7 +562,7 @@ onBeforeUnmount(() => {
>
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
<kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
</SkyButton>
<sky-fab
component="button"
@@ -618,16 +593,16 @@ onBeforeUnmount(() => {
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
variant="plain"
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
:class="{ active: zoomPresetIsActive(zoom) }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
:aria-pressed="zoomPresetIsActive(zoom)"
@click="setZoom(zoom)"
>
{{ zoom }}x
{{ zoomPresetLabel(zoom) }}
</SkyButton>
</div>
</div>
@@ -718,10 +693,15 @@ onBeforeUnmount(() => {
<style scoped>
.camera-page {
--sky-bg: #000;
--sky-text: #fff;
position: relative;
overflow: clip;
background: #000;
color: #fff;
background: var(--sky-bg);
color: var(--sky-text);
}
.camera-page--landscape {
--sky-bg: rgba(0, 0, 0, 0.42);
}
.camera-viewport {
position: absolute;
@@ -733,11 +713,11 @@ onBeforeUnmount(() => {
transform: translateY(-50%);
}
.camera-page--landscape .camera-viewport {
top: 46%;
top: 50%;
left: 50%;
width: calc(100% * 16 / 9);
width: calc(100% * var(--phone-screen-portrait-ratio));
height: auto;
aspect-ratio: 16 / 9;
aspect-ratio: var(--phone-screen-portrait-ratio);
transform: translate(-50%, -50%) rotate(90deg);
}
.camera-game-view,
@@ -795,6 +775,16 @@ onBeforeUnmount(() => {
pointer-events: none;
background: linear-gradient(#0008, transparent 22%);
}
.camera-page--landscape .camera-shade {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.42) 0 18%,
transparent 18% 75%,
rgba(0, 0, 0, 0.42) 75% 100%
),
linear-gradient(#0008, transparent 22%);
}
.camera-flash {
z-index: 8;
pointer-events: none;
@@ -855,25 +845,22 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
padding: 7px 10px;
overflow: hidden;
border-radius: 999px;
background: #0006;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-notice-text,
.camera-upload-pill {
min-width: 0;
color: #ffd60a;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-upload-pill {
color: #ffd60a;
}
.camera-focus-pill--notice {
color: #ffd60a;
}
.camera-lock-control {
min-height: 44px;
@@ -924,7 +911,7 @@ onBeforeUnmount(() => {
.camera-zoom-control {
position: absolute;
z-index: 4;
bottom: 196px;
bottom: 216px;
left: 50%;
width: auto;
transform: translateX(-50%);
@@ -932,16 +919,19 @@ onBeforeUnmount(() => {
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 8px;
gap: 10px;
}
.camera-zoom-pill {
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
border: 0;
color: #fff;
font-size: 10px;
background: transparent;
font-size: 12px;
font-weight: 500;
text-align: center;
transition:
background-color 0.2s ease,
@@ -949,8 +939,20 @@ onBeforeUnmount(() => {
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.camera-zoom-pill::before {
width: var(--sky-touch-target, 44px);
inset-block: -4px;
}
.camera-zoom-pill.active {
color: #ffd60a;
background: rgba(28, 28, 30, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.camera-zoom-pill:active:not(:disabled) {
background: rgba(28, 28, 30, 0.42);
}
.camera-zoom-pill.active:active:not(:disabled) {
background: rgba(28, 28, 30, 0.86);
}
.camera-controls {
position: absolute;
@@ -40,6 +40,10 @@ describe('CityWarn product contract', () => {
})
it('reserves the pill navigation, keeps sheets safe and avoids blur flicker', () => {
expect(source).toMatch(
/\.citywarn-scroll\s*\{[^}]*min-height:\s*0;[^}]*height:\s*auto;[^}]*flex:\s*1 1 0;[^}]*overflow-y:\s*auto;/s,
)
expect(source).not.toMatch(/\.citywarn-scroll\s*\{[^}]*height:\s*100%;/s)
expect(source).toMatch(
/\.citywarn-scroll\.sky-scroll-area--tabbar\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 84px\)/s,
)
@@ -56,6 +60,51 @@ describe('CityWarn product contract', () => {
expect(source).not.toContain('<span>{{ alert.title }}</span>')
})
it('uses one spacing and radius system throughout the current feed', () => {
expect(source).toMatch(
/\.citywarn-overview\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
)
expect(source).toMatch(
/\.citywarn-overview \+ \.citywarn-feed\s*\{[^}]*margin-top:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
)
expect(source).toMatch(
/\.citywarn-alert-card\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
)
expect(source).toMatch(
/\.citywarn-publisher-card\s*\{[^}]*margin:\s*var\(--sky-space-3\) 0 0;[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
)
expect(source).toMatch(
/\.citywarn-publisher-card :deep\(\.sky-card__content\)\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
)
for (const selector of [
'citywarn-overview-symbol',
'citywarn-card-icon',
'citywarn-publisher-icon',
]) {
expect(source).toMatch(
new RegExp(
`\\.${selector}\\s*\\{[^}]*border-radius:\\s*var\\(--sky-radius-control\\);`,
's',
),
)
}
})
it('keeps settings groups on one compact spacing rhythm', () => {
expect(source).toMatch(
/\.citywarn-settings\s*\{[^}]*gap:\s*var\(--sky-space-5\);/s,
)
expect(source).toMatch(
/\.citywarn-settings :deep\(\.sky-settings-group\)\s*\{[^}]*margin:\s*0;/s,
)
expect(source).toMatch(
/\.citywarn-settings :deep\(\.sky-settings-group__title\)\s*\{[^}]*margin:\s*0 var\(--sky-space-1\) var\(--sky-space-2\);/s,
)
expect(source).toMatch(
/\.citywarn-settings :deep\(\.sky-settings-group__footer\)\s*\{[^}]*margin:\s*var\(--sky-space-2\) var\(--sky-space-1\) 0;/s,
)
})
it('keeps publishing authorization and validation on the server', () => {
expect(server).toContain('Bridge.Framework.GetJob(source)')
expect(server).toContain('SkyPhone.RequireSession(source)')
+34 -17
View File
@@ -1026,7 +1026,9 @@ onMounted(async () => {
height: 18px;
}
.citywarn-scroll {
height: 100%;
min-height: 0;
height: auto;
flex: 1 1 0;
padding: 12px 13px calc(28px + env(safe-area-inset-bottom));
overflow-y: auto;
}
@@ -1045,11 +1047,11 @@ onMounted(async () => {
}
.citywarn-overview {
display: flex;
padding: 15px;
padding: var(--sky-space-3);
align-items: center;
gap: 12px;
gap: var(--sky-space-3);
border: 1px solid #bbf7d0;
border-radius: 18px;
border-radius: var(--sky-radius-card);
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
}
.citywarn-overview--active {
@@ -1062,7 +1064,7 @@ onMounted(async () => {
height: 46px;
flex: 0 0 auto;
place-items: center;
border-radius: 15px;
border-radius: var(--sky-radius-control);
color: #15803d;
background: #dcfce7;
}
@@ -1086,12 +1088,17 @@ onMounted(async () => {
flex-direction: column;
gap: 10px;
}
.citywarn-overview + .citywarn-feed {
margin-top: var(--sky-space-3);
gap: var(--sky-space-3);
}
.citywarn-alert-card {
position: relative;
padding: 13px 13px 12px 17px;
padding: var(--sky-space-3) var(--sky-space-3) var(--sky-space-3)
var(--sky-space-4);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 17px;
border-radius: var(--sky-radius-card);
background: #fff;
box-shadow: 0 4px 15px rgb(15 23 42 / 5%);
cursor: pointer;
@@ -1115,7 +1122,7 @@ onMounted(async () => {
width: 32px;
height: 32px;
place-items: center;
border-radius: 10px;
border-radius: var(--sky-radius-control);
color: var(--severity);
background: color-mix(in srgb, var(--severity) 12%, white);
}
@@ -1139,7 +1146,7 @@ onMounted(async () => {
color: #9ca3af;
}
.citywarn-alert-card h3 {
margin: 10px 0 5px;
margin: var(--sky-space-2) 0 var(--sky-space-1);
font-size: 16px;
line-height: 1.18;
}
@@ -1155,10 +1162,10 @@ onMounted(async () => {
}
.citywarn-alert-card footer {
display: flex;
margin-top: 11px;
margin-top: var(--sky-space-2);
align-items: center;
justify-content: space-between;
gap: 8px;
gap: var(--sky-space-2);
color: #6b7280;
font-size: 10.5px;
}
@@ -1172,12 +1179,13 @@ onMounted(async () => {
white-space: nowrap;
}
.citywarn-publisher-card {
margin-top: 12px;
margin: var(--sky-space-3) 0 0;
border-radius: var(--sky-radius-card);
}
.citywarn-publisher-card :deep(.sky-card__content) {
display: flex;
padding: 12px;
gap: 10px;
padding: var(--sky-space-3);
gap: var(--sky-space-3);
}
.citywarn-publisher-icon {
display: grid;
@@ -1185,7 +1193,7 @@ onMounted(async () => {
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
border-radius: var(--sky-radius-control);
color: #1d4ed8;
background: #dbeafe;
}
@@ -1523,11 +1531,20 @@ onMounted(async () => {
.citywarn-settings {
display: flex;
flex-direction: column;
gap: 15px;
gap: var(--sky-space-5);
}
.citywarn-settings :deep(.sky-settings-group) {
margin: 0;
}
.citywarn-settings :deep(.sky-settings-group__title) {
margin: 0 var(--sky-space-1) var(--sky-space-2);
}
.citywarn-settings :deep(.sky-settings-group__footer) {
margin: var(--sky-space-2) var(--sky-space-1) 0;
}
.citywarn-settings-hint {
display: flex;
margin: 0 8px;
margin: 0 var(--sky-space-1);
align-items: flex-start;
gap: 7px;
color: #6b7280;
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
':opened="activeCanMessage && attachmentPicker !== null"',
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
+57 -12
View File
@@ -215,6 +215,9 @@ const activeContact = computed(() =>
const activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const activeServiceLine = computed(
() => activeContact.value?.source === 'company',
)
const activeContactEmail = computed(() =>
normalizeMailAddress(activeContact.value?.email ?? ''),
)
@@ -255,12 +258,15 @@ const inboxMenuItems = computed(() => [
},
])
const attachmentPanelOpen = computed(
() => emojiOpen.value || attachmentPicker.value !== null,
() =>
emojiOpen.value ||
(!activeServiceLine.value && attachmentPicker.value !== null),
)
const composerHasContent = computed(
() =>
Boolean(draft.value.trim() || shareDraft.value) ||
pendingAttachments.value.length > 0,
Boolean(draft.value.trim()) ||
(!activeServiceLine.value &&
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
)
function contactName(number: string): string {
return (
@@ -417,6 +423,8 @@ function errorText(error?: string): string {
'gif_provider_failed',
'self_message',
'recipient_not_found',
'messaging_unavailable',
'service_line_text_only',
'no_sim',
'rate_limited',
'blocked',
@@ -614,6 +622,7 @@ async function callActiveContact(): Promise<void> {
}
function toggleAttachmentMenu(): void {
if (activeServiceLine.value) return
attachmentMenuOpen.value = !attachmentMenuOpen.value
emojiOpen.value = false
attachmentPicker.value = null
@@ -646,7 +655,7 @@ function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber) return
if (!messages.activeNumber || activeServiceLine.value) return
const remainingSlots =
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
if (remainingSlots < 1) {
@@ -707,7 +716,14 @@ async function sendAttachment(
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
if (
!messages.activeNumber ||
!activeCanMessage.value ||
activeServiceLine.value ||
sending.value
) {
return
}
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -722,7 +738,7 @@ async function sendAttachment(
}
async function sendContact(contact: PhoneContact): Promise<void> {
if (!messages.activeNumber || sending.value) return
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -885,7 +901,12 @@ function sampleMicrophone(): void {
}
async function startVoiceRecording(): Promise<void> {
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
if (
!activeCanMessage.value ||
activeServiceLine.value ||
recording.value ||
recordingStarting.value
) {
return
}
emojiOpen.value = false
@@ -1041,6 +1062,27 @@ watch(
},
)
watch(activeServiceLine, (serviceLine) => {
if (!serviceLine) return
const discarded = Boolean(
shareDraft.value ||
pendingAttachments.value.length ||
recording.value ||
recordingStarting.value,
)
attachmentMenuOpen.value = false
attachmentPicker.value = null
shareDraft.value = null
pendingAttachments.value = []
if (recording.value) {
discardRecording = true
cancelVoiceRecording()
} else if (recordingStarting.value) {
cleanupRecorder()
}
if (discarded) showToast(errorText('service_line_text_only'))
})
onBeforeUnmount(() => {
discardRecording = true
cleanupRecorder()
@@ -1614,7 +1656,7 @@ onBeforeUnmount(() => {
</SkyScrollArea>
<section
v-if="activeCanMessage && attachmentMenuOpen"
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
class="messages-attachment-menu"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1659,7 +1701,9 @@ onBeforeUnmount(() => {
<SkySheet
class="messages-media-picker-sheet"
:opened="activeCanMessage && attachmentPicker !== null"
:opened="
activeCanMessage && !activeServiceLine && attachmentPicker !== null
"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
@@ -1794,7 +1838,7 @@ onBeforeUnmount(() => {
/>
<div
v-if="activeCanMessage && shareDraft && !recording"
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
class="shared-composer-preview"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1810,7 +1854,7 @@ onBeforeUnmount(() => {
</div>
<section
v-if="activeCanMessage && recording"
v-if="activeCanMessage && !activeServiceLine && recording"
class="messages-recorder"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1898,6 +1942,7 @@ onBeforeUnmount(() => {
<div class="messages-sky-composer-row">
<SkyGlass
v-if="!activeServiceLine"
component="button"
type="button"
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
@@ -1934,7 +1979,7 @@ onBeforeUnmount(() => {
<ArrowUpCircle :size="29" :stroke-width="2.4" />
</SkyLink>
<SkyLink
v-else
v-else-if="!activeServiceLine"
icon-only
class="messages-sky-messagebar__send"
:disabled="sending || recordingStarting"
@@ -63,6 +63,21 @@ describe('PhoneApp EasyShare contract', () => {
expect(source).not.toContain('rgba(10, 132, 255')
})
it('keeps contact profiles readable in light mode', () => {
expect(source).toMatch(
/\.phone-app--light\.phone-calls-app--profile\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-bg\) !important;/s,
)
expect(source).toMatch(
/\.phone-app--light \.phone-profile-action\s*\{[^}]*color:\s*var\(--sky-text\) !important;/s,
)
expect(source).toMatch(
/\.phone-app--light \.phone-profile-action:disabled\s*\{[^}]*color:\s*var\(--sky-subtle\) !important;/s,
)
expect(source).toMatch(
/\.phone-app--light \.phone-profile-card,[\s\S]*?\.phone-app--light \.phone-history-card\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-glass\);/,
)
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
const mounted = source.slice(
source.indexOf('onMounted(async () => {'),
+57
View File
@@ -3858,6 +3858,63 @@ onBeforeUnmount(() => {
background: var(--sky-glass);
}
.phone-app--light.phone-calls-app--profile {
color: var(--sky-text);
background: var(--sky-bg) !important;
}
.phone-app--light .phone-call-content--profile,
.phone-app--light .phone-contact-hero h2,
.phone-app--light .phone-detail-header-button,
.phone-app--light .phone-detail-edit {
color: var(--sky-text);
}
.phone-app--light .phone-contact-hero .phone-contact-organization,
.phone-app--light .phone-profile-notes > p,
.phone-app--light .phone-history-copy small,
.phone-app--light .phone-history-row time,
.phone-app--light .phone-history-empty {
color: var(--sky-muted);
}
.phone-app--light .phone-profile-action {
color: var(--sky-text) !important;
}
.phone-app--light .phone-profile-action:disabled {
color: var(--sky-subtle) !important;
}
.phone-app--light .phone-profile-card,
.phone-app--light .phone-own-profile-card,
.phone-app--light .phone-history-card {
border-color: var(--sky-hairline);
color: var(--sky-text);
background: var(--sky-glass);
box-shadow: var(--sky-shadow-glass);
}
.phone-app--light .phone-own-profile-card > div:not(:last-child),
.phone-app--light .phone-profile-info-card > button,
.phone-app--light .phone-profile-options-card > button,
.phone-app--light .phone-profile-single-option > button,
.phone-app--light .phone-profile-notes,
.phone-app--light .phone-history-row {
border-color: var(--sky-hairline);
}
.phone-app--light .phone-own-profile-card > div > span {
color: var(--sky-text);
background: var(--sky-pressed);
}
.phone-app--light .phone-profile-info-card > button > svg,
.phone-app--light .phone-profile-notes > span,
.phone-app--light .phone-history-card h3 {
color: var(--sky-text);
}
.phone-app--light .phone-bottom-tabbar {
--sky-app-accent: #3a3a3c;
}
@@ -32,6 +32,52 @@ describe('SkyRide app layout', () => {
)
})
it('scrolls Ride and Drive away without full-width nav backgrounds', () => {
expect(source).toMatch(
/\.skyride-navbar :deep\(\.sky-navbar__blur\),\s*\.skyride-navbar :deep\(\.sky-navbar__background\)\s*\{[^}]*display:\s*none;/s,
)
expect(source).toMatch(
/<div class="skyride-scroll">\s*<div class="skyride-mode">/s,
)
expect(source).toMatch(
/\.skyride-scroll\s*\{[^}]*box-sizing:\s*border-box;[^}]*inset:\s*114px 0 0;[^}]*padding:\s*0 0 116px;/s,
)
expect(source).toMatch(
/\.skyride-tabbar :deep\(\.sky-tabbar__blur\),\s*\.skyride-tabbar :deep\(\.sky-tabbar__background\)\s*\{[^}]*display:\s*none;/s,
)
expect(source).not.toMatch(
/\.skyride-tabbar :deep\(\.sky-tabbar__pane\)\s*\{\s*border:/s,
)
})
it('keeps active-ride card spacing even and outline actions contrasted', () => {
expect(source).toMatch(
/\.skyride-ride-status-card\s*\{[^}]*margin-bottom:\s*12px;/s,
)
expect(source).toMatch(
/\.skyride-person-card\s*\{[^}]*margin-bottom:\s*12px;/s,
)
expect(source).toMatch(
/\.skyride-trip-card\s*\{[^}]*margin-bottom:\s*12px;/s,
)
expect(source).toContain('.sky-button--primary:not(.sky-button--outline)')
expect(source).not.toMatch(
/\.skyride-app :deep\(\.sky-button--primary\)\s*\{/,
)
})
it('aligns pickup and destination text to one timeline axis', () => {
expect(source).toMatch(
/\.skyride-route-stop\s*\{[^}]*grid-template-columns:\s*18px minmax\(0, 1fr\);/s,
)
expect(source).toMatch(
/\.skyride-route-stop > \.skyride-dot\s*\{[^}]*justify-self:\s*center;/s,
)
expect(source).toMatch(
/\.skyride-trip-card > i\s*\{[^}]*margin:\s*1px 0 1px 8px;/s,
)
})
it('uses a compact swipeable profile editor with equal actions', () => {
expect(source).toContain('class="skyride-profile-sheet"')
expect(source).toContain('swipe-to-close')
+47 -25
View File
@@ -622,26 +622,26 @@ onBeforeUnmount(() => {
</k-card>
<template v-else>
<div class="skyride-mode">
<k-segmented v-if="skyride.driverEligible">
<k-segmented-button
:active="mode === 'rider'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'rider'"
>
{{ phone.t('Apps.skyride.mode.rider') }}
</k-segmented-button>
<k-segmented-button
:active="mode === 'driver'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'driver'"
>
{{ phone.t('Apps.skyride.mode.driver') }}
</k-segmented-button>
</k-segmented>
</div>
<div class="skyride-scroll">
<div class="skyride-mode">
<k-segmented v-if="skyride.driverEligible">
<k-segmented-button
:active="mode === 'rider'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'rider'"
>
{{ phone.t('Apps.skyride.mode.rider') }}
</k-segmented-button>
<k-segmented-button
:active="mode === 'driver'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'driver'"
>
{{ phone.t('Apps.skyride.mode.driver') }}
</k-segmented-button>
</k-segmented>
</div>
<template v-if="activeTab === 'home'">
<section v-if="mode === 'rider'" class="skyride-home-panel">
<template v-if="!skyride.activeRide">
@@ -1816,6 +1816,11 @@ onBeforeUnmount(() => {
color: var(--ride-text);
}
.skyride-navbar :deep(.sky-navbar__blur),
.skyride-navbar :deep(.sky-navbar__background) {
display: none;
}
.skyride-navbar :deep(.sky-navbar__title) {
color: var(--ride-text);
font-size: 18px;
@@ -1853,7 +1858,9 @@ onBeforeUnmount(() => {
.skyride-scroll {
position: absolute;
z-index: 2;
inset: 148px 0 116px;
box-sizing: border-box;
inset: 114px 0 0;
padding: 0 0 116px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
@@ -2253,6 +2260,10 @@ onBeforeUnmount(() => {
padding: 16px;
}
.skyride-person-card {
margin-bottom: 12px;
}
.skyride-avatar,
.skyride-profile-avatar {
display: grid;
@@ -2329,13 +2340,19 @@ onBeforeUnmount(() => {
}
.skyride-route-stop {
display: flex;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 11px;
column-gap: 11px;
}
.skyride-route-stop > .skyride-dot {
justify-self: center;
}
.skyride-route-stop > svg {
flex: 0 0 18px;
width: 18px;
height: 18px;
color: var(--ride-accent-strong);
}
@@ -2366,7 +2383,7 @@ onBeforeUnmount(() => {
display: block;
width: 1px;
height: 17px;
margin: 1px 0 1px 5px;
margin: 1px 0 1px 8px;
border-left: 1px dashed var(--ride-muted);
}
@@ -2691,6 +2708,11 @@ button.skyride-profile-avatar--editable {
gap: 0 !important;
}
.skyride-tabbar :deep(.sky-tabbar__blur),
.skyride-tabbar :deep(.sky-tabbar__background) {
display: none;
}
.skyride-tab-pane {
width: 100% !important;
max-width: none !important;
@@ -2969,7 +2991,7 @@ button.skyride-profile-avatar--editable {
background-color: var(--ride-accent);
}
.skyride-app :deep(.sky-button--primary) {
.skyride-app :deep(.sky-button--primary:not(.sky-button--outline)) {
color: #171719;
}
@@ -93,7 +93,7 @@ describe('phone apps use Sky UI', () => {
it('uses shared liquid glass for remaining compact interaction controls', () => {
const minimumGlassButtons: Record<string, number> = {
'CameraApp.vue': 2,
'CameraApp.vue': 1,
'FeatherApp.vue': 2,
'FlareApp.vue': 1,
'FlipTokApp.vue': 2,
@@ -476,6 +476,66 @@ function emptyStructure(scope, path) {
return undefined
}
function companyDefinitionEntryDefault(definitions) {
const firstDefinition = definitions[Object.keys(definitions).sort()[0]]
const usedNumbers = new Set(
Object.values(definitions).map((definition) =>
String(definition.ServiceLine.Number).replace(/\D/g, ''),
),
)
let serviceNumber = ''
for (let offset = 0; offset < 999; offset += 1) {
const candidate = String(((499 + offset) % 999) + 1).padStart(3, '0')
if (!usedNumbers.has(candidate)) {
serviceNumber = candidate
break
}
}
return {
AcceptsRequests: true,
Address: '',
Category: firstDefinition.Category,
DefaultAvailability: 'closed',
Description: '',
District: '',
Emergency: false,
Icon: 'building',
Job: '',
Location: { __skyType: 'vector3', x: 0, y: 0, z: 0 },
LocationLabel: '',
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
Name: '',
Permissions: {
Announcement: 0,
Assign: 0,
Availability: 0,
Hours: 0,
Profile: 0,
Services: 0,
WorkQueue: 0,
},
Public: true,
ServiceLine: {
AutoContact: true,
CanCall: true,
CanMessage: false,
MinimumGrade: 0,
Number: serviceNumber,
Routing: 'round_robin',
},
Services: [
{
Description: '',
Id: '',
Price: '',
RequestsEnabled: true,
Title: '',
},
],
Verified: false,
}
}
function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
@@ -495,6 +555,7 @@ function buildStructure(value, scope, path) {
]),
)
return {
entryDefault: companyDefinitionEntryDefault(value),
fields,
kind: 'table',
mutableKeys: true,
@@ -12,6 +12,7 @@ type ConfiguratorField = {
}
type ConfiguratorStructure = {
entryDefault?: unknown
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
@@ -122,6 +123,22 @@ describe('admin configurator fixture', () => {
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
expect(companyJobs?.label).toBe('Jobs')
expect(companyJobs?.structure).toMatchObject({
entryDefault: {
AcceptsRequests: true,
Category: 'public_services',
Job: '',
ServiceLine: {
Number: '500',
Routing: 'round_robin',
},
Services: [
{
Id: '',
RequestsEnabled: true,
Title: '',
},
],
},
fields: {
ambulance: { kind: 'table' },
police: { kind: 'table' },
@@ -135,6 +152,47 @@ describe('admin configurator fixture', () => {
).toBe('map')
})
it('exposes the requestable police assistance defaults', () => {
const companyJobs = loadConfiguratorSections()
.flatMap((section) => section.fields)
.find((field) => field.path === 'Companies.Definitions')
const police = (
companyJobs?.value as Record<string, Record<string, unknown>> | undefined
)?.police
expect(police).toMatchObject({
AcceptsRequests: true,
Emergency: true,
Services: [
{
Id: 'police-assistance',
RequestsEnabled: true,
},
],
})
expect(
companyJobs?.structure?.fields?.police.fields?.Services,
).toMatchObject({
items: [
{
fields: {
Id: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
},
],
kind: 'list',
template: {
fields: {
Id: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
},
})
})
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
@@ -183,7 +241,7 @@ describe('admin configurator fixture', () => {
template: { kind: 'value', valueType: 'string' },
})
expect(
root('Companies.Definitions')?.fields?.police.fields?.Services,
root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
).toMatchObject({
items: [],
kind: 'list',
+9 -17
View File
@@ -1978,7 +1978,7 @@ const contacts = [
},
{
canCall: true,
canMessage: false,
canMessage: true,
companyId: 'police',
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
id: 'company:police',
@@ -4178,7 +4178,7 @@ const companyCategories = [
let companyCallAvailable = false
const companyProfiles = [
{
acceptsRequests: false,
acceptsRequests: true,
announcement: {
body: 'Community traffic unit active around Legion Square.',
expiresAt: isoTime(6 * 60 * 60 * 1000),
@@ -4187,7 +4187,7 @@ const companyProfiles = [
availability: 'available',
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
canCall: true,
canMessage: false,
canMessage: true,
categoryId: 'public_services',
categoryName: 'Public Services',
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
@@ -4202,28 +4202,20 @@ const companyProfiles = [
label: 'Mission Row Police Station',
},
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
name: 'Los Santos Police',
name: 'Los Santos Police Department',
phoneNumber: '911',
revision: 3,
services: [
{
acceptsRequests: false,
acceptsRequests: true,
active: true,
description: 'Immediate police response through the service line.',
id: 'emergency-response',
description: 'Request non-emergency police assistance.',
id: 'police-assistance',
priceText: null,
title: 'Emergency Response',
},
{
acceptsRequests: false,
active: true,
description: 'General information and non-emergency assistance.',
id: 'public-assistance',
priceText: null,
title: 'Public Assistance',
title: 'Police Assistance',
},
],
serviceSummary: 'Emergency response and public assistance',
serviceSummary: 'Non-emergency police assistance',
verified: true,
},
{
+11 -3
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 = false,
AcceptsRequests = true,
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 = false,
CanMessage = true,
Routing = "round_robin",
MinimumGrade = 0,
},
@@ -1203,7 +1203,15 @@ if IsDuplicityVersion() then
Services = 3,
Announcement = 3,
},
Services = {},
Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
},
ambulance = {
Job = "ambulance",
+5 -4
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.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", invalid_company_configuration = "Die Firmenkonfiguration ist unvollständig oder enthält einen doppelten Job, eine doppelte Servicenummer oder Service-ID.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
},
Apps = {
health = {
@@ -695,6 +695,7 @@ Locales["de"] = {
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
},
@@ -1228,7 +1229,7 @@ Locales["de"] = {
},
skyride = {
name = "SkyRide", loading = "Laden SkyRide", unavailable = "SkyRide nicht verfügbar", tryAgain = "Erneut versuchen",
mode = { rider = "Reiten", driver = "Antrieb" },
mode = { rider = "Passagier", driver = "Fahrer" },
riderEyebrow = "Fahr irgendwohin.", whereTo = "Wohin?", pickup = "Abholung", destination = "Ziel",
currentLocation = "Aktueller Standort", notSelected = "Nicht ausgewählt", location = "Wähle einen Ort",
chooseLocation = { pickup = "Abholvorgang einstellen", destination = "Ziel auswählen" },
@@ -1449,8 +1450,8 @@ Locales["de"] = {
camera = {
name = "Kamera", flash = "Blitz", flip = "Kamera wechseln", landscape = "Zum Querformat wechseln",
portrait = "Zum Hochformat wechseln", photo = "Foto", video = "Video", microphoneOn = "Mikrofon eingeschaltet", microphoneOff = "Mikrofon stumm",
focusHelp = "Halte die Leertaste gedrückt, um dich umzusehen", returnHelp = "Leertaste für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", spaceKey = "Leertaste", uploading = "Upload: {count}",
focusHelp = "Halte die Umschautaste gedrückt, um dich umzusehen", returnHelp = "Umschautaste loslassen für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", lookKey = "Umschauen", uploading = "Upload: {count}",
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
zoom = "Kamerazoom auf {zoom} einstellen",
+4 -3
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.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", invalid_company_configuration = "The company configuration is incomplete or contains a duplicate job, service number, or service ID.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
},
Apps = {
health = {
@@ -695,6 +695,7 @@ Locales["en"] = {
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
messaging_unavailable = "This company contact does not accept messages.",
service_line_text_only = "Company service lines currently accept text messages only.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
},
@@ -1449,8 +1450,8 @@ Locales["en"] = {
camera = {
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted",
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading",
focusHelp = "Hold the look key to look around", returnHelp = "Release the look key for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", lookKey = "Look", uploading = "{count} uploading",
saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
zoom = "Set camera zoom to {zoom}",
+4 -3
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.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." },
errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", invalid_company_configuration = "La configuración de la empresa está incompleta o contiene un trabajo, número de servicio o ID de servicio duplicado.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." },
},
Apps = {
health = {
@@ -695,6 +695,7 @@ Locales["es"] = {
gif_provider_rate_limited = "GIPHY está ocupado. Inténtalo de nuevo en un momento.", gif_provider_failed = "La búsqueda de GIFs está temporalmente inaccesible.",
self_message = "No puedes enviar mensajes a tu propio número.", recipient_not_found = "Ese número no está disponible.", blocked = "Este contacto ha bloqueado las llamadas y los mensajes de esta SIM.",
messaging_unavailable = "Este contacto de empresa no acepta mensajes.",
service_line_text_only = "Las líneas de servicio de empresa solo aceptan mensajes de texto por ahora.",
no_sim = "Este teléfono no tiene tarjeta SIM.", rate_limited = "Demasiados mensajes, inténtalo de nuevo en un minuto.",
request_failed = "Los mensajes están temporalmente inaccesibles.", default = "El mensaje no se pudo enviar.",
},
@@ -1449,8 +1450,8 @@ Locales["es"] = {
camera = {
name = "Cámara", flash = "Linterna", flip = "Cambiar de cámara", landscape = "Cambiar a paisaje",
portrait = "Cambiar a vertical", photo = "Foto", video = "Vídeo", microphoneOn = "Micrófono encendido", microphoneOff = "Micrófono silenciado",
focusHelp = "Presionar Espacio para mirar alrededor", returnHelp = "Soltar espacio para controles", lockCamera = "Bloquear movimiento de la cámara",
unlockCamera = "Desbloquear movimiento de la cámara", spaceKey = "Espacio", uploading = "Subiendo {count}",
focusHelp = "Mantén pulsada la tecla de vista para mirar alrededor", returnHelp = "Suelta la tecla de vista para usar los controles", lockCamera = "Bloquear movimiento de la cámara",
unlockCamera = "Desbloquear movimiento de la cámara", lookKey = "Mirar", uploading = "Subiendo {count}",
saving = "Guardando video...", openGallery = "Abrir fotos", takePhoto = "Tomar foto",
startRecording = "Iniciar grabación", stopRecording = "Detener grabación", saved = "Guardado en fotos.",
zoom = "Establecer zoom de la cámara en {zoom}",
+2 -4
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.2.8'
version '0.3.1'
provide 'lb-phone'
provide '17mov_Phone'
@@ -30,9 +30,7 @@ shared_scripts {
client_scripts {
'config/config.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'config/locales/es.lua',
'config/locales/*.lua',
'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua',
+57 -15
View File
@@ -9,9 +9,13 @@ local front_camera_fov = 32.0
local front_camera_distance = 1.05
local front_camera_height = 0.05
local front_camera_target_height = 0.03
local front_camera_horizontal_limit = 75.0
local front_camera_vertical_limit = 35.0
local front_camera_rotate_speed = 5.0
local camera_passthrough_control = 22 -- INPUT_JUMP (Space by default)
local blocked_camera_controls = {
0, -- INPUT_NEXT_CAMERA
22, -- INPUT_JUMP
camera_passthrough_control,
24, -- INPUT_ATTACK
25, -- INPUT_AIM
37, -- INPUT_SELECT_WEAPON
@@ -45,6 +49,8 @@ local camera_state = {
focus_watcher = false,
front_camera = false,
front_camera_handle = nil,
front_camera_pitch = 0.0,
front_camera_yaw = 0.0,
game_input = false,
landscape = false,
locked = false,
@@ -89,20 +95,39 @@ end
local function get_front_camera_transform(ped)
local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
local forward = GetEntityForwardVector(ped)
local forward_vector = vector3(forward.x, forward.y, forward.z)
local camera_offset = forward_vector * front_camera_distance
local camera_position = head_position + camera_offset + vector3(0.0, 0.0, front_camera_height)
local to_camera = camera_position - head_position
local dot = (to_camera.x * forward_vector.x)
+ (to_camera.y * forward_vector.y)
+ (to_camera.z * forward_vector.z)
if dot < 0.0 then
camera_position = head_position - camera_offset + vector3(0.0, 0.0, front_camera_height)
end
local yaw = math.rad(camera_state.front_camera_yaw)
local pitch = math.rad(camera_state.front_camera_pitch)
local orbit_direction = vector3(
(forward.x * math.cos(yaw)) - (forward.y * math.sin(yaw)),
(forward.x * math.sin(yaw)) + (forward.y * math.cos(yaw)),
0.0
)
local camera_position = head_position
+ (orbit_direction * (math.cos(pitch) * front_camera_distance))
+ vector3(0.0, 0.0, front_camera_height + (math.sin(pitch) * front_camera_distance))
local target_position = head_position + vector3(0.0, 0.0, front_camera_target_height)
return camera_position, target_position
end
local function update_front_camera_orbit()
local horizontal_input = GetDisabledControlNormal(0, 1)
local vertical_input = GetDisabledControlNormal(0, 2)
camera_state.front_camera_yaw = math.max(
-front_camera_horizontal_limit,
math.min(
front_camera_horizontal_limit,
camera_state.front_camera_yaw - (horizontal_input * front_camera_rotate_speed)
)
)
camera_state.front_camera_pitch = math.max(
-front_camera_vertical_limit,
math.min(
front_camera_vertical_limit,
camera_state.front_camera_pitch - (vertical_input * front_camera_rotate_speed)
)
)
end
local function apply_front_camera(ped)
if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
@@ -198,7 +223,18 @@ local function watch_camera_controls()
CreateThread(function()
while camera_state.active do
apply_camera_controls()
if not camera_state.walkable then
local passthrough_pressed = SkyPhoneFocus.IsHoldToLookPressed()
or IsDisabledControlPressed(0, camera_passthrough_control)
local should_focus = camera_state.locked or not passthrough_pressed
if camera_state.nui_focused ~= should_focus then
set_camera_focus(should_focus)
end
end
if camera_state.game_input then
if camera_state.front_camera and not camera_state.locked then
update_front_camera_orbit()
end
if
IsDisabledControlJustPressed(0, 241)
or IsDisabledControlJustPressed(0, 261)
@@ -214,9 +250,6 @@ local function watch_camera_controls()
math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step)
)
end
if IsDisabledControlJustReleased(0, 22) then
set_camera_focus(true)
end
end
Wait(0)
end
@@ -243,7 +276,7 @@ AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
camera_state.applied_nui_focus = data.focused
SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
end
if data.active and data.gameInput then
if data.active then
watch_camera_controls()
end
end)
@@ -256,6 +289,8 @@ local function set_camera_active(active)
TriggerEvent("sky_phone:client:cameraActiveChanged", active)
if active then
camera_state.front_camera = false
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
camera_state.landscape = false
camera_state.locked = false
camera_state.zoom = 1.0
@@ -290,6 +325,8 @@ local function set_camera_active(active)
end
set_flash_enabled(false)
camera_state.front_camera = false
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
camera_state.game_input = false
camera_state.landscape = false
camera_state.locked = false
@@ -314,6 +351,8 @@ local function set_front_camera(active)
return
end
if active then
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
apply_front_camera(PlayerPedId())
else
clear_front_camera()
@@ -405,6 +444,9 @@ RegisterNUICallback("camera:setLocked", function(data, cb)
return
end
camera_state.locked = data.locked == true
if camera_state.locked and not camera_state.walkable then
set_camera_focus(true)
end
cb({ success = true })
end)
+17 -4
View File
@@ -58,6 +58,16 @@ AddEventHandler("sky_phone:configurator:updated", function()
SkyPhoneFocus.Reapply()
end)
function SkyPhoneFocus.IsHoldToLookPressed()
if not hold_to_look_enabled then
return false
end
if IsControlPressed(0, hold_to_look_control) then
return true
end
return IsDisabledControlPressed(0, hold_to_look_control)
end
function SkyPhoneFocus.ApplyFocusedControls()
for _, group in ipairs(focused_control_groups) do
DisableAllControlActions(group)
@@ -96,6 +106,10 @@ function SkyPhoneFocus.Resolve(state)
if state.camera_active and not state.camera_nui_focused then
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
end
if state.camera_active then
-- Forward controls so disabled inputs remain readable while the NUI cursor owns focus.
return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }
end
local game_input = state.is_open
and allows_game_input(state)
and not state.camera_active
@@ -137,7 +151,7 @@ function SkyPhoneFocus.Reapply()
active = state.camera_active,
cursor = focus.cursor,
focused = focus.focused,
gameInput = focus.keep_input,
gameInput = focus.game_input,
})
end
@@ -234,10 +248,9 @@ end
CreateThread(function()
while true do
if game_input or block_game then
local look_passthrough = hold_to_look_enabled
and game_input
local look_passthrough = game_input
and not state.cursor_disabled
and IsControlPressed(0, hold_to_look_control)
and SkyPhoneFocus.IsHoldToLookPressed()
if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply()
+16 -20
View File
@@ -13,9 +13,7 @@ local suggested_admin_command = nil
local suggested_test_data_command = nil
local active_development_command = nil
local registered_development_commands = {}
local active_key_mapping_command = nil
local active_key_mapping_key = nil
local key_mapping_revision = 0
local phone_key_mapping_registered = false
local refresh_development_command
local refresh_phone_key_mapping
local refresh_test_data_command_suggestion
@@ -312,32 +310,30 @@ RegisterCommand("sky_phone_live_activity_open", function()
end
end, false)
RegisterCommand("sky_phone_toggle", run_phone_toggle, false)
RegisterCommand("sky_phone_toggle", function()
if not Config.Phone.Keybind then
return
end
run_phone_toggle()
end, false)
refresh_phone_key_mapping = function()
local key_name = Config.Phone.Keybind
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end
if key_name == active_key_mapping_key then
if phone_key_mapping_registered or not key_name then
return
end
active_key_mapping_key = key_name
active_key_mapping_command = nil
if not key_name then
return
end
key_mapping_revision = key_mapping_revision + 1
local command_name = "sky_phone_toggle_config_" .. key_mapping_revision
active_key_mapping_command = command_name
RegisterCommand(command_name, function()
if active_key_mapping_command == command_name then
run_phone_toggle()
end
end, false)
RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)
-- FiveM persists player rebindings by command name, so this identifier must remain stable.
phone_key_mapping_registered = true
RegisterKeyMapping(
"sky_phone_toggle",
locale.Controls.OpenPhone,
"keyboard",
key_name
)
end
refresh_phone_key_mapping()
+377 -86
View File
@@ -156,12 +156,18 @@ local function permission_grade(definition, permission)
return grade and math.max(0, math.floor(grade)) or nil
end
local function validate_configuration()
if type(Config.Companies) ~= "table" or type(Config.Companies.Definitions) ~= "table" then
error("[sky_phone] Config.Companies.Definitions must be configured.")
local function validate_configuration(configuration)
local company_config = configuration.Companies
local validated_definitions = {}
local validated_definition_ids = {}
local validated_definitions_by_job = {}
local validated_service_lines_by_number = {}
if type(company_config) ~= "table" or type(company_config.Definitions) ~= "table" then
return nil, "[sky_phone] Config.Companies.Definitions must be configured."
end
if type(Config.Companies.Enabled) ~= "boolean" then
error("[sky_phone] Config.Companies.Enabled must be a boolean.")
if type(company_config.Enabled) ~= "boolean" then
return nil, "[sky_phone] Config.Companies.Enabled must be a boolean."
end
for _, field in ipairs({
{ "PageSize", 1000 },
@@ -184,26 +190,26 @@ local function validate_configuration()
{ "AnnouncementMaximumSeconds", 31536000 },
{ "RetentionDays", 36500 },
}) do
if not valid_integer(Config.Companies[field[1]], 1, field[2]) then
error(("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1]))
if not valid_integer(company_config[field[1]], 1, field[2]) then
return nil, ("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1])
end
end
if Config.Companies.PageSize > Config.Companies.MaximumPageSize then
error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.")
if company_config.PageSize > company_config.MaximumPageSize then
return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize."
end
if type(Config.Companies.RateLimits) ~= "table" then
error("[sky_phone] Config.Companies.RateLimits must be configured.")
if type(company_config.RateLimits) ~= "table" then
return nil, "[sky_phone] Config.Companies.RateLimits must be configured."
end
for _, name in ipairs({ "Read", "Search", "CreateRequest", "Message", "RequestAction", "Profile", "CallAvailability" }) do
if not valid_integer(Config.Companies.RateLimits[name], 1, 100000) then
error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name))
if not valid_integer(company_config.RateLimits[name], 1, 100000) then
return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name)
end
end
if type(Config.Companies.CallRouting) ~= "table"
or not valid_integer(Config.Companies.CallRouting.MaxAttempts, 1, 20)
or not valid_integer(Config.Companies.CallRouting.RingSeconds, 1, 120)
if type(company_config.CallRouting) ~= "table"
or not valid_integer(company_config.CallRouting.MaxAttempts, 1, 20)
or not valid_integer(company_config.CallRouting.RingSeconds, 1, 120)
then
error("[sky_phone] Config.Companies.CallRouting is invalid.")
return nil, "[sky_phone] Config.Companies.CallRouting is invalid."
end
local configured_statuses = {
new = true,
@@ -213,48 +219,48 @@ local function validate_configuration()
completed = true,
cancelled = true,
}
if type(Config.Companies.Statuses) ~= "table" then
error("[sky_phone] Config.Companies.Statuses must be configured.")
if type(company_config.Statuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.Statuses must be configured."
end
for status in pairs(configured_statuses) do
if Config.Companies.Statuses[status] ~= true then
error(("[sky_phone] Companies status '%s' must be enabled."):format(status))
if company_config.Statuses[status] ~= true then
return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status)
end
end
for status, enabled in pairs(Config.Companies.Statuses) do
for status, enabled in pairs(company_config.Statuses) do
if not configured_statuses[status] or enabled ~= true then
error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status)))
return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))
end
end
if type(Config.Companies.AvailabilityStatuses) ~= "table" then
error("[sky_phone] Config.Companies.AvailabilityStatuses must be configured.")
if type(company_config.AvailabilityStatuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.AvailabilityStatuses must be configured."
end
local configured_availability = { available = true, busy = true, closed = true }
for _, status in ipairs({ "available", "busy", "closed" }) do
if Config.Companies.AvailabilityStatuses[status] ~= true then
error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status))
if company_config.AvailabilityStatuses[status] ~= true then
return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status)
end
end
for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do
for status, enabled in pairs(company_config.AvailabilityStatuses) do
if not configured_availability[status] or enabled ~= true then
error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status)))
return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))
end
end
if not valid_array(Config.Companies.Categories, 100) then
error("[sky_phone] Config.Companies.Categories must be a bounded array.")
if not valid_array(company_config.Categories, 100) then
return nil, "[sky_phone] Config.Companies.Categories must be a bounded array."
end
local category_ids = {}
local configured_service_ids = {}
for _, category_id in ipairs(Config.Companies.Categories) do
for _, category_id in ipairs(company_config.Categories) do
if type(category_id) ~= "string" or #category_id > 64
or not category_id:match("^[a-z0-9_-]+$") or category_ids[category_id]
then
error("[sky_phone] Companies contains an invalid category ID.")
return nil, "[sky_phone] Companies contains an invalid category ID."
end
category_ids[category_id] = true
end
for company_id, definition in pairs(Config.Companies.Definitions) do
for company_id, definition in pairs(company_config.Definitions) do
if type(company_id) ~= "string" or #company_id > 64 or not company_id:match("^[a-z0-9_-]+$")
or type(definition) ~= "table"
or type(definition.Job) ~= "string" or #definition.Job > 64
@@ -262,26 +268,25 @@ local function validate_configuration()
or not valid_text(definition.Name, 120, false)
or not category_ids[definition.Category]
then
error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id)))
return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))
end
local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true)
local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true)
local location_label = valid_text(
definition.LocationLabel or definition.Address or "",
Config.Companies.DistrictMaxLength,
company_config.DistrictMaxLength,
true
)
local address = valid_text(definition.Address or "", Config.Companies.AddressMaxLength, true)
local address = valid_text(definition.Address or "", company_config.AddressMaxLength, true)
local logo_url = valid_text(definition.LogoUrl, 2048, false)
if not description or not district or not location_label or not address
or type(definition.Public) ~= "boolean" or type(definition.Emergency) ~= "boolean"
or type(definition.Verified) ~= "boolean" or type(definition.AcceptsRequests) ~= "boolean"
or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability]
or not company_config.AvailabilityStatuses[definition.DefaultAvailability]
or not valid_text(definition.Icon, 64, false)
or not logo_url or not logo_url:match("^https://[^%s]+$")
or (definition.Emergency and definition.AcceptsRequests)
then
error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id))
return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)
end
definition.Name = trim(definition.Name)
definition.Description = description
@@ -292,7 +297,7 @@ local function validate_configuration()
if definition.Location ~= nil then
local location_type = type(definition.Location)
if location_type ~= "table" and location_type ~= "vector3" then
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
end
local x = tonumber(definition.Location.x)
local y = tonumber(definition.Location.y)
@@ -300,46 +305,46 @@ local function validate_configuration()
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000
then
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
end
end
if definitions_by_job[definition.Job] then
error(("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job))
if validated_definitions_by_job[definition.Job] then
return nil, ("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job)
end
local line = definition.ServiceLine
if type(line) ~= "table" then
error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id))
return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id)
end
local number = SkyPhoneSimNumber.NormalizeService(line.Number, Config.Sim.NumberLength)
local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength)
if not number then
error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id))
return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id)
end
if service_lines_by_number[number] then
error(("[sky_phone] Service number '%s' is assigned more than once."):format(number))
if validated_service_lines_by_number[number] then
return nil, ("[sky_phone] Service number '%s' is assigned more than once."):format(number)
end
if type(line.AutoContact) ~= "boolean" or type(line.CanCall) ~= "boolean"
or type(line.CanMessage) ~= "boolean"
or not valid_integer(line.MinimumGrade, 0, 10000)
then
error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id))
return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)
end
if line.AutoContact and not definition.Public then
error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id))
return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)
end
if line.Routing ~= "round_robin" then
error(("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(company_id, tostring(line.Routing)))
end
if line.CanMessage then
error(("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(company_id))
return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(
company_id,
tostring(line.Routing)
)
end
line.Number = number
definitions[company_id] = definition
definition_ids[#definition_ids + 1] = company_id
definitions_by_job[definition.Job] = company_id
service_lines_by_number[number] = company_id
validated_definitions[company_id] = definition
validated_definition_ids[#validated_definition_ids + 1] = company_id
validated_definitions_by_job[definition.Job] = company_id
validated_service_lines_by_number[number] = company_id
for _, permission in ipairs({ "WorkQueue", "Availability", "Assign", "Profile", "Hours", "Services", "Announcement" }) do
if not definition.Permissions or not valid_integer(definition.Permissions[permission], 0, 10000) then
error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission))
return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)
end
end
local default_services = definition.Services
@@ -347,40 +352,54 @@ local function validate_configuration()
default_services = {}
definition.Services = default_services
end
if not valid_array(default_services, Config.Companies.MaximumServices) then
error(("[sky_phone] Company '%s' has an invalid default service list."):format(company_id))
if not valid_array(default_services, company_config.MaximumServices) then
return nil, ("[sky_phone] Company '%s' has an invalid default service list."):format(company_id)
end
for _, service in ipairs(default_services) do
if type(service) ~= "table" then
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
end
local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false)
local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false)
local service_description = valid_text(
service.Description or "",
Config.Companies.ServiceDescriptionMaxLength,
company_config.ServiceDescriptionMaxLength,
true
)
local price = valid_text(service.Price or "", Config.Companies.ServicePriceMaxLength, true)
local price = valid_text(service.Price or "", company_config.ServicePriceMaxLength, true)
if not valid_service_id(service.Id) or not title or not service_description or not price
or type(service.RequestsEnabled) ~= "boolean"
then
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
end
service.Title = title
service.Description = service_description
service.Price = price
if configured_service_ids[service.Id] then
error(("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id))
return nil, ("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)
end
configured_service_ids[service.Id] = true
end
end
table.sort(definition_ids, function(left, right)
local left_name = definitions[left].Name:lower()
local right_name = definitions[right].Name:lower()
table.sort(validated_definition_ids, function(left, right)
local left_name = validated_definitions[left].Name:lower()
local right_name = validated_definitions[right].Name:lower()
return left_name == right_name and left < right or left_name < right_name
end)
return {
definition_ids = validated_definition_ids,
definitions = validated_definitions,
definitions_by_job = validated_definitions_by_job,
service_lines_by_number = validated_service_lines_by_number,
}
end
function SkyPhoneCompanies.ValidateConfiguration(configuration)
local validated, validation_error = validate_configuration(configuration)
if not validated then
return false, validation_error
end
return true
end
local function seed_companies()
@@ -466,6 +485,48 @@ local function seed_companies()
end
local function migrate_requestable_emergency_companies()
local migration_name = "sky-phone:companies:requestable-emergency:v1"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local statements = {}
local migrated_companies = {}
for _, company_id in ipairs(definition_ids) do
local definition = definitions[company_id]
if definition.Emergency and definition.AcceptsRequests then
statements[#statements + 1] = {
query = [[
UPDATE `sky_phone_company_profiles`
SET `accepts_requests` = 1, `revision` = `revision` + 1
WHERE `company_id` = ? AND `accepts_requests` = 0
]],
params = { company_id },
}
migrated_companies[#migrated_companies + 1] = company_id
end
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ companies = migrated_companies }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate requestable emergency company profiles.")
end
end
local function tombstone_removed_companies()
local profiles = Bridge.Database.Query([[
SELECT DISTINCT profile.`company_id`
@@ -826,7 +887,7 @@ local function company_payload(company_id, include_inactive_services)
availability = availability,
availabilityUpdatedAt = iso_time(row.availability_updated_at_unix)
or iso_time(row.updated_at_unix),
acceptsRequests = tonumber(row.accepts_requests) == 1 and not definition.Emergency,
acceptsRequests = tonumber(row.accepts_requests) == 1,
phoneNumber = line and line.Number or nil,
canCall = line and line.CanCall == true or false,
canMessage = line and line.CanMessage == true or false,
@@ -906,12 +967,16 @@ local function cleanup_retained_data()
end
local function refresh_runtime_configuration()
definitions = {}
definition_ids = {}
definitions_by_job = {}
service_lines_by_number = {}
validate_configuration()
local validated, validation_error = validate_configuration(Config)
if not validated then
error(validation_error)
end
definitions = validated.definitions
definition_ids = validated.definition_ids
definitions_by_job = validated.definitions_by_job
service_lines_by_number = validated.service_lines_by_number
seed_companies()
migrate_requestable_emergency_companies()
tombstone_removed_companies()
end
@@ -1123,7 +1188,7 @@ end
local function request_row(request_id)
local rows = Bridge.Database.Query([[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
@@ -1825,6 +1890,175 @@ local function notification_payload(kind, area, row)
}
end
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
if not Config.Companies.Enabled or type(data) ~= "table"
or data.messageType ~= "text" or not valid_uuid(data.id)
then
return { success = false, error = "invalid_request" }
end
local device, device_error = current_device(source, false)
if not device then
return device_error
end
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
if not service_line or not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
local body = valid_text(
data.body,
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
false
)
if not body then
return { success = false, error = "invalid_message" }
end
local request_id = uuid()
local created_event_id = uuid()
local status_event_id = uuid()
local mutation_token = uuid()
local statements = {
{
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
params = { device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_requests`
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
`company_activity_revision`)
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
WHERE sim.`id` = ? AND NOT EXISTS (
SELECT 1 FROM `sky_phone_company_requests` existing
WHERE existing.`company_id` = profile.`company_id`
AND existing.`customer_sim_id` = sim.`id`
AND existing.`channel` = 'service_line'
AND existing.`status` NOT IN ('completed', 'cancelled')
)
]],
params = {
request_id,
body,
service_line.companyId,
device.sim_id,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { created_event_id, request_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
FROM `sky_phone_company_requests`
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { status_event_id, service_line.companyId, device.sim_id },
},
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
`company_activity_revision` = `company_activity_revision` + 1,
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line'
AND `status` NOT IN ('completed', 'cancelled')
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { mutation_token, service_line.companyId, device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
FROM `sky_phone_company_requests`
WHERE `mutation_token` = ? LIMIT 1
]],
params = { data.id, body, mutation_token },
},
{
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
WHERE sim.`id` = ?
]],
params = {
data.id,
service_line.number,
body,
data.id,
device.sim_id,
},
},
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local routed = Bridge.Database.Query([[
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
FROM `sky_phone_sms_messages` sms
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
LEFT JOIN `sky_phone_company_request_events` created
ON created.`id` = ? AND created.`request_id` = request.`id`
WHERE sms.`id` = ? LIMIT 1
]], { created_event_id, data.id })
if not routed[1] then
Bridge.Debug(
"error",
"[sky_phone] Service-line message %s committed without a complete route.",
tostring(data.id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(routed[1].request_id)
if not row then
return { success = false, error = "request_failed" }
end
emit_request_change(row, true, true, source)
if routed[1].created_event_id then
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
"newRequest",
"work",
row
), source)
elseif row.assigned_identifier then
notify_identifier(
row.assigned_identifier,
row.company_id,
"sky_phone:companies:notification",
notification_payload("newMessage", "work", row)
)
end
return {
success = true,
data = {
messageId = data.id,
requestId = row.id,
},
}
end
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
if not allowed then
@@ -1839,7 +2073,7 @@ Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source,
end
local company_id = data.companyId
local definition = type(company_id) == "string" and definitions[company_id] or nil
if not definition or not definition.Public or definition.Emergency then
if not definition or not definition.Public then
return { success = false, error = "company_not_found" }
end
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false)
@@ -2090,6 +2324,11 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
then
return { success = false, error = "invalid_status" }
end
local service_line = access.row.channel == "service_line"
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
return { success = false, error = "messaging_unavailable" }
end
local message_id = uuid()
local mutation_token = uuid()
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
@@ -2129,6 +2368,37 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
}
end
if service_line then
if access.audience == "customer" then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
]],
params = { service_line.number, message_id },
}
else
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'company'
]],
params = { service_line.number, message_id },
}
end
end
if new_status ~= access.row.status then
statements[#statements + 1] = {
query = [[
@@ -2143,16 +2413,32 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local inserted = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
{ message_id }
)
local inserted = Bridge.Database.Query([[
SELECT message.`id`, sms.`id` AS `sms_id`
FROM `sky_phone_company_request_messages` message
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
WHERE message.`id` = ? LIMIT 1
]], { message_id })
if not inserted[1] then
return { success = false, error = "revision_conflict" }
end
if service_line and not inserted[1].sms_id then
Bridge.Debug(
"error",
"[sky_phone] Company request message %s committed without its service-line SMS.",
tostring(message_id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
if access.audience == "customer" then
if service_line then
TriggerClientEvent("sky_phone:messages:changed", source, {
phoneNumber = service_line.number,
})
end
local notification = notification_payload("newMessage", "work", row)
if row.assigned_identifier then
notify_identifier(
@@ -2162,6 +2448,12 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
notification
)
end
elseif service_line then
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
phoneNumber = service_line.number,
sender = service_line.name,
voice = false,
})
else
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"newMessage",
@@ -2636,7 +2928,6 @@ Bridge.Callbacks.Register("sky_phone:companies:update-profile", function(source,
local address = valid_text(data.address, Config.Companies.AddressMaxLength, true)
if not revision or not description or not district or not location_label or not address
or type(data.acceptsRequests) ~= "boolean"
or (member.definition.Emergency and data.acceptsRequests)
then
return { success = false, error = "invalid_profile" }
end
+205 -64
View File
@@ -6,7 +6,12 @@ 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,
@@ -370,6 +375,144 @@ 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)
local phone_session, error_response = SkyPhone.RequireSession(source)
if not phone_session then
@@ -451,20 +594,7 @@ local function balance(account, asset)
row and (tonumber(row.version) or 0) or 0
end
local function market_rows()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
FROM `sky_phone_crypto_markets`
]], {})
local indexed = {}
for _, row in ipairs(rows) do
indexed[row.id] = row
end
return indexed
end
local function market_dtos(selected_market_ids)
local current = market_rows()
local selected = nil
if selected_market_ids then
selected = {}
@@ -472,28 +602,17 @@ 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 = current[market_id]
local ticks = Bridge.Database.Query([[
SELECT `price` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
]], { market_id, Config.Crypto.SparklinePoints })
local row = market_state[market_id]
local history = market_history[market_id]
local prices = {}
for index = #ticks, 1, -1 do
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
for index = first_history_index, #history do
prices[#prices + 1] = history[index]
end
if #prices == 0 then
prices[1] = tonumber(row.price)
@@ -509,7 +628,7 @@ local function market_dtos(selected_market_ids)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
local daily_range = daily[market_id]
local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
@@ -519,13 +638,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_range and daily_range.high or maximum, Config.Crypto.PriceScale),
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
}
end
end
@@ -567,13 +686,12 @@ end
local function bootstrap(profile)
local cash = balance(account_id(profile.id), "CASH")
local current_markets = market_rows()
local holdings = {}
local portfolio = cash
for _, market_id in ipairs(market_order) do
local available = balance(account_id(profile.id), market_id)
if available > 0 then
local price = tonumber(current_markets[market_id].price) or 0
local price = tonumber(market_state[market_id].price) or 0
local value = math.floor(available * price / Config.Crypto.AssetScale)
local fill = Bridge.Database.Query([[
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
@@ -1044,10 +1162,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
if not config or not side or not quantity then
return { success = false, error = "invalid_quantity" }
end
local market = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ config.Id }
)[1]
local market = market_state[config.Id]
if not market or market.status ~= "active" then
return { success = false, error = "market_unavailable" }
end
@@ -1127,9 +1242,8 @@ local function execute_trade(profile, data)
or { success = false, error = "duplicate_request" }
end
local quote = Bridge.Database.Query([[
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
SELECT quote.*
FROM `sky_phone_crypto_quotes` quote
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
]], { data.quoteId, profile.id })[1]
if not quote or quote.consumed_operation_id then
@@ -1142,7 +1256,10 @@ local function execute_trade(profile, data)
if not expiry or tonumber(expiry.expires_at) < os.time() then
return { success = false, error = "quote_expired" }
end
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
local current_market = market_state[quote.market_id]
if not current_market or current_market.status ~= "active"
or current_market.version ~= tonumber(quote.market_version)
then
return { success = false, error = "quote_expired" }
end
local quantity = tonumber(quote.quantity)
@@ -1367,6 +1484,7 @@ 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)"
@@ -1507,6 +1625,16 @@ 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)
@@ -1536,16 +1664,17 @@ 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 = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id }
)[1]
local row = market_state[market_id]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = crypto_random_int(
@@ -1614,22 +1743,19 @@ 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 = (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 })
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)
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
@@ -1645,11 +1771,26 @@ local function start_crypto_schedulers()
end
local function refresh_crypto_runtime()
initialize_markets()
start_crypto_schedulers()
if exchange_lock then
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
return
end
with_exchange_lock(function()
if not persist_market_cache() then
return
end
initialize_markets()
load_market_cache()
start_crypto_schedulers()
end)
end
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
persist_market_cache()
end
end)
start_crypto_schedulers()
end)
+2
View File
@@ -2710,6 +2710,7 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "channel", type = "ENUM('app','service_line') NOT NULL DEFAULT 'app'" },
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
@@ -2729,6 +2730,7 @@ local schema = {
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
{ name = "idx_sky_phone_company_requests_service_line", columns = "(`company_id`, `customer_sim_id`, `channel`, `status`, `updated_at`, `id`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
+47 -7
View File
@@ -97,6 +97,14 @@ local function current_device(source)
return device
end
local function resolve_message_recipient(value)
local service_line = SkyPhoneCompanies.GetServiceLine(value)
if service_line then
return service_line.number, service_line
end
return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix), nil
end
local function shared_contact(device, contact_id)
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
return nil, "invalid_contact"
@@ -366,7 +374,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local number = resolve_message_recipient(data.phoneNumber)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -407,11 +415,7 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
local number = resolve_message_recipient(data.phoneNumbers[index])
if not number then
return { success = false, error = "invalid_number" }
end
@@ -483,7 +487,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local number, service_line = resolve_message_recipient(data.phoneNumber)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -536,6 +540,42 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
else
return { success = false, error = "invalid_request" }
end
if service_line then
if not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
if message_type ~= "text" then
return { success = false, error = "service_line_text_only" }
end
local id = uuid()
local routed = SkyPhoneCompanies.RouteServiceLineMessage(source, {
id = id,
body = body,
messageType = message_type,
phoneNumber = number,
})
if not routed or not routed.success then
return routed or { success = false, error = "request_failed" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`,
`media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`,
`read_at`, `created_at`, 'sent' AS `direction`
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
]], { id })
if not rows[1] then
Bridge.Debug(
"error",
"[sky_phone] Routed service-line SMS %s could not be read back.",
tostring(id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local message = format_message(rows[1])
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
return { success = true, data = message }
end
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
+300 -4
View File
@@ -26,10 +26,16 @@ local FIXED_CONFIG_PATHS = {
}
if configurator_enabled then
print(
"[sky_phone] Phone Configurator enabled: file-based settings from config.lua " ..
"(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active."
)
local border = "======================================================================"
print(([[
^1%s^0
^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
^1%s^0
^1 The Phone Configurator is ENABLED.^0
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
^1%s^0]]):format(border, border, border))
end
local CLIENT_CONFIG_KEYS = {
@@ -557,6 +563,75 @@ local function empty_structure(scope, path)
return nil
end
local function next_available_company_service_number(configuration)
configuration = configuration or stored_config
local maximum_length = configuration.Sim.NumberLength
local service_length = math.max(1, math.min(3, maximum_length - 1))
local first_candidate = service_length == 1 and 5 or 5 * (10 ^ (service_length - 1))
local last_candidate = (10 ^ service_length) - 1
local used = {}
for _, definition in pairs(configuration.Companies.Definitions) do
used[tostring(definition.ServiceLine.Number):gsub("%D", "")] = true
end
for offset = 0, last_candidate - 1 do
local candidate = ((first_candidate - 1 + offset) % last_candidate) + 1
local number = ("%0" .. tostring(service_length) .. "d"):format(candidate)
if not used[number] then
return number
end
end
return ""
end
local function company_definition_entry_default(company_id, configuration)
configuration = configuration or stored_config
local name = company_id and humanize(company_id) or ""
local logo_seed = company_id or "new"
return {
AcceptsRequests = true,
Address = "",
Category = configuration.Companies.Categories[1] or default_config.Companies.Categories[1],
DefaultAvailability = "closed",
Description = "",
District = "",
Emergency = false,
Icon = "building",
Job = company_id or "",
Location = { __skyType = "vector3", x = 0.0, y = 0.0, z = 0.0 },
LocationLabel = "",
LogoUrl = ("https://picsum.photos/seed/companies-%s-logo/180/180"):format(logo_seed),
Name = name,
Permissions = {
Announcement = 0,
Assign = 0,
Availability = 0,
Hours = 0,
Profile = 0,
Services = 0,
WorkQueue = 0,
},
Public = true,
ServiceLine = {
AutoContact = true,
CanCall = true,
CanMessage = false,
MinimumGrade = 0,
Number = next_available_company_service_number(configuration),
Routing = "round_robin",
},
Services = {
{
Description = "",
Id = company_id or "",
Price = "",
RequestsEnabled = true,
Title = name,
},
},
Verified = false,
}
end
local function build_structure(value, scope, path)
local value_type = type(value)
if scope == "config" and path == "Phone.Keybind" then
@@ -575,6 +650,7 @@ local function build_structure(value, scope, path)
fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key))
end
return {
entryDefault = company_definition_entry_default(),
fields = fields,
kind = "table",
mutableKeys = true,
@@ -1069,6 +1145,211 @@ 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
@@ -1089,6 +1370,9 @@ Bridge.Database.Query(([[
apply_stored_row(read_stored_row())
apply_runtime_configuration()
Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)
function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data()
@@ -1138,6 +1422,18 @@ function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier,
end
end
local candidate_config = deserialize_value(next_config)
local companies_valid, validation_error = SkyPhoneCompanies.ValidateConfiguration(candidate_config)
if not companies_valid then
Bridge.Debug(
"warn",
"[sky_phone] Rejected invalid Phone Configurator Companies configuration: %s",
validation_error,
{ always = true }
)
return { success = false, error = "invalid_company_configuration" }
end
local config_encoded = encode_payload(next_config, "config")
local media_encoded = encode_payload(next_media, "media")
local result = Bridge.Database.Query(([[
+15 -10
View File
@@ -1836,16 +1836,21 @@ CreateThread(function()
while true do
local recovery_success, recovery_rows = pcall(
Bridge.Database.Query,
ride_select .. (([[
WHERE r.`refund_status` IN ('pending','completed')
AND (
r.`status` = 'cancelled'
OR (
r.`status` = 'payment_pending'
AND r.`updated_at` <= DATE_SUB(
CURRENT_TIMESTAMP,
INTERVAL %d SECOND
)
(([[
SELECT r.`id`, r.`status`, r.`refund_status`, r.`price`,
passenger.`owner_identifier` AS `passenger_identifier`
FROM `sky_phone_skyride_rides` r
INNER JOIN `sky_phone_skyride_profiles` passenger
ON passenger.`id` = r.`passenger_profile_id`
WHERE (
r.`refund_status` = 'pending'
AND r.`status` = 'cancelled'
) OR (
r.`refund_status` IN ('pending','completed')
AND r.`status` = 'payment_pending'
AND r.`updated_at` <= DATE_SUB(
CURRENT_TIMESTAMP,
INTERVAL %d SECOND
)
)
ORDER BY r.`updated_at` ASC
+11 -3
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 = false,
AcceptsRequests = true,
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 = false,
CanMessage = true,
Routing = "round_robin",
MinimumGrade = 0,
},
@@ -1188,7 +1188,15 @@ if IsDuplicityVersion() then
Services = 3,
Announcement = 3,
},
Services = {},
Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
},
ambulance = {
Job = "ambulance",
+63 -5
View File
@@ -4,6 +4,20 @@ local camera_target = nil
local camera_created = false
local camera_destroyed = false
local scripted_camera_rendering = false
local event_handlers = {}
local threads = {}
local hold_to_look_pressed = false
local disabled_controls = {}
local disabled_pressed_controls = {}
local disabled_control_normals = {}
local triggered_events = {}
local thread_stop = {}
SkyPhoneFocus = {
IsHoldToLookPressed = function()
return hold_to_look_pressed
end,
}
local vector_meta = {}
vector_meta.__index = vector_meta
@@ -32,12 +46,20 @@ function RegisterNUICallback(name, callback)
end
function RegisterNetEvent() end
function AddEventHandler() end
function TriggerEvent() end
function AddEventHandler(name, callback)
event_handlers[name] = callback
end
function TriggerEvent(name, data)
triggered_events[#triggered_events + 1] = { name = name, data = data }
end
function TriggerServerEvent() end
function SendNUIMessage() end
function CreateThread() end
function Wait() end
function CreateThread(callback)
threads[#threads + 1] = callback
end
function Wait()
error(thread_stop, 0)
end
function PlayerPedId() return 7 end
function PlayerId() return 8 end
function GetFollowPedCamViewMode() return 1 end
@@ -47,10 +69,19 @@ function DisplayRadar() end
function SetFollowPedCamViewMode() end
function SetFollowVehicleCamViewMode() end
function IsPedInAnyVehicle() return false end
function DisableControlAction() end
function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "camera controls must be disabled in the primary input group")
disabled_controls[control] = true
end
function DisablePlayerFiring() end
function HideHudAndRadarThisFrame() end
function GetCurrentResourceName() return "sky_phone" end
function IsDisabledControlJustPressed() return false end
function IsDisabledControlPressed(group, control)
assert(group == 0, "camera passthrough must read the primary input group")
return disabled_pressed_controls[control] == true
end
function GetDisabledControlNormal(_, control) return disabled_control_normals[control] or 0.0 end
function GetEntityCoords()
return vector3(10.0, 20.0, 1.0)
@@ -120,6 +151,33 @@ assert(close_enough(camera_target.x, 10.0))
assert(close_enough(camera_target.y, 20.0))
assert(close_enough(camera_target.z, 2.73))
event_handlers["sky_phone:client:cameraFocusApplied"]({
active = true,
cursor = false,
focused = true,
gameInput = true,
})
disabled_pressed_controls[22] = true
disabled_control_normals[1] = 0.2
local watcher_ok, watcher_error = pcall(threads[2])
assert(not watcher_ok and watcher_error == thread_stop, "camera watcher must run one controlled frame")
local camera_focus_event = triggered_events[#triggered_events]
assert(
camera_focus_event.name == "sky_phone:client:setCameraFocus"
and camera_focus_event.data.active
and not camera_focus_event.data.nuiFocused,
"holding Space must release the camera cursor for simultaneous look and movement"
)
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
end
local camera_ok, camera_error = pcall(threads[1])
assert(not camera_ok and camera_error == thread_stop, "selfie camera must render one controlled frame")
assert(not close_enough(camera_coord.x, 10.0), "horizontal look input must orbit the selfie camera around the player")
assert(camera_coord.y < 21.05, "selfie orbit must retain its configured distance from the player")
disabled_pressed_controls[22] = false
disabled_control_normals[1] = 0.0
assert(response_from("camera:setFacing", { front = false }).success)
assert(camera_destroyed and not scripted_camera_rendering, "rear mode must release the selfie camera")
+57 -3
View File
@@ -5,6 +5,9 @@ local event_handlers = {}
local nui_callbacks = {}
local nui_focus = nil
local nui_keep_input = nil
local pressed_controls = {}
local disabled_pressed_controls = {}
local triggered_events = {}
Config = {
Phone = {
@@ -34,7 +37,19 @@ function SetNuiFocusKeepInput(keep_input)
nui_keep_input = keep_input
end
function TriggerEvent() end
function TriggerEvent(name, data)
triggered_events[#triggered_events + 1] = { name = name, data = data }
end
function IsControlPressed(group, control)
assert(group == 0, "HoldToLook must read the primary input group")
return pressed_controls[control] == true
end
function IsDisabledControlPressed(group, control)
assert(group == 0, "HoldToLook must read disabled controls from the primary input group")
return disabled_pressed_controls[control] == true
end
function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "phone controls must be disabled in the primary input group")
@@ -56,6 +71,25 @@ end
dofile("sky_phone/source/client/focus.lua")
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must be idle until its configured control is held")
pressed_controls[19] = true
assert(SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must read its configured active control")
pressed_controls[19] = false
Config.Phone.HoldToLook.Control = 38
event_handlers["sky_phone:configurator:updated"]()
disabled_pressed_controls[38] = true
assert(
SkyPhoneFocus.IsHoldToLookPressed(),
"HoldToLook must read its configured control while NUI focus has disabled GTA input"
)
disabled_pressed_controls[38] = false
Config.Phone.HoldToLook.Enabled = false
event_handlers["sky_phone:configurator:updated"]()
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "disabled HoldToLook must reject every control state")
Config.Phone.HoldToLook.Enabled = true
Config.Phone.HoldToLook.Control = 19
event_handlers["sky_phone:configurator:updated"]()
local function resolve(overrides)
local state = {
activity_suspended = false,
@@ -233,6 +267,9 @@ disabled_controls = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(false)
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
end
assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
local movable_notification = resolve({ allow_movement = true, notification_focus = true })
@@ -264,11 +301,28 @@ local focused_camera = resolve({
assert(
focused_camera.cursor
and focused_camera.focused
and not focused_camera.keep_input
and focused_camera.keep_input
and not focused_camera.game_input,
"focused camera must override movement configuration until Space enables passthrough"
"focused camera must forward readable input while blocking game actions until passthrough is held"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = true })
local focused_camera_event = triggered_events[#triggered_events]
assert(
nui_focus.focused and nui_focus.cursor and nui_keep_input
and focused_camera_event.name == "sky_phone:client:cameraFocusApplied"
and not focused_camera_event.data.gameInput,
"focused camera must keep controls readable without reporting movement passthrough"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = false })
local passthrough_camera_event = triggered_events[#triggered_events]
assert(
nui_focus.focused and not nui_focus.cursor and nui_keep_input
and passthrough_camera_event.data.gameInput,
"camera passthrough must apply keyboard focus without a cursor and keep GTA input enabled"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = false, nuiFocused = true })
local camera_interrupted_by_call = resolve({
call_focus = true,
camera_active = true,