mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4809931ec9 | |||
| 9efd92e6b6 |
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
||||
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||
| **Languages** | English, German |
|
||||
@@ -650,7 +650,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
||||
|
||||
### Housing
|
||||
|
||||
Select `rtx`, `quasar`, `tgiann`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
|
||||
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
|
||||
|
||||
### Companies
|
||||
|
||||
|
||||
+10
-8
@@ -86,7 +86,6 @@ import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||
import { consumeEscape } from '@/utils/keyboard'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -1316,17 +1315,20 @@ async function closePhone(): Promise<void> {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
if (simPicker.value) {
|
||||
if (!consumeEscape(event)) return
|
||||
event.preventDefault()
|
||||
void closeSimPicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
queueMicrotask(() => {
|
||||
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
|
||||
return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
})
|
||||
}
|
||||
|
||||
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
||||
|
||||
@@ -70,31 +70,6 @@ describe('browser development preview contract', () => {
|
||||
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
|
||||
})
|
||||
|
||||
it('clips composited app and overlay layers to the curved display', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces the CEF button focus rectangle around the home indicator', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
|
||||
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
|
||||
expect(source).toContain(
|
||||
'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
|
||||
)
|
||||
})
|
||||
|
||||
it('maps the visible device side controls to phone actions', () => {
|
||||
expect(source).toContain('@click="toggleHardwareAlertMute"')
|
||||
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
|
||||
|
||||
@@ -460,7 +460,6 @@ button {
|
||||
}
|
||||
}
|
||||
.phone-screen {
|
||||
--phone-screen-radius: 40px;
|
||||
--phone-screen-portrait-ratio: 2.30951;
|
||||
position: relative;
|
||||
container-type: size;
|
||||
@@ -470,8 +469,7 @@ button {
|
||||
height: 98%;
|
||||
overflow: hidden;
|
||||
background: #08080a;
|
||||
border-radius: var(--phone-screen-radius);
|
||||
clip-path: inset(0 round var(--phone-screen-radius));
|
||||
border-radius: 40px;
|
||||
}
|
||||
.phone-screen--camera-landscape {
|
||||
background: transparent;
|
||||
@@ -858,14 +856,6 @@ button {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px #0008;
|
||||
}
|
||||
.phone-home-indicator:focus {
|
||||
outline: none;
|
||||
}
|
||||
.phone-home-indicator:focus-visible span {
|
||||
box-shadow:
|
||||
0 0 0 2px #0a84ff,
|
||||
0 1px 4px #0008;
|
||||
}
|
||||
.phone-home-indicator--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
)
|
||||
}
|
||||
|
||||
function sourceBlock(
|
||||
value: string,
|
||||
startMarker: string,
|
||||
endMarker: string,
|
||||
): string {
|
||||
const start = value.indexOf(startMarker)
|
||||
const end = value.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return value.slice(start, end)
|
||||
}
|
||||
|
||||
const companiesServer = source('../../sky_phone/source/server/companies.lua')
|
||||
const messagesServer = source('../../sky_phone/source/server/messages.lua')
|
||||
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
|
||||
const configDefaults = source(
|
||||
'../../sky_phone/source/shared/config_default.lua',
|
||||
)
|
||||
const messagesApp = source('./views/apps/MessagesApp.vue')
|
||||
const phoneFallbacks = source('./stores/phone.ts')
|
||||
const locales = ['en', 'de', 'es'].map((locale) =>
|
||||
source(`../../sky_phone/config/locales/${locale}.lua`),
|
||||
)
|
||||
|
||||
describe('Companies service-line message routing contract', () => {
|
||||
it('enables the police service line and persists an indexed route channel', () => {
|
||||
const police = sourceBlock(
|
||||
configDefaults,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const schema = sourceBlock(
|
||||
databaseMigration,
|
||||
' name = "sky_phone_company_requests",',
|
||||
' name = "sky_phone_company_request_reads",',
|
||||
)
|
||||
|
||||
expect(police).toContain('Number = "911"')
|
||||
expect(police).toContain('CanMessage = true')
|
||||
expect(schema).toContain(
|
||||
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
|
||||
)
|
||||
expect(schema).toContain(
|
||||
'name = "idx_sky_phone_company_requests_service_line"',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves short service numbers before the normal SIM-number path', () => {
|
||||
const resolver = sourceBlock(
|
||||
messagesServer,
|
||||
'local function resolve_message_recipient(value)',
|
||||
'\n\nlocal function shared_contact',
|
||||
)
|
||||
const send = sourceBlock(
|
||||
messagesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:messages:send"',
|
||||
'\n\nend)',
|
||||
)
|
||||
|
||||
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
|
||||
expect(resolver).toContain('return service_line.number, service_line')
|
||||
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
|
||||
expect(send).toContain('service_line_text_only')
|
||||
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
|
||||
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
|
||||
send.indexOf('SELECT s.`id`, s.`phone_number`'),
|
||||
)
|
||||
})
|
||||
|
||||
it('atomically mirrors inbound SMS into one active company request', () => {
|
||||
const route = sourceBlock(
|
||||
companiesServer,
|
||||
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
)
|
||||
|
||||
expect(route).toContain('current_device(source, false)')
|
||||
expect(route).toContain('service_line.canMessage')
|
||||
expect(route).toContain('UPDATE `sky_phone_sims`')
|
||||
expect(route).toContain("`channel` = 'service_line'")
|
||||
expect(route).toContain('AND NOT EXISTS (')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
|
||||
expect(route).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(route).toContain('emit_request_change(row, true, true, source)')
|
||||
expect(route).toContain('"sky_phone:companies:notification"')
|
||||
})
|
||||
|
||||
it('mirrors authorized company replies back to the customer SMS thread', () => {
|
||||
const sendMessage = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
|
||||
)
|
||||
|
||||
expect(sendMessage).toContain('access.row.channel == "service_line"')
|
||||
expect(
|
||||
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
|
||||
).toBe(2)
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'company'")
|
||||
expect(sendMessage).toContain('"sky_phone:messages:changed"')
|
||||
expect(sendMessage).toContain('"sky_phone:messages:new"')
|
||||
})
|
||||
|
||||
it('keeps service-line composition text-only in every shipped locale', () => {
|
||||
expect(messagesApp).toContain(
|
||||
"() => activeContact.value?.source === 'company'",
|
||||
)
|
||||
expect(messagesApp).toContain(
|
||||
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
|
||||
)
|
||||
expect(messagesApp).toContain('v-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain("'service_line_text_only'")
|
||||
expect(phoneFallbacks).toContain('service_line_text_only:')
|
||||
for (const locale of locales) {
|
||||
expect(locale).toContain('service_line_text_only =')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -107,10 +107,10 @@ describe('company configurator creation contract', () => {
|
||||
expect(blankRegistration).toBeLessThan(policeRegistration)
|
||||
})
|
||||
|
||||
it('enables persisted police service-line messaging before Companies starts', () => {
|
||||
it('repairs persisted service-line messaging before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_service_line_messaging()',
|
||||
'local function migrate_unsupported_company_message_defaults()',
|
||||
),
|
||||
configuratorServer.indexOf('\n\ndefault_config = {}'),
|
||||
)
|
||||
@@ -118,22 +118,19 @@ describe('company configurator creation contract', () => {
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
const messageRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_unsupported_company_message_defaults)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:service-line-messaging:v2',
|
||||
'sky-phone:configurator:unsupported-company-message-defaults:v1',
|
||||
)
|
||||
expect(migration).toContain('line.CanMessage ~= true')
|
||||
expect(migration).toContain('line.CanMessage = true')
|
||||
expect(migration).toContain('line.CanMessage == true')
|
||||
expect(migration).toContain('line.CanMessage = false')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(messageRegistration).toBeGreaterThan(policeRegistration)
|
||||
expect(companiesServer).not.toContain(
|
||||
'enables messaging without a virtual service-line message router',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -380,10 +380,10 @@ function toggleOptionalString(event: Event): void {
|
||||
function addListRow(): void {
|
||||
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const index = rows.length
|
||||
const value = listTemplate.value
|
||||
? blankFromConfiguratorStructure(listTemplate.value)
|
||||
: rows.length
|
||||
? blankLike(rows[0])
|
||||
const value = rows.length
|
||||
? blankLike(rows[0])
|
||||
: listTemplate.value
|
||||
? blankFromConfiguratorStructure(listTemplate.value)
|
||||
: blankValue(newArrayKind.value)
|
||||
rows.push(value)
|
||||
emit('update:modelValue', rows)
|
||||
|
||||
@@ -440,9 +440,6 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(source).toContain("selectConfiguratorScope('media')")
|
||||
expect(source).not.toContain('class="admin-panel-config-meta"')
|
||||
expect(configuratorValueEditor).toContain('function addListRow()')
|
||||
expect(configuratorValueEditor).toMatch(
|
||||
/const value = listTemplate\.value\s+\? blankFromConfiguratorStructure\(listTemplate\.value\)\s+: rows\.length\s+\? blankLike\(rows\[0\]\)/,
|
||||
)
|
||||
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'const canExtendTable = computed(',
|
||||
|
||||
@@ -24,13 +24,4 @@ describe('EasyShareSheet Sky UI contract', () => {
|
||||
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('only exposes and opens installed share destinations', () => {
|
||||
expect(source).toContain("if (appStore.isInstalled('flare'))")
|
||||
expect(source).toContain("if (appStore.isInstalled('darkchat'))")
|
||||
expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
|
||||
expect(source).toContain('if (!appStore.isInstalled(kind)) return')
|
||||
expect(source).toContain('if (!appStore.isInstalled(appId)) return')
|
||||
expect(source).not.toContain('appStore.homeLayout.hidden.includes')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
|
||||
}> = []
|
||||
const phoneNumbers = new Set<string>()
|
||||
|
||||
if (appStore.isInstalled('flare')) {
|
||||
if (!appStore.homeLayout.hidden.includes('flare')) {
|
||||
for (const match of flare.matches) {
|
||||
people.push({
|
||||
avatar: match.profile.photoUrls[0],
|
||||
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
|
||||
phoneNumbers.add(contact.phone_number)
|
||||
}
|
||||
|
||||
if (appStore.isInstalled('darkchat')) {
|
||||
if (!appStore.homeLayout.hidden.includes('darkchat')) {
|
||||
for (const conversation of darkChat.conversations.slice(0, 8)) {
|
||||
people.push({
|
||||
kind: 'darkchat',
|
||||
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
|
||||
})
|
||||
const shareApps = computed(() =>
|
||||
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
|
||||
.filter((id) => appStore.isInstalled(id))
|
||||
.filter((id) => !appStore.homeLayout.hidden.includes(id))
|
||||
.flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [{ app, id }] : []
|
||||
@@ -195,21 +195,18 @@ function endDrag(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
|
||||
if (!appStore.isInstalled(kind)) return
|
||||
if (!easyShare.prepareChatDraft(kind, targetId)) return
|
||||
close()
|
||||
void router.push(`/apps/${kind}`)
|
||||
}
|
||||
|
||||
function openChatApp(kind: EasyShareChatApp): void {
|
||||
if (!appStore.isInstalled(kind)) return
|
||||
if (!easyShare.prepareChatDraft(kind)) return
|
||||
close()
|
||||
void router.push(`/apps/${kind}`)
|
||||
}
|
||||
|
||||
function openShareApp(appId: EasyShareDestinationApp): void {
|
||||
if (!appStore.isInstalled(appId)) return
|
||||
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
|
||||
openChatApp(appId)
|
||||
return
|
||||
|
||||
@@ -26,10 +26,6 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const openedFolderAppBlocks =
|
||||
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
|
||||
const homeFolderIconBlocks =
|
||||
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
|
||||
|
||||
describe('Home folder interaction contract', () => {
|
||||
it('opens folders from edit mode and starts dragging only after movement', () => {
|
||||
@@ -40,7 +36,7 @@ describe('Home folder interaction contract', () => {
|
||||
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
|
||||
})
|
||||
|
||||
it('keeps whole-folder dragging aligned at every phone scale', () => {
|
||||
it('keeps folder app dragging aligned at every phone scale', () => {
|
||||
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
|
||||
expect(dragSource).toContain('viewportWidth: bounds.width')
|
||||
expect(dragSource).not.toContain("getPropertyValue('zoom')")
|
||||
@@ -51,60 +47,6 @@ describe('Home folder interaction contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
|
||||
expect(homeFolderIconBlocks).toHaveLength(2)
|
||||
for (const area of ['grid', 'dock'] as const) {
|
||||
const block = homeFolderIconBlocks.find((candidate) =>
|
||||
candidate.includes(`data-home-area="${area}"`),
|
||||
)
|
||||
|
||||
expect(block).toBeDefined()
|
||||
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
|
||||
expect(block).toContain('@dragcancel="stopHomeDrag"')
|
||||
expect(block).toContain('@dragend="finishHomeDrag"')
|
||||
expect(block).toContain('@dragmove="moveHomeDrag"')
|
||||
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the external viewport visual for apps dragged from an opened folder', () => {
|
||||
expect(openedFolderAppBlocks).toHaveLength(1)
|
||||
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
':external-drag-visual="externalDragVisual"',
|
||||
)
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
})
|
||||
|
||||
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
|
||||
expect(overlaySource).toMatch(
|
||||
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
|
||||
)
|
||||
expect(overlaySource).toContain("emit('dragstart', index, event)")
|
||||
expect(overlaySource).toContain("emit('dragmove', event)")
|
||||
expect(overlaySource).toContain("emit('dragcancel')")
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragstart="startFolderAppDrag(entry.index, $event)"',
|
||||
)
|
||||
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragcancel="stopFolderAppDrag"',
|
||||
)
|
||||
})
|
||||
|
||||
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
|
||||
expect(overlaySource).toContain('readPhoneViewportGeometry')
|
||||
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(panel)')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(element)')
|
||||
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
|
||||
expect(overlaySource).not.toContain(
|
||||
'panelElement.value?.getBoundingClientRect()',
|
||||
)
|
||||
expect(overlaySource).not.toContain('document.elementsFromPoint')
|
||||
})
|
||||
|
||||
it('edits the folder name inline with Sky UI controls', () => {
|
||||
expect(overlaySource).toContain('<SkyField')
|
||||
expect(overlaySource).toContain('home-folder-heading--editing')
|
||||
@@ -121,9 +63,8 @@ describe('Home folder interaction contract', () => {
|
||||
expect(springboardSource).toContain(
|
||||
'@drag-outside-change="folderDraggingOutside = $event"',
|
||||
)
|
||||
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
|
||||
expect(overlaySource).toContain(
|
||||
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
|
||||
'if (draggingIndex.value === null || draggingOutside.value) return',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'draggingOutside.value || !isPointerInsidePanel(event)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Pencil, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -11,35 +11,20 @@ import {
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
type HomeFolder,
|
||||
} from '@/utils/homeLayout'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type FolderAppEntry = {
|
||||
app: PhoneAppDefinition
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>(),
|
||||
{
|
||||
externalDragVisual: false,
|
||||
},
|
||||
)
|
||||
const props = defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
dragcancel: []
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [index: number, event: PointerEvent]
|
||||
'drag-outside-change': [outside: boolean]
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
@@ -58,7 +43,6 @@ const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
let pagePointerId: number | null = null
|
||||
let dragGeometry: PhoneViewportGeometry | null = null
|
||||
|
||||
const folderName = computed(
|
||||
() => props.folder.name || phone.t('Home.folders.defaultName'),
|
||||
@@ -129,48 +113,25 @@ function saveRename(): void {
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number, event: PointerEvent): void {
|
||||
function startFolderAppDrag(index: number): void {
|
||||
draggingIndex.value = index
|
||||
dragGeometry = readPhoneViewportGeometry(panelElement.value)
|
||||
setDraggingOutside(false)
|
||||
emit('dragstart', index, event)
|
||||
}
|
||||
|
||||
function fallbackViewportRect(element: Element): PhoneViewportRect {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function folderDragViewportRect(element: Element): PhoneViewportRect {
|
||||
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
|
||||
return geometry?.rect(element) ?? fallbackViewportRect(element)
|
||||
}
|
||||
|
||||
function isPointerInsidePanel(event: PointerEvent): boolean {
|
||||
const panel = panelElement.value
|
||||
const panelBounds = panelElement.value?.getBoundingClientRect()
|
||||
if (!panelBounds) return false
|
||||
return (
|
||||
panel !== null &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(panel),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
)
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
function moveFolderAppDrag(event: PointerEvent): void {
|
||||
if (draggingIndex.value === null) return
|
||||
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
|
||||
setDraggingOutside(true)
|
||||
}
|
||||
emit('dragmove', event)
|
||||
if (draggingIndex.value === null || draggingOutside.value) return
|
||||
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
|
||||
}
|
||||
|
||||
function setDraggingOutside(outside: boolean): void {
|
||||
@@ -184,52 +145,31 @@ function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) {
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
if (shouldExtract) {
|
||||
emit('extract', sourceIndex, event)
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
setDraggingOutside(false)
|
||||
|
||||
const target = Array.from(
|
||||
panelElement.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-folder-app-index]',
|
||||
) ?? [],
|
||||
).find(
|
||||
(element) =>
|
||||
Number(element.dataset.folderAppIndex) !== sourceIndex &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(element),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
)
|
||||
if (!target) {
|
||||
dragGeometry = null
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
|
||||
.find(
|
||||
(element) =>
|
||||
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
|
||||
)
|
||||
if (!target) return
|
||||
const targetIndex = Number(target.dataset.folderAppIndex)
|
||||
dragGeometry = null
|
||||
if (!Number.isInteger(targetIndex)) {
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
emit('move', sourceIndex, targetIndex)
|
||||
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
const wasDragging = draggingIndex.value !== null
|
||||
draggingIndex.value = null
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
@@ -255,11 +195,6 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
}
|
||||
pagePointerId = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFolderAppDrag()
|
||||
pagePointerId = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -355,11 +290,10 @@ onBeforeUnmount(() => {
|
||||
:app="entry.app"
|
||||
:data-folder-app-index="entry.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="externalDragVisual"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragmove="moveFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index, $event)"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(
|
||||
new URL(`../../sky_phone/${path}`, import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
|
||||
describe('housing provider contracts', () => {
|
||||
it('registers TGIANN House with server-authorized owner properties', () => {
|
||||
const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
|
||||
|
||||
expect(adapter).toContain('local resource_name = "tgiann-house"')
|
||||
expect(adapter).toContain('FROM `tgiann_house`')
|
||||
expect(adapter).toContain('WHERE `owner` = ?')
|
||||
expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
|
||||
expect(adapter).toContain(
|
||||
'Bridge.Housing.RegisterProvider(provider_name, {',
|
||||
)
|
||||
expect(adapter).toContain('return nil, "property_access_denied"')
|
||||
expect(adapter).not.toContain('`houseKeys`')
|
||||
expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
|
||||
})
|
||||
|
||||
it('uses the published client export for entrance waypoints only', () => {
|
||||
const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
|
||||
|
||||
expect(adapter).toContain(
|
||||
'return exports[resource_name]:getHouseData(house)',
|
||||
)
|
||||
expect(adapter).toContain(
|
||||
'Bridge.Normalize.Coordinates(house_data.doorCoord)',
|
||||
)
|
||||
expect(adapter).toContain(
|
||||
'Bridge.Housing.RegisterClientProvider(provider_name, {',
|
||||
)
|
||||
expect(adapter).toContain('SetNewWaypoint(')
|
||||
expect(adapter).not.toContain('enterHouse(')
|
||||
expect(adapter).not.toContain('forceOpenDoorHouse')
|
||||
})
|
||||
|
||||
it('advertises TGIANN in housing configuration and documentation', () => {
|
||||
const config = readResourceFile('config/config.lua')
|
||||
const readme = readFileSync(
|
||||
new URL('../../README.md', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(config).toContain(
|
||||
'"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
|
||||
)
|
||||
expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
|
||||
expect(readme).toContain('`quasar`, `tgiann`, `vms`')
|
||||
expect(readme).toContain(
|
||||
'expects `tgiann-core` to start before `tgiann-house`',
|
||||
)
|
||||
expect(readme).toContain(
|
||||
'lists server-authorized owner properties, and supports entrance waypoints',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -264,23 +264,6 @@ describe('app store', () => {
|
||||
expect(apps.homeLayout.hidden).not.toContain('snake')
|
||||
})
|
||||
|
||||
it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
for (const appId of ['banking', 'picstagram'] as const) {
|
||||
expect(apps.isInstalled(appId)).toBe(true)
|
||||
expect(apps.uninstallApp(appId)).toBe(true)
|
||||
expect(apps.isInstalled(appId)).toBe(false)
|
||||
expect(apps.claimedApps).not.toContain(appId)
|
||||
expect(apps.uninstalledApps).toContain(appId)
|
||||
expect(apps.homeLayout.hidden).toContain(appId)
|
||||
}
|
||||
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -491,41 +474,6 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('materializes a trailing page only for a successful folder extraction', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
settingsIndex,
|
||||
'Utilities',
|
||||
)
|
||||
expect(folderId).toBeTruthy()
|
||||
|
||||
const originalPageCount = apps.homeLayout.pageCount
|
||||
const targetPage = originalPageCount + 1
|
||||
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
expect(
|
||||
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
|
||||
).toBe(false)
|
||||
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(apps.homeLayout.pageCount).toBe(targetPage)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not commit an installation to a different phone', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
moveHomeAppToGridPage,
|
||||
@@ -520,23 +519,14 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): boolean {
|
||||
let sourceLayout = this.homeLayout
|
||||
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
|
||||
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
|
||||
while (sourceLayout.pageCount < targetPage) {
|
||||
const expanded = addHomePage(sourceLayout)
|
||||
if (expanded === sourceLayout) return false
|
||||
sourceLayout = expanded
|
||||
}
|
||||
}
|
||||
const next = extractHomeFolderApp(
|
||||
sourceLayout,
|
||||
this.homeLayout,
|
||||
folderId,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
)
|
||||
if (next === sourceLayout) return false
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
|
||||
@@ -2712,8 +2712,6 @@ const defaultLocales: LocaleTree = {
|
||||
recipient_not_found: 'That number is unavailable.',
|
||||
blocked: 'This contact has blocked calls and messages from your SIM.',
|
||||
messaging_unavailable: 'This company contact does not accept messages.',
|
||||
service_line_text_only:
|
||||
'Company service lines currently accept text messages only.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
rate_limited: 'Too many messages. Try again in a minute.',
|
||||
request_failed: 'Messages are temporarily unavailable.',
|
||||
@@ -3027,7 +3025,6 @@ const defaultLocales: LocaleTree = {
|
||||
uninstallTitle: 'Uninstall this app?',
|
||||
uninstallBody:
|
||||
'{app} will be removed from this phone. You can download it again from the App Store.',
|
||||
uninstallFailed: 'The app could not be uninstalled. Please try again.',
|
||||
},
|
||||
details: {
|
||||
skyStudios: 'Sky Studios',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
import {
|
||||
blankFromConfiguratorStructure,
|
||||
createMutableTableEntry,
|
||||
} from '@/utils/adminConfiguratorDefaults'
|
||||
import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
|
||||
|
||||
describe('admin configurator defaults', () => {
|
||||
it('creates a usable company draft from its key and the server defaults', () => {
|
||||
@@ -114,33 +111,4 @@ describe('admin configurator defaults', () => {
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves required nested list fields in schema-derived rows', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
fields: {
|
||||
jobs: {
|
||||
fields: {
|
||||
police: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
range: {
|
||||
items: [
|
||||
{ kind: 'value', valueType: 'number' },
|
||||
{ kind: 'value', valueType: 'number' },
|
||||
],
|
||||
kind: 'list',
|
||||
template: { kind: 'value', valueType: 'number' },
|
||||
},
|
||||
},
|
||||
kind: 'table',
|
||||
}
|
||||
|
||||
expect(blankFromConfiguratorStructure(structure)).toEqual({
|
||||
jobs: { police: false },
|
||||
range: [0, 0],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
@@ -64,78 +63,7 @@ function createGeometryFixture(options: {
|
||||
}
|
||||
}
|
||||
|
||||
const PHONE_BASE_ZOOM = 0.69 * 1.2
|
||||
const PHONE_HEIGHT = 844
|
||||
const PHONE_WIDTH = 390
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
|
||||
const displayCases = [
|
||||
{ height: 1080, label: '1080p 16:9', width: 1920 },
|
||||
{ height: 2160, label: '4K 16:9', width: 3840 },
|
||||
{ height: 1440, label: '21:9', width: 3440 },
|
||||
{ height: 1440, label: '32:9', width: 5120 },
|
||||
{ height: 1600, label: '16:10', width: 2560 },
|
||||
] as const
|
||||
|
||||
const phoneScales = [80, 100, 120] as const
|
||||
|
||||
function productionPhoneZoom(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
phoneScale: number,
|
||||
): number {
|
||||
const viewportScale = Math.min(
|
||||
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
|
||||
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const viewportMaximum = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(viewportWidth - edgeGap) / PHONE_WIDTH,
|
||||
(viewportHeight - edgeGap) / PHONE_HEIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
|
||||
}
|
||||
|
||||
const cefDisplayMatrix = displayCases.flatMap((display) =>
|
||||
phoneScales.map((phoneScale) => ({ display, phoneScale })),
|
||||
)
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
|
||||
const rect: PhoneViewportRect = {
|
||||
bottom: 260,
|
||||
height: 160,
|
||||
left: 120,
|
||||
right: 360,
|
||||
top: 100,
|
||||
width: 240,
|
||||
}
|
||||
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||
@@ -196,76 +124,6 @@ describe('phone viewport geometry', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(cefDisplayMatrix)(
|
||||
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
|
||||
({ display, phoneScale }) => {
|
||||
const viewportScale = Math.min(
|
||||
display.width / REFERENCE_VIEWPORT_WIDTH,
|
||||
display.height / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const zoom = productionPhoneZoom(
|
||||
display.width,
|
||||
display.height,
|
||||
phoneScale,
|
||||
)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const wrapper = measuredRect(
|
||||
display.width - edgeGap - PHONE_WIDTH * zoom,
|
||||
display.height - edgeGap - PHONE_HEIGHT * zoom,
|
||||
PHONE_WIDTH * zoom,
|
||||
PHONE_HEIGHT * zoom,
|
||||
)
|
||||
// Live CEF 103 can report the zoomed canvas in a different coordinate
|
||||
// space from pointer events. Preserve that mismatch in this fixture.
|
||||
const rawCanvas = measuredRect(
|
||||
display.width + 117.375,
|
||||
83.625,
|
||||
PHONE_WIDTH,
|
||||
PHONE_HEIGHT,
|
||||
)
|
||||
const rawPanel = measuredRect(
|
||||
rawCanvas.left + 23.25,
|
||||
rawCanvas.top + 246.75,
|
||||
343.5,
|
||||
324.25,
|
||||
)
|
||||
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
|
||||
const expected = {
|
||||
bottom: wrapper.top + (246.75 + 324.25) * zoom,
|
||||
height: 324.25 * zoom,
|
||||
left: wrapper.left + 23.25 * zoom,
|
||||
right: wrapper.left + (23.25 + 343.5) * zoom,
|
||||
top: wrapper.top + 246.75 * zoom,
|
||||
width: 343.5 * zoom,
|
||||
}
|
||||
|
||||
expectRectClose(panel, expected)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(
|
||||
panel,
|
||||
panel.left + panel.width / 2,
|
||||
panel.top + panel.height / 2,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
|
||||
).toBe(true)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
|
||||
).toBe(false)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
|
||||
).toBe(false)
|
||||
expect(panel.left).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.right).toBeLessThanOrEqual(display.width)
|
||||
expect(panel.top).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.bottom).toBeLessThanOrEqual(display.height)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
|
||||
@@ -7,14 +7,6 @@ export type PhoneViewportRect = {
|
||||
width: number
|
||||
}
|
||||
|
||||
export function phoneViewportRectContainsPoint(
|
||||
rect: PhoneViewportRect,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
|
||||
@@ -31,22 +31,6 @@ const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
const folderOverlayBlock =
|
||||
viewSource.match(/<HomeFolderOverlay\b[\s\S]*?\/>/)?.[0] ?? ''
|
||||
|
||||
function handlerName(block: string, event: string): string {
|
||||
return (
|
||||
block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function functionSource(name: string): string {
|
||||
if (!name) return ''
|
||||
const start = viewSource.indexOf(`function ${name}(`)
|
||||
if (start < 0) return ''
|
||||
const next = viewSource.indexOf('\nfunction ', start + 1)
|
||||
return viewSource.slice(start, next < 0 ? viewSource.length : next)
|
||||
}
|
||||
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||
@@ -152,7 +136,7 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
expect(
|
||||
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||
).toHaveLength(5)
|
||||
).toHaveLength(4)
|
||||
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||
@@ -176,65 +160,6 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('owns one external drag session for an app leaving an opened folder', () => {
|
||||
expect(folderOverlayBlock).toContain(
|
||||
':external-drag-visual="homeDragVisualActive"',
|
||||
)
|
||||
|
||||
const startSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragstart'),
|
||||
)
|
||||
const moveSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragmove'),
|
||||
)
|
||||
const cancelSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragcancel'),
|
||||
)
|
||||
|
||||
expect(startSource).toContain('createHomeDragGhost(event)')
|
||||
expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
|
||||
expect(moveSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(moveSource).toContain('folderDraggingOutside.value')
|
||||
expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
|
||||
expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
|
||||
expect(viewSource).toContain(
|
||||
'(draggingHomeApp.value || draggingFolderApp.value)',
|
||||
)
|
||||
expect(cancelSource).toContain('clearHomeDragGhost()')
|
||||
expect(cancelSource).toContain('clearTemporaryHomePage()')
|
||||
})
|
||||
|
||||
it('settles an extracted folder app from the tracked viewport preview', () => {
|
||||
const extractSource = functionSource('extractOpenedFolderApp')
|
||||
const targetSource = functionSource('folderExtractionDropTarget')
|
||||
|
||||
expect(extractSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(extractSource).toContain('homeDragPreviewBounds')
|
||||
expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
|
||||
expect(extractSource).not.toContain('sourceElement')
|
||||
expect(extractSource).not.toContain('getBoundingClientRect()')
|
||||
expect(extractSource).toMatch(
|
||||
/animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
|
||||
)
|
||||
expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||
expect(extractSource).toMatch(
|
||||
/if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
|
||||
const moveSource = functionSource('moveOpenedFolderApp')
|
||||
const cancelSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragcancel'),
|
||||
)
|
||||
|
||||
expect(moveSource).toContain('stopOpenedFolderAppDrag()')
|
||||
expect(cancelSource).toContain('clearHomeDragGhost()')
|
||||
expect(viewSource).toMatch(
|
||||
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the external drag visual on every terminal path', () => {
|
||||
expect(
|
||||
viewSource.match(
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
@@ -112,11 +111,6 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const draggingFolderApp = ref<{
|
||||
appId: LaunchablePhoneAppId
|
||||
folderId: string
|
||||
sourceIndex: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
@@ -616,11 +610,7 @@ function queueEdgePageTurn(
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = direction
|
||||
edgePageLocked = dragType === 'widget'
|
||||
if (
|
||||
dragType === 'app' &&
|
||||
lastHomePointer &&
|
||||
(draggingHomeApp.value || draggingFolderApp.value)
|
||||
) {
|
||||
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
|
||||
queueEdgePageTurn(lastHomePointer, 'app')
|
||||
}
|
||||
}, HOME_EDGE_PAGE_TURN_DELAY)
|
||||
@@ -824,7 +814,7 @@ function updateHomeDragGhost(event: {
|
||||
)
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
@@ -837,7 +827,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return null
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||
|
||||
const portalBounds = portal.getBoundingClientRect()
|
||||
const clipBounds = geometry.rect(springboard)
|
||||
@@ -848,7 +838,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
sourceBounds.width <= 0 ||
|
||||
sourceBounds.height <= 0
|
||||
) {
|
||||
return null
|
||||
return
|
||||
}
|
||||
|
||||
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||
@@ -875,12 +865,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (
|
||||
attribute.startsWith('data-home-') ||
|
||||
attribute.startsWith('data-folder-')
|
||||
) {
|
||||
element.removeAttribute(attribute)
|
||||
}
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
@@ -909,7 +894,6 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
layer.appendChild(position)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
return position
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
@@ -1206,98 +1190,6 @@ function nearestDockDropTarget(event: {
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
|
||||
function folderExtractionDropTarget(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}):
|
||||
| { area: 'dock'; index: number }
|
||||
| { area: 'grid'; index: number; page: number }
|
||||
| null {
|
||||
const geometry = homeDragGeometry
|
||||
if (!geometry) return null
|
||||
|
||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||
if (dock) {
|
||||
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||
if (
|
||||
phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
|
||||
) {
|
||||
const slots = Array.from(
|
||||
dock.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="dock"][data-home-index]',
|
||||
),
|
||||
).filter((slot) => {
|
||||
const index = Number(slot.dataset.homeIndex)
|
||||
return (
|
||||
Number.isInteger(index) && appStore.homeLayout.dock[index] === null
|
||||
)
|
||||
})
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||
)
|
||||
if (closestIndex === null) return null
|
||||
const index = Number(slots[closestIndex]?.dataset.homeIndex)
|
||||
return Number.isInteger(index) ? { area: 'dock', index } : null
|
||||
}
|
||||
}
|
||||
|
||||
const page = phone.currentPage
|
||||
if (page < 1 || page > appPages.value.length) return null
|
||||
const grid = document.querySelector<HTMLElement>(
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
|
||||
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||
const rawGridBounds = homeDragViewportRect(grid, geometry)
|
||||
const gridBounds = viewportRectAt(
|
||||
springboardBounds.left + (rawGridBounds.left - pageBounds.left),
|
||||
springboardBounds.top + (rawGridBounds.top - pageBounds.top),
|
||||
rawGridBounds.width,
|
||||
rawGridBounds.height,
|
||||
)
|
||||
if (
|
||||
!phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slots = Array.from(
|
||||
grid.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="grid"][data-home-index]',
|
||||
),
|
||||
).filter((slot) => {
|
||||
const index = Number(slot.dataset.homeIndex)
|
||||
const item = appStore.homeLayout.grid[index]
|
||||
return (
|
||||
Number.isInteger(index) &&
|
||||
(item === null ||
|
||||
(item === undefined && page > appStore.homeLayout.pageCount))
|
||||
)
|
||||
})
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => {
|
||||
const bounds = homeDragViewportRect(slot, geometry)
|
||||
return {
|
||||
height: bounds.height,
|
||||
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||
width: bounds.width,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (closestIndex === null) return null
|
||||
const index = Number(slots[closestIndex]?.dataset.homeIndex)
|
||||
return Number.isInteger(index) ? { area: 'grid', index, page } : null
|
||||
}
|
||||
|
||||
function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
@@ -1432,83 +1324,16 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
|
||||
return apps
|
||||
}
|
||||
|
||||
function currentOpenedFolderDragSession(sourceIndex?: number) {
|
||||
const session = draggingFolderApp.value
|
||||
const folder = openedFolder.value
|
||||
if (
|
||||
!session ||
|
||||
!folder ||
|
||||
folder.id !== session.folderId ||
|
||||
(sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
|
||||
folder.apps[session.sourceIndex] !== session.appId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function startOpenedFolderAppDrag(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
const folder = openedFolder.value
|
||||
if (
|
||||
!folder ||
|
||||
!Number.isInteger(sourceIndex) ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= folder.apps.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
const appId = folder.apps[sourceIndex]
|
||||
if (!appId || !installedAppsById.value.has(appId)) return
|
||||
if (!createHomeDragGhost(event)) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
}
|
||||
|
||||
function moveOpenedFolderAppDrag(event: PointerEvent): void {
|
||||
if (!currentOpenedFolderDragSession()) {
|
||||
stopOpenedFolderAppDrag()
|
||||
return
|
||||
}
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
|
||||
queueEdgePageTurn(event, 'app')
|
||||
return
|
||||
}
|
||||
clearEdgePageTurn()
|
||||
}
|
||||
|
||||
function stopOpenedFolderAppDrag(): void {
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
function resetOpenedFolderState(): void {
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
|
||||
function openFolder(folderId: string): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
pageTransitioning.value = false
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
|
||||
function closeFolder(): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
resetOpenedFolderState()
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
|
||||
function renameOpenedFolder(name: string): void {
|
||||
@@ -1517,68 +1342,71 @@ function renameOpenedFolder(name: string): void {
|
||||
}
|
||||
|
||||
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
|
||||
const session = currentOpenedFolderDragSession(sourceIndex)
|
||||
stopOpenedFolderAppDrag()
|
||||
if (!session) return
|
||||
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
|
||||
if (!openedFolderId.value) return
|
||||
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function extractOpenedFolderApp(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
const session = currentOpenedFolderDragSession(sourceIndex)
|
||||
if (!session) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
updateHomeDragGhost(event)
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||
if (!dropGhost || !dropOrigin) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
const target = folderExtractionDropTarget(event)
|
||||
const folder = openedFolder.value
|
||||
if (!folder) return
|
||||
const appId = folder.apps[sourceIndex]
|
||||
const sourceElement = document
|
||||
.querySelector<HTMLElement>(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||
const sourceBounds = sourceElement
|
||||
? homeDragViewportRect(sourceElement, geometry)
|
||||
: null
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
return (
|
||||
(area === 'grid' || area === 'dock') &&
|
||||
Number.isInteger(index) &&
|
||||
appStore.homeLayout[area][index] === null
|
||||
)
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = homeDragViewportRect(candidate, geometry)
|
||||
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
)
|
||||
const closestDistance = Math.hypot(
|
||||
event.clientX - (closestBounds.left + closestBounds.width / 2),
|
||||
event.clientY - (closestBounds.top + closestBounds.height / 2),
|
||||
)
|
||||
return distance < closestDistance ? candidate : closest
|
||||
}, null)
|
||||
if (!target) {
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
resetOpenedFolderState()
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
|
||||
const { area, index: targetIndex } = target
|
||||
const area = target.dataset.homeArea as HomeArea
|
||||
const targetIndex = Number(target.dataset.homeIndex)
|
||||
if (
|
||||
!appStore.extractHomeFolderApp(
|
||||
session.folderId,
|
||||
session.sourceIndex,
|
||||
area,
|
||||
targetIndex,
|
||||
)
|
||||
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
|
||||
) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(area === 'grid')
|
||||
resetOpenedFolderState()
|
||||
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
|
||||
if (extractedIndex === null) {
|
||||
clearHomeDragGhost(dropGhost)
|
||||
return
|
||||
closeFolder()
|
||||
if (sourceBounds) {
|
||||
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
|
||||
if (extractedIndex !== null) {
|
||||
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
|
||||
}
|
||||
}
|
||||
void animateHomeItemDrop(
|
||||
session.appId,
|
||||
area,
|
||||
extractedIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
@@ -1598,12 +1426,11 @@ watch(isEditablePage, (visible) => {
|
||||
watch(editMode, (editing) => {
|
||||
emit('editModeChange', editing)
|
||||
if (editing) return
|
||||
stopOpenedFolderAppDrag()
|
||||
stopHomeDrag()
|
||||
stopWidgetDrag()
|
||||
})
|
||||
watch(openedFolder, (folder) => {
|
||||
if (!folder && openedFolderId.value !== null) closeFolder()
|
||||
if (!folder) closeFolder()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
emit('editModeChange', false)
|
||||
@@ -1618,7 +1445,6 @@ onBeforeUnmount(() => {
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingFolderApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
})
|
||||
</script>
|
||||
@@ -2032,13 +1858,9 @@ onBeforeUnmount(() => {
|
||||
v-if="openedFolder"
|
||||
:apps="openedFolderApps"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="openedFolder"
|
||||
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
|
||||
@close="closeFolder"
|
||||
@dragcancel="stopOpenedFolderAppDrag"
|
||||
@dragmove="moveOpenedFolderAppDrag"
|
||||
@dragstart="startOpenedFolderAppDrag"
|
||||
@drag-outside-change="folderDraggingOutside = $event"
|
||||
@edit="enterEditMode"
|
||||
@extract="extractOpenedFolderApp"
|
||||
|
||||
@@ -101,10 +101,6 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain(
|
||||
'appStore.uninstallApp(uninstallCandidate.value.id)',
|
||||
)
|
||||
expect(source).toContain('@click.stop="requestUninstall(app)"')
|
||||
expect(source).toContain('if (!appStore.uninstallApp(')
|
||||
expect(source).toContain('Apps.appStore.account.uninstallFailed')
|
||||
expect(source).toContain('role="alert"')
|
||||
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
|
||||
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
|
||||
expect(source).toContain('class="store-account__grabber"')
|
||||
|
||||
@@ -63,7 +63,6 @@ const openedAt = new Date()
|
||||
const featuredSlide = ref(0)
|
||||
const profileOpened = ref(false)
|
||||
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
|
||||
const uninstallError = ref('')
|
||||
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
|
||||
const storeScroll = ref<ComponentPublicInstance | null>(null)
|
||||
const featuredScroller = ref<HTMLElement | null>(null)
|
||||
@@ -289,16 +288,6 @@ function closeProfile(): void {
|
||||
profileDragOffset.value = 0
|
||||
}
|
||||
|
||||
function requestUninstall(app: LaunchablePhoneAppDefinition): void {
|
||||
uninstallError.value = ''
|
||||
uninstallCandidate.value = app
|
||||
}
|
||||
|
||||
function closeUninstallDialog(): void {
|
||||
uninstallError.value = ''
|
||||
uninstallCandidate.value = null
|
||||
}
|
||||
|
||||
function beginProfileDrag(event: PointerEvent): void {
|
||||
if (!profileOpened.value || event.button !== 0) return
|
||||
profileDragPointerId = event.pointerId
|
||||
@@ -329,11 +318,8 @@ function endProfileDrag(event: PointerEvent): void {
|
||||
|
||||
function confirmUninstall(): void {
|
||||
if (!uninstallCandidate.value) return
|
||||
if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
|
||||
uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
|
||||
return
|
||||
}
|
||||
closeUninstallDialog()
|
||||
appStore.uninstallApp(uninstallCandidate.value.id)
|
||||
uninstallCandidate.value = null
|
||||
}
|
||||
|
||||
function highlightStyle(index: number): Record<string, string> {
|
||||
@@ -1121,7 +1107,7 @@ watch(
|
||||
app: getPhoneAppLabel(app, phone.t),
|
||||
})
|
||||
"
|
||||
@click.stop="requestUninstall(app)"
|
||||
@click="uninstallCandidate = app"
|
||||
>
|
||||
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1133,8 +1119,8 @@ watch(
|
||||
<SkyDialog
|
||||
:opened="Boolean(uninstallCandidate)"
|
||||
role="alertdialog"
|
||||
@backdropclick="closeUninstallDialog"
|
||||
@escape="closeUninstallDialog"
|
||||
@backdropclick="uninstallCandidate = null"
|
||||
@escape="uninstallCandidate = null"
|
||||
>
|
||||
<template #title>
|
||||
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
|
||||
@@ -1146,15 +1132,8 @@ watch(
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="uninstallError"
|
||||
class="store-account__uninstall-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ uninstallError }}
|
||||
</p>
|
||||
<template #buttons>
|
||||
<SkyDialogButton @click="closeUninstallDialog">
|
||||
<SkyDialogButton @click="uninstallCandidate = null">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyDialogButton>
|
||||
<SkyDialogButton strong @click="confirmUninstall">
|
||||
@@ -1497,11 +1476,6 @@ watch(
|
||||
background: var(--sky-danger-soft);
|
||||
}
|
||||
|
||||
.store-account__uninstall-error {
|
||||
color: var(--sky-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.store-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
expect(sheetStart).toBeGreaterThan(-1)
|
||||
expect(sheetEnd).toBeGreaterThan(sheetStart)
|
||||
expect(sheet).toContain(
|
||||
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
|
||||
':opened="activeCanMessage && attachmentPicker !== null"',
|
||||
)
|
||||
expect(sheet).toContain('swipe-to-close')
|
||||
expect(sheet).toContain('grabber-clickable')
|
||||
|
||||
@@ -215,9 +215,6 @@ const activeContact = computed(() =>
|
||||
const activeCanMessage = computed(
|
||||
() => activeContact.value?.canMessage !== false,
|
||||
)
|
||||
const activeServiceLine = computed(
|
||||
() => activeContact.value?.source === 'company',
|
||||
)
|
||||
const activeContactEmail = computed(() =>
|
||||
normalizeMailAddress(activeContact.value?.email ?? ''),
|
||||
)
|
||||
@@ -258,15 +255,12 @@ const inboxMenuItems = computed(() => [
|
||||
},
|
||||
])
|
||||
const attachmentPanelOpen = computed(
|
||||
() =>
|
||||
emojiOpen.value ||
|
||||
(!activeServiceLine.value && attachmentPicker.value !== null),
|
||||
() => emojiOpen.value || attachmentPicker.value !== null,
|
||||
)
|
||||
const composerHasContent = computed(
|
||||
() =>
|
||||
Boolean(draft.value.trim()) ||
|
||||
(!activeServiceLine.value &&
|
||||
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
|
||||
Boolean(draft.value.trim() || shareDraft.value) ||
|
||||
pendingAttachments.value.length > 0,
|
||||
)
|
||||
function contactName(number: string): string {
|
||||
return (
|
||||
@@ -423,8 +417,6 @@ function errorText(error?: string): string {
|
||||
'gif_provider_failed',
|
||||
'self_message',
|
||||
'recipient_not_found',
|
||||
'messaging_unavailable',
|
||||
'service_line_text_only',
|
||||
'no_sim',
|
||||
'rate_limited',
|
||||
'blocked',
|
||||
@@ -622,7 +614,6 @@ async function callActiveContact(): Promise<void> {
|
||||
}
|
||||
|
||||
function toggleAttachmentMenu(): void {
|
||||
if (activeServiceLine.value) return
|
||||
attachmentMenuOpen.value = !attachmentMenuOpen.value
|
||||
emojiOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
@@ -655,7 +646,7 @@ function openMediaApp(
|
||||
app: 'camera' | 'photos',
|
||||
mediaType: 'photo' | 'video',
|
||||
): void {
|
||||
if (!messages.activeNumber || activeServiceLine.value) return
|
||||
if (!messages.activeNumber) return
|
||||
const remainingSlots =
|
||||
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
|
||||
if (remainingSlots < 1) {
|
||||
@@ -716,14 +707,7 @@ async function sendAttachment(
|
||||
mediaAssetId: string,
|
||||
mediaDurationMs?: number,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!messages.activeNumber ||
|
||||
!activeCanMessage.value ||
|
||||
activeServiceLine.value ||
|
||||
sending.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
@@ -738,7 +722,7 @@ async function sendAttachment(
|
||||
}
|
||||
|
||||
async function sendContact(contact: PhoneContact): Promise<void> {
|
||||
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
|
||||
if (!messages.activeNumber || sending.value) return
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
@@ -901,12 +885,7 @@ function sampleMicrophone(): void {
|
||||
}
|
||||
|
||||
async function startVoiceRecording(): Promise<void> {
|
||||
if (
|
||||
!activeCanMessage.value ||
|
||||
activeServiceLine.value ||
|
||||
recording.value ||
|
||||
recordingStarting.value
|
||||
) {
|
||||
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
|
||||
return
|
||||
}
|
||||
emojiOpen.value = false
|
||||
@@ -1062,27 +1041,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(activeServiceLine, (serviceLine) => {
|
||||
if (!serviceLine) return
|
||||
const discarded = Boolean(
|
||||
shareDraft.value ||
|
||||
pendingAttachments.value.length ||
|
||||
recording.value ||
|
||||
recordingStarting.value,
|
||||
)
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
shareDraft.value = null
|
||||
pendingAttachments.value = []
|
||||
if (recording.value) {
|
||||
discardRecording = true
|
||||
cancelVoiceRecording()
|
||||
} else if (recordingStarting.value) {
|
||||
cleanupRecorder()
|
||||
}
|
||||
if (discarded) showToast(errorText('service_line_text_only'))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
discardRecording = true
|
||||
cleanupRecorder()
|
||||
@@ -1656,7 +1614,7 @@ onBeforeUnmount(() => {
|
||||
</SkyScrollArea>
|
||||
|
||||
<section
|
||||
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
|
||||
v-if="activeCanMessage && attachmentMenuOpen"
|
||||
class="messages-attachment-menu"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1701,9 +1659,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkySheet
|
||||
class="messages-media-picker-sheet"
|
||||
:opened="
|
||||
activeCanMessage && !activeServiceLine && attachmentPicker !== null
|
||||
"
|
||||
:opened="activeCanMessage && attachmentPicker !== null"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
attachmentPicker === 'contacts'
|
||||
@@ -1838,7 +1794,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
|
||||
v-if="activeCanMessage && shareDraft && !recording"
|
||||
class="shared-composer-preview"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1854,7 +1810,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="activeCanMessage && !activeServiceLine && recording"
|
||||
v-if="activeCanMessage && recording"
|
||||
class="messages-recorder"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1942,7 +1898,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="messages-sky-composer-row">
|
||||
<SkyGlass
|
||||
v-if="!activeServiceLine"
|
||||
component="button"
|
||||
type="button"
|
||||
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
|
||||
@@ -1979,7 +1934,7 @@ onBeforeUnmount(() => {
|
||||
<ArrowUpCircle :size="29" :stroke-width="2.4" />
|
||||
</SkyLink>
|
||||
<SkyLink
|
||||
v-else-if="!activeServiceLine"
|
||||
v-else
|
||||
icon-only
|
||||
class="messages-sky-messagebar__send"
|
||||
:disabled="sending || recordingStarting"
|
||||
|
||||
@@ -540,25 +540,6 @@ function buildStructure(value, scope, path) {
|
||||
if (scope === 'config' && path === 'Phone.Keybind') {
|
||||
return { kind: 'optionalString' }
|
||||
}
|
||||
if (
|
||||
scope === 'config' &&
|
||||
/^Radio\.LockedChannels\.\d+\.jobs$/.test(path) &&
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
return {
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
buildStructure(child, scope, `${path}.${key}`),
|
||||
]),
|
||||
),
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
}
|
||||
if (
|
||||
scope === 'config' &&
|
||||
path === 'Companies.Definitions' &&
|
||||
|
||||
@@ -193,29 +193,6 @@ describe('admin configurator fixture', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('allows custom jobs in locked radio channel entries', () => {
|
||||
const radio = loadConfiguratorSections()
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === 'Radio')
|
||||
const lockedChannels = radio?.structure?.fields?.LockedChannels
|
||||
const jobs = lockedChannels?.items?.[0]?.fields?.jobs
|
||||
|
||||
expect(jobs).toMatchObject({
|
||||
fields: {
|
||||
ambulance: { kind: 'value', valueType: 'boolean' },
|
||||
police: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
})
|
||||
expect(lockedChannels?.template?.fields?.jobs).toMatchObject({
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes fixed schemas for every empty configurable collection', () => {
|
||||
const fields = loadConfiguratorSections().flatMap(
|
||||
(section) => section.fields,
|
||||
|
||||
@@ -1978,7 +1978,7 @@ const contacts = [
|
||||
},
|
||||
{
|
||||
canCall: true,
|
||||
canMessage: true,
|
||||
canMessage: false,
|
||||
companyId: 'police',
|
||||
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
id: 'company:police',
|
||||
@@ -4187,7 +4187,7 @@ const companyProfiles = [
|
||||
availability: 'available',
|
||||
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
|
||||
canCall: true,
|
||||
canMessage: true,
|
||||
canMessage: false,
|
||||
categoryId: 'public_services',
|
||||
categoryName: 'Public Services',
|
||||
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
|
||||
|
||||
@@ -450,8 +450,8 @@ Config.Garage = {
|
||||
}
|
||||
|
||||
Config.Housing = {
|
||||
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
|
||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
||||
MaximumProperties = 50,
|
||||
OverviewRequestsPerMinute = 30,
|
||||
ActionsPerMinute = 12,
|
||||
@@ -1190,7 +1190,7 @@ if IsDuplicityVersion() then
|
||||
Number = "911",
|
||||
AutoContact = true,
|
||||
CanCall = true,
|
||||
CanMessage = true,
|
||||
CanMessage = false,
|
||||
Routing = "round_robin",
|
||||
MinimumGrade = 0,
|
||||
},
|
||||
|
||||
@@ -695,7 +695,6 @@ Locales["de"] = {
|
||||
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
|
||||
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
|
||||
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
|
||||
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
|
||||
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
|
||||
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
|
||||
},
|
||||
@@ -2022,11 +2021,10 @@ Locales["de"] = {
|
||||
},
|
||||
account = {
|
||||
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
|
||||
apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
|
||||
apps = "Installation von Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
|
||||
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
|
||||
uninstallTitle = "Diese App deinstallieren?",
|
||||
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
|
||||
uninstallFailed = "Die App konnte nicht deinstalliert werden. Bitte versuche es erneut.",
|
||||
},
|
||||
details = {
|
||||
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
|
||||
|
||||
@@ -695,7 +695,6 @@ Locales["en"] = {
|
||||
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
|
||||
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
|
||||
messaging_unavailable = "This company contact does not accept messages.",
|
||||
service_line_text_only = "Company service lines currently accept text messages only.",
|
||||
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
|
||||
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
|
||||
},
|
||||
@@ -2026,7 +2025,6 @@ Locales["en"] = {
|
||||
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
|
||||
uninstallTitle = "Uninstall this app?",
|
||||
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
|
||||
uninstallFailed = "The app could not be uninstalled. Please try again.",
|
||||
},
|
||||
details = {
|
||||
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
|
||||
|
||||
@@ -695,7 +695,6 @@ Locales["es"] = {
|
||||
gif_provider_rate_limited = "GIPHY está ocupado. Inténtalo de nuevo en un momento.", gif_provider_failed = "La búsqueda de GIFs está temporalmente inaccesible.",
|
||||
self_message = "No puedes enviar mensajes a tu propio número.", recipient_not_found = "Ese número no está disponible.", blocked = "Este contacto ha bloqueado las llamadas y los mensajes de esta SIM.",
|
||||
messaging_unavailable = "Este contacto de empresa no acepta mensajes.",
|
||||
service_line_text_only = "Las líneas de servicio de empresa solo aceptan mensajes de texto por ahora.",
|
||||
no_sim = "Este teléfono no tiene tarjeta SIM.", rate_limited = "Demasiados mensajes, inténtalo de nuevo en un minuto.",
|
||||
request_failed = "Los mensajes están temporalmente inaccesibles.", default = "El mensaje no se pudo enviar.",
|
||||
},
|
||||
@@ -2026,7 +2025,6 @@ Locales["es"] = {
|
||||
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
|
||||
uninstallTitle = "¿Desinstalar esta aplicación?",
|
||||
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
|
||||
uninstallFailed = "No se ha podido desinstalar la aplicación. Inténtalo de nuevo.",
|
||||
},
|
||||
details = {
|
||||
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.3.3'
|
||||
version '0.2.8'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
@@ -30,7 +30,9 @@ shared_scripts {
|
||||
|
||||
client_scripts {
|
||||
'config/config.lua',
|
||||
'config/locales/*.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'config/locales/es.lua',
|
||||
'source/bridge/client/callbacks.lua',
|
||||
'source/client/phone_configurator.lua',
|
||||
'source/bridge/client/framework.lua',
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
local provider_name = "tgiann"
|
||||
local resource_name = "tgiann-house"
|
||||
|
||||
local function house_reference(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
return value ~= "" and value or nil
|
||||
end
|
||||
|
||||
local function house_entrance(house)
|
||||
local success, house_data = pcall(function()
|
||||
return exports[resource_name]:getHouseData(house)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] tgiann-house:getHouseData failed for '%s': %s",
|
||||
house,
|
||||
tostring(house_data)
|
||||
)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
local entrance = type(house_data) == "table"
|
||||
and Bridge.Normalize.Coordinates(house_data.doorCoord) or nil
|
||||
if not entrance then
|
||||
return nil, "invalid_coordinates"
|
||||
end
|
||||
return entrance
|
||||
end
|
||||
|
||||
local function enrich_overview(properties)
|
||||
local result = {}
|
||||
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
|
||||
return result
|
||||
end
|
||||
|
||||
for _, property in ipairs(properties) do
|
||||
local house = type(property) == "table" and house_reference(property.providerId) or nil
|
||||
if house and property.id == provider_name .. ":" .. house then
|
||||
local entrance = house_entrance(house)
|
||||
if entrance then
|
||||
result[#result + 1] = {
|
||||
id = property.id,
|
||||
entrance = entrance,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterClientProvider(provider_name, {
|
||||
enrich_overview = enrich_overview,
|
||||
execute = function(action, data)
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
if action ~= "set_waypoint" then
|
||||
return false, "capability_unavailable"
|
||||
end
|
||||
local house = type(data) == "table" and house_reference(data.providerId) or nil
|
||||
if not house then
|
||||
return false, "invalid_property"
|
||||
end
|
||||
local entrance, error_code = house_entrance(house)
|
||||
if not entrance then
|
||||
return false, error_code
|
||||
end
|
||||
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
|
||||
return true
|
||||
end,
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
local provider_name = "tgiann"
|
||||
local resource_name = "tgiann-house"
|
||||
|
||||
local function query_owned_houses(identifier)
|
||||
local success, properties = pcall(function()
|
||||
return MySQL.query.await([[
|
||||
SELECT `name`
|
||||
FROM `tgiann_house`
|
||||
WHERE `owner` = ?
|
||||
ORDER BY `name` ASC
|
||||
]], { identifier })
|
||||
end)
|
||||
if not success or type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] tgiann-house overview query failed: %s", tostring(properties))
|
||||
return nil
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function owns_house(identifier, house)
|
||||
local success, property = pcall(function()
|
||||
return MySQL.single.await([[
|
||||
SELECT `name`
|
||||
FROM `tgiann_house`
|
||||
WHERE `name` = ? AND `owner` = ?
|
||||
LIMIT 1
|
||||
]], { house, identifier })
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] tgiann-house ownership query failed: %s", tostring(property))
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return type(property) == "table"
|
||||
end
|
||||
|
||||
local function house_from_property_id(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local house = value:match("^tgiann:(.+)$")
|
||||
return house and house ~= "" and house or nil
|
||||
end
|
||||
|
||||
local function normalized_property(house)
|
||||
return {
|
||||
id = provider_name .. ":" .. house,
|
||||
providerId = house,
|
||||
name = house,
|
||||
access = "owner",
|
||||
locked = false,
|
||||
capabilities = {
|
||||
lock = false,
|
||||
keys = false,
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = nil,
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = function(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, "housing_unavailable"
|
||||
end
|
||||
local properties = query_owned_houses(identifier)
|
||||
if not properties then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local result = {}
|
||||
local seen = {}
|
||||
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
|
||||
for _, property in ipairs(properties) do
|
||||
if #result >= maximum then
|
||||
break
|
||||
end
|
||||
local house = type(property.name) == "string" and property.name or nil
|
||||
if house and house ~= "" and not seen[house] then
|
||||
seen[house] = true
|
||||
result[#result + 1] = normalized_property(house)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end,
|
||||
prepare = function(source, action, data)
|
||||
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
|
||||
or action == "key_candidates"
|
||||
then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
if action ~= "set_waypoint" then
|
||||
return nil, "invalid_action"
|
||||
end
|
||||
|
||||
local house = house_from_property_id(data and data.propertyId)
|
||||
if not house then
|
||||
return nil, "invalid_property"
|
||||
end
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, "housing_unavailable"
|
||||
end
|
||||
local owned, error_code = owns_house(identifier, house)
|
||||
if owned == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
if not owned then
|
||||
return nil, "property_access_denied"
|
||||
end
|
||||
return { providerId = house }
|
||||
end,
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
SkyPhoneFocus = {}
|
||||
|
||||
local blocked_phone_controls = { 24, 140, 141, 142, 199, 200, 257, 263, 264 }
|
||||
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||
local focused_control_groups = { 0, 1, 2 }
|
||||
local hold_to_look_enabled = false
|
||||
|
||||
@@ -337,6 +337,11 @@ local function validate_configuration(configuration)
|
||||
tostring(line.Routing)
|
||||
)
|
||||
end
|
||||
if line.CanMessage then
|
||||
return nil, ("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(
|
||||
company_id
|
||||
)
|
||||
end
|
||||
line.Number = number
|
||||
validated_definitions[company_id] = definition
|
||||
validated_definition_ids[#validated_definition_ids + 1] = company_id
|
||||
@@ -1188,7 +1193,7 @@ end
|
||||
|
||||
local function request_row(request_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
|
||||
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
|
||||
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
|
||||
r.`company_activity_revision`, r.`revision`,
|
||||
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
|
||||
@@ -1890,175 +1895,6 @@ local function notification_payload(kind, area, row)
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
|
||||
if not Config.Companies.Enabled or type(data) ~= "table"
|
||||
or data.messageType ~= "text" or not valid_uuid(data.id)
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local device, device_error = current_device(source, false)
|
||||
if not device then
|
||||
return device_error
|
||||
end
|
||||
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
|
||||
if not service_line or not service_line.canMessage then
|
||||
return { success = false, error = "messaging_unavailable" }
|
||||
end
|
||||
local body = valid_text(
|
||||
data.body,
|
||||
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
|
||||
false
|
||||
)
|
||||
if not body then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
|
||||
local request_id = uuid()
|
||||
local created_event_id = uuid()
|
||||
local status_event_id = uuid()
|
||||
local mutation_token = uuid()
|
||||
local statements = {
|
||||
{
|
||||
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
|
||||
params = { device.sim_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_requests`
|
||||
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
|
||||
`company_activity_revision`)
|
||||
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
|
||||
FROM `sky_phone_sims` sim
|
||||
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
|
||||
WHERE sim.`id` = ? AND NOT EXISTS (
|
||||
SELECT 1 FROM `sky_phone_company_requests` existing
|
||||
WHERE existing.`company_id` = profile.`company_id`
|
||||
AND existing.`customer_sim_id` = sim.`id`
|
||||
AND existing.`channel` = 'service_line'
|
||||
AND existing.`status` NOT IN ('completed', 'cancelled')
|
||||
)
|
||||
]],
|
||||
params = {
|
||||
request_id,
|
||||
body,
|
||||
service_line.companyId,
|
||||
device.sim_id,
|
||||
},
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_request_events`
|
||||
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
|
||||
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
|
||||
FROM `sky_phone_company_requests` WHERE `id` = ?
|
||||
]],
|
||||
params = { created_event_id, request_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_request_events`
|
||||
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
|
||||
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
|
||||
FROM `sky_phone_company_requests`
|
||||
WHERE `company_id` = ? AND `customer_sim_id` = ?
|
||||
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
|
||||
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
|
||||
]],
|
||||
params = { status_event_id, service_line.companyId, device.sim_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
UPDATE `sky_phone_company_requests`
|
||||
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
|
||||
`company_activity_revision` = `company_activity_revision` + 1,
|
||||
`revision` = `revision` + 1, `mutation_token` = ?
|
||||
WHERE `company_id` = ? AND `customer_sim_id` = ?
|
||||
AND `channel` = 'service_line'
|
||||
AND `status` NOT IN ('completed', 'cancelled')
|
||||
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
|
||||
]],
|
||||
params = { mutation_token, service_line.companyId, device.sim_id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_company_request_messages`
|
||||
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
|
||||
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
|
||||
FROM `sky_phone_company_requests`
|
||||
WHERE `mutation_token` = ? LIMIT 1
|
||||
]],
|
||||
params = { data.id, body, mutation_token },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_sms_messages`
|
||||
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
|
||||
`message_type`, `body`)
|
||||
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
|
||||
FROM `sky_phone_sims` sim
|
||||
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
|
||||
WHERE sim.`id` = ?
|
||||
]],
|
||||
params = {
|
||||
data.id,
|
||||
service_line.number,
|
||||
body,
|
||||
data.id,
|
||||
device.sim_id,
|
||||
},
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
|
||||
local routed = Bridge.Database.Query([[
|
||||
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
|
||||
FROM `sky_phone_sms_messages` sms
|
||||
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
|
||||
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
|
||||
LEFT JOIN `sky_phone_company_request_events` created
|
||||
ON created.`id` = ? AND created.`request_id` = request.`id`
|
||||
WHERE sms.`id` = ? LIMIT 1
|
||||
]], { created_event_id, data.id })
|
||||
if not routed[1] then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Service-line message %s committed without a complete route.",
|
||||
tostring(data.id),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local row = request_row(routed[1].request_id)
|
||||
if not row then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
|
||||
emit_request_change(row, true, true, source)
|
||||
if routed[1].created_event_id then
|
||||
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
|
||||
"newRequest",
|
||||
"work",
|
||||
row
|
||||
), source)
|
||||
elseif row.assigned_identifier then
|
||||
notify_identifier(
|
||||
row.assigned_identifier,
|
||||
row.company_id,
|
||||
"sky_phone:companies:notification",
|
||||
notification_payload("newMessage", "work", row)
|
||||
)
|
||||
end
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
messageId = data.id,
|
||||
requestId = row.id,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
|
||||
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
|
||||
if not allowed then
|
||||
@@ -2324,11 +2160,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
then
|
||||
return { success = false, error = "invalid_status" }
|
||||
end
|
||||
local service_line = access.row.channel == "service_line"
|
||||
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
|
||||
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
|
||||
return { success = false, error = "messaging_unavailable" }
|
||||
end
|
||||
local message_id = uuid()
|
||||
local mutation_token = uuid()
|
||||
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
|
||||
@@ -2368,37 +2199,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
|
||||
}
|
||||
end
|
||||
if service_line then
|
||||
if access.audience == "customer" then
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_sms_messages`
|
||||
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
|
||||
`message_type`, `body`)
|
||||
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
|
||||
FROM `sky_phone_company_request_messages` message
|
||||
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
|
||||
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
|
||||
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
|
||||
]],
|
||||
params = { service_line.number, message_id },
|
||||
}
|
||||
else
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_sms_messages`
|
||||
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
|
||||
`message_type`, `body`)
|
||||
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
|
||||
FROM `sky_phone_company_request_messages` message
|
||||
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
|
||||
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
|
||||
WHERE message.`id` = ? AND message.`sender_type` = 'company'
|
||||
]],
|
||||
params = { service_line.number, message_id },
|
||||
}
|
||||
end
|
||||
end
|
||||
if new_status ~= access.row.status then
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
@@ -2413,32 +2213,16 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local inserted = Bridge.Database.Query([[
|
||||
SELECT message.`id`, sms.`id` AS `sms_id`
|
||||
FROM `sky_phone_company_request_messages` message
|
||||
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
|
||||
WHERE message.`id` = ? LIMIT 1
|
||||
]], { message_id })
|
||||
local inserted = Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
|
||||
{ message_id }
|
||||
)
|
||||
if not inserted[1] then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
if service_line and not inserted[1].sms_id then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Company request message %s committed without its service-line SMS.",
|
||||
tostring(message_id),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local row = request_row(request_id)
|
||||
emit_request_change(row, true, true, source)
|
||||
if access.audience == "customer" then
|
||||
if service_line then
|
||||
TriggerClientEvent("sky_phone:messages:changed", source, {
|
||||
phoneNumber = service_line.number,
|
||||
})
|
||||
end
|
||||
local notification = notification_payload("newMessage", "work", row)
|
||||
if row.assigned_identifier then
|
||||
notify_identifier(
|
||||
@@ -2448,12 +2232,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
|
||||
notification
|
||||
)
|
||||
end
|
||||
elseif service_line then
|
||||
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
|
||||
phoneNumber = service_line.number,
|
||||
sender = service_line.name,
|
||||
voice = false,
|
||||
})
|
||||
else
|
||||
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
|
||||
"newMessage",
|
||||
|
||||
@@ -6,12 +6,7 @@ local exchange_lock = false
|
||||
local markets = {}
|
||||
local market_order = {}
|
||||
local market_dynamics = {}
|
||||
local market_state = {}
|
||||
local market_history = {}
|
||||
local market_daily_buckets = {}
|
||||
local market_cursor = 1
|
||||
local market_daily_bucket_seconds = 5 * 60
|
||||
local market_persistence_interval = market_daily_bucket_seconds * 1000
|
||||
local global_market_trend = 0
|
||||
local global_market_cycle = {
|
||||
direction = 0,
|
||||
@@ -375,144 +370,6 @@ 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
|
||||
@@ -594,7 +451,20 @@ local function balance(account, asset)
|
||||
row and (tonumber(row.version) or 0) or 0
|
||||
end
|
||||
|
||||
local function market_rows()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
||||
FROM `sky_phone_crypto_markets`
|
||||
]], {})
|
||||
local indexed = {}
|
||||
for _, row in ipairs(rows) do
|
||||
indexed[row.id] = row
|
||||
end
|
||||
return indexed
|
||||
end
|
||||
|
||||
local function market_dtos(selected_market_ids)
|
||||
local current = market_rows()
|
||||
local selected = nil
|
||||
if selected_market_ids then
|
||||
selected = {}
|
||||
@@ -602,17 +472,28 @@ local function market_dtos(selected_market_ids)
|
||||
selected[market_id] = true
|
||||
end
|
||||
end
|
||||
local daily_rows = Bridge.Database.Query([[
|
||||
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
|
||||
FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||
GROUP BY `market_id`
|
||||
]], {})
|
||||
local daily = {}
|
||||
for _, daily_row in ipairs(daily_rows) do
|
||||
daily[daily_row.market_id] = daily_row
|
||||
end
|
||||
local result = {}
|
||||
local timestamp = os.time()
|
||||
for _, market_id in ipairs(market_order) do
|
||||
if not selected or selected[market_id] then
|
||||
local config = markets[market_id]
|
||||
local row = market_state[market_id]
|
||||
local history = market_history[market_id]
|
||||
local row = current[market_id]
|
||||
local ticks = Bridge.Database.Query([[
|
||||
SELECT `price` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
]], { market_id, Config.Crypto.SparklinePoints })
|
||||
local prices = {}
|
||||
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
|
||||
for index = first_history_index, #history do
|
||||
prices[#prices + 1] = history[index]
|
||||
for index = #ticks, 1, -1 do
|
||||
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
|
||||
end
|
||||
if #prices == 0 then
|
||||
prices[1] = tonumber(row.price)
|
||||
@@ -628,7 +509,7 @@ local function market_dtos(selected_market_ids)
|
||||
end
|
||||
local first = prices[1]
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
|
||||
local daily_range = daily[market_id]
|
||||
result[#result + 1] = {
|
||||
id = market_id,
|
||||
symbol = config.Symbol,
|
||||
@@ -638,13 +519,13 @@ local function market_dtos(selected_market_ids)
|
||||
price = decimal_string(price, Config.Crypto.PriceScale),
|
||||
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
|
||||
enabled = row.status == "active",
|
||||
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
|
||||
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
|
||||
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
|
||||
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
|
||||
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
|
||||
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
|
||||
priceHistory = price_history,
|
||||
sparkline = sparkline,
|
||||
updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
|
||||
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -686,12 +567,13 @@ end
|
||||
|
||||
local function bootstrap(profile)
|
||||
local cash = balance(account_id(profile.id), "CASH")
|
||||
local current_markets = market_rows()
|
||||
local holdings = {}
|
||||
local portfolio = cash
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local available = balance(account_id(profile.id), market_id)
|
||||
if available > 0 then
|
||||
local price = tonumber(market_state[market_id].price) or 0
|
||||
local price = tonumber(current_markets[market_id].price) or 0
|
||||
local value = math.floor(available * price / Config.Crypto.AssetScale)
|
||||
local fill = Bridge.Database.Query([[
|
||||
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
|
||||
@@ -1162,7 +1044,10 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
|
||||
if not config or not side or not quantity then
|
||||
return { success = false, error = "invalid_quantity" }
|
||||
end
|
||||
local market = market_state[config.Id]
|
||||
local market = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ config.Id }
|
||||
)[1]
|
||||
if not market or market.status ~= "active" then
|
||||
return { success = false, error = "market_unavailable" }
|
||||
end
|
||||
@@ -1242,8 +1127,9 @@ local function execute_trade(profile, data)
|
||||
or { success = false, error = "duplicate_request" }
|
||||
end
|
||||
local quote = Bridge.Database.Query([[
|
||||
SELECT quote.*
|
||||
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
|
||||
FROM `sky_phone_crypto_quotes` quote
|
||||
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
|
||||
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
|
||||
]], { data.quoteId, profile.id })[1]
|
||||
if not quote or quote.consumed_operation_id then
|
||||
@@ -1256,10 +1142,7 @@ local function execute_trade(profile, data)
|
||||
if not expiry or tonumber(expiry.expires_at) < os.time() then
|
||||
return { success = false, error = "quote_expired" }
|
||||
end
|
||||
local current_market = market_state[quote.market_id]
|
||||
if not current_market or current_market.status ~= "active"
|
||||
or current_market.version ~= tonumber(quote.market_version)
|
||||
then
|
||||
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
|
||||
return { success = false, error = "quote_expired" }
|
||||
end
|
||||
local quantity = tonumber(quote.quantity)
|
||||
@@ -1484,7 +1367,6 @@ end)
|
||||
ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
load_market_cache()
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||
@@ -1625,16 +1507,6 @@ local function start_crypto_schedulers()
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(market_persistence_interval)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(persist_market_cache)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
@@ -1664,17 +1536,16 @@ local function start_crypto_schedulers()
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
local history_limit = math.min(
|
||||
Config.Crypto.HistoryRetentionTicks,
|
||||
Config.Crypto.SparklinePoints
|
||||
)
|
||||
market_count = math.min(market_count, #market_order)
|
||||
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = market_state[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
@@ -1743,19 +1614,22 @@ local function start_crypto_schedulers()
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = row.version + 1
|
||||
local updated_at = os.time()
|
||||
row.price = next_price
|
||||
row.version = next_version
|
||||
row.updated_at = updated_at
|
||||
row.dirty = true
|
||||
local history = market_history[market_id]
|
||||
history[#history + 1] = next_price
|
||||
if #history > history_limit then
|
||||
table.remove(history, 1)
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
end
|
||||
add_market_daily_price(market_daily_buckets[market_id], next_price, updated_at)
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
end
|
||||
end
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
@@ -1771,26 +1645,11 @@ local function start_crypto_schedulers()
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
if exchange_lock then
|
||||
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
|
||||
return
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
if not persist_market_cache() then
|
||||
return
|
||||
end
|
||||
initialize_markets()
|
||||
load_market_cache()
|
||||
start_crypto_schedulers()
|
||||
end)
|
||||
initialize_markets()
|
||||
start_crypto_schedulers()
|
||||
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)
|
||||
|
||||
@@ -2710,7 +2710,6 @@ local schema = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "channel", type = "ENUM('app','service_line') NOT NULL DEFAULT 'app'" },
|
||||
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
|
||||
@@ -2730,7 +2729,6 @@ local schema = {
|
||||
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
|
||||
{ name = "idx_sky_phone_company_requests_service_line", columns = "(`company_id`, `customer_sim_id`, `channel`, `status`, `updated_at`, `id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
|
||||
|
||||
@@ -97,14 +97,6 @@ local function current_device(source)
|
||||
return device
|
||||
end
|
||||
|
||||
local function resolve_message_recipient(value)
|
||||
local service_line = SkyPhoneCompanies.GetServiceLine(value)
|
||||
if service_line then
|
||||
return service_line.number, service_line
|
||||
end
|
||||
return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix), nil
|
||||
end
|
||||
|
||||
local function shared_contact(device, contact_id)
|
||||
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
|
||||
return nil, "invalid_contact"
|
||||
@@ -374,7 +366,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
local number = resolve_message_recipient(data.phoneNumber)
|
||||
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
@@ -415,7 +407,11 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
|
||||
local numbers = {}
|
||||
local seen = {}
|
||||
for index = 1, #data.phoneNumbers do
|
||||
local number = resolve_message_recipient(data.phoneNumbers[index])
|
||||
local number = SkyPhoneSimNumber.Normalize(
|
||||
data.phoneNumbers[index],
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
)
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
@@ -487,7 +483,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
local number, service_line = resolve_message_recipient(data.phoneNumber)
|
||||
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
@@ -540,42 +536,6 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
else
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
if service_line then
|
||||
if not service_line.canMessage then
|
||||
return { success = false, error = "messaging_unavailable" }
|
||||
end
|
||||
if message_type ~= "text" then
|
||||
return { success = false, error = "service_line_text_only" }
|
||||
end
|
||||
local id = uuid()
|
||||
local routed = SkyPhoneCompanies.RouteServiceLineMessage(source, {
|
||||
id = id,
|
||||
body = body,
|
||||
messageType = message_type,
|
||||
phoneNumber = number,
|
||||
})
|
||||
if not routed or not routed.success then
|
||||
return routed or { success = false, error = "request_failed" }
|
||||
end
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`,
|
||||
`media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`,
|
||||
`read_at`, `created_at`, 'sent' AS `direction`
|
||||
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
|
||||
]], { id })
|
||||
if not rows[1] then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Routed service-line SMS %s could not be read back.",
|
||||
tostring(id),
|
||||
{ always = true }
|
||||
)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local message = format_message(rows[1])
|
||||
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
|
||||
return { success = true, data = message }
|
||||
end
|
||||
local recipients = Bridge.Database.Query([[
|
||||
SELECT s.`id`, s.`phone_number`
|
||||
FROM `sky_phone_sims` s
|
||||
|
||||
@@ -637,21 +637,6 @@ local function build_structure(value, scope, path)
|
||||
if scope == "config" and path == "Phone.Keybind" then
|
||||
return { kind = "optionalString" }
|
||||
end
|
||||
if scope == "config"
|
||||
and path:match("^Radio%.LockedChannels%.%d+%.jobs$")
|
||||
and value_type == "table"
|
||||
then
|
||||
local fields = {}
|
||||
for key, child in pairs(value) do
|
||||
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
||||
end
|
||||
return {
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
template = { kind = "value", valueType = "boolean" },
|
||||
}
|
||||
end
|
||||
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
|
||||
local keys = {}
|
||||
for key in pairs(value) do
|
||||
@@ -1304,8 +1289,8 @@ local function migrate_police_request_defaults()
|
||||
)
|
||||
end
|
||||
|
||||
local function migrate_police_service_line_messaging()
|
||||
local migration_name = "sky-phone:configurator:service-line-messaging:v2"
|
||||
local function migrate_unsupported_company_message_defaults()
|
||||
local migration_name = "sky-phone:configurator:unsupported-company-message-defaults:v1"
|
||||
local completed = Bridge.Database.Query(
|
||||
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
|
||||
{ migration_name }
|
||||
@@ -1316,17 +1301,22 @@ local function migrate_police_service_line_messaging()
|
||||
|
||||
local row = read_stored_row()
|
||||
local config_payload = decode_payload(row.config_payload, "config")
|
||||
local police = config_payload.Companies
|
||||
local definitions = 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
|
||||
local migrated_companies = {}
|
||||
if type(definitions) == "table" then
|
||||
for company_id, definition in pairs(definitions) do
|
||||
local line = type(definition) == "table" and definition.ServiceLine or nil
|
||||
if type(line) == "table" and line.CanMessage == true then
|
||||
line.CanMessage = false
|
||||
migrated_companies[#migrated_companies + 1] = tostring(company_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(migrated_companies)
|
||||
|
||||
local statements = {}
|
||||
if migrated then
|
||||
if #migrated_companies > 0 then
|
||||
statements[#statements + 1] = {
|
||||
query = ([[
|
||||
UPDATE `%s`
|
||||
@@ -1344,13 +1334,13 @@ local function migrate_police_service_line_messaging()
|
||||
params = {
|
||||
migration_name,
|
||||
"sky-phone",
|
||||
json.encode({ police = migrated }),
|
||||
json.encode({ companies = migrated_companies }),
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Could not enable Phone Configurator police service-line messaging.")
|
||||
error("[sky_phone] Could not migrate unsupported Phone Configurator service-line messaging defaults.")
|
||||
end
|
||||
if not migrated then
|
||||
if #migrated_companies == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1360,7 +1350,8 @@ local function migrate_police_service_line_messaging()
|
||||
SkyPhoneConfigurator.Broadcast(-1)
|
||||
Bridge.Debug(
|
||||
"info",
|
||||
"[sky_phone] Enabled Phone Configurator police service-line messaging.",
|
||||
"[sky_phone] Disabled unsupported Phone Configurator service-line messaging for: %s",
|
||||
table.concat(migrated_companies, ", "),
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
@@ -1387,7 +1378,7 @@ 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)
|
||||
Bridge.Database.AfterMigration("sky_phone", migrate_unsupported_company_message_defaults)
|
||||
|
||||
function SkyPhoneConfigurator.GetAdminData()
|
||||
local data = build_admin_data()
|
||||
|
||||
@@ -1836,21 +1836,16 @@ CreateThread(function()
|
||||
while true do
|
||||
local recovery_success, recovery_rows = pcall(
|
||||
Bridge.Database.Query,
|
||||
(([[
|
||||
SELECT r.`id`, r.`status`, r.`refund_status`, r.`price`,
|
||||
passenger.`owner_identifier` AS `passenger_identifier`
|
||||
FROM `sky_phone_skyride_rides` r
|
||||
INNER JOIN `sky_phone_skyride_profiles` passenger
|
||||
ON passenger.`id` = r.`passenger_profile_id`
|
||||
WHERE (
|
||||
r.`refund_status` = 'pending'
|
||||
AND r.`status` = 'cancelled'
|
||||
) OR (
|
||||
r.`refund_status` IN ('pending','completed')
|
||||
AND r.`status` = 'payment_pending'
|
||||
AND r.`updated_at` <= DATE_SUB(
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL %d SECOND
|
||||
ride_select .. (([[
|
||||
WHERE r.`refund_status` IN ('pending','completed')
|
||||
AND (
|
||||
r.`status` = 'cancelled'
|
||||
OR (
|
||||
r.`status` = 'payment_pending'
|
||||
AND r.`updated_at` <= DATE_SUB(
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL %d SECOND
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY r.`updated_at` ASC
|
||||
|
||||
@@ -435,8 +435,8 @@ Config.Garage = {
|
||||
}
|
||||
|
||||
Config.Housing = {
|
||||
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
|
||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
||||
MaximumProperties = 50,
|
||||
OverviewRequestsPerMinute = 30,
|
||||
ActionsPerMinute = 12,
|
||||
@@ -1175,7 +1175,7 @@ if IsDuplicityVersion() then
|
||||
Number = "911",
|
||||
AutoContact = true,
|
||||
CanCall = true,
|
||||
CanMessage = true,
|
||||
CanMessage = false,
|
||||
Routing = "round_robin",
|
||||
MinimumGrade = 0,
|
||||
},
|
||||
|
||||
@@ -252,7 +252,7 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
|
||||
all_controls_disabled = {}
|
||||
firing_disabled = false
|
||||
SkyPhoneFocus.ApplyGameInputControls(true)
|
||||
for _, control in ipairs({ 24, 140, 141, 142, 199, 200, 257, 263, 264 }) do
|
||||
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
|
||||
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
|
||||
end
|
||||
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")
|
||||
|
||||
Reference in New Issue
Block a user