mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21913b585c | ||
|
|
e056d0f05c | ||
|
|
edd9677f43 | ||
|
|
97db163cd0 | ||
|
|
698420584d | ||
|
|
56901621a0 | ||
|
|
7b4e445394 | ||
|
|
7bbafd0026 | ||
|
|
300329825f | ||
|
|
93d2263f14 | ||
|
|
dca0868753 | ||
|
|
36b859cabb | ||
|
|
d0317a35c0 | ||
|
|
074117fe33 | ||
|
|
a1025f4520 | ||
|
|
7b8bab75a8 | ||
|
|
c0492a70cb | ||
|
|
05169acbfe | ||
|
|
086b77e464 | ||
|
|
de613e2502 |
@@ -17,6 +17,8 @@ for (const requiredFragment of [
|
|||||||
"fx_version 'cerulean'",
|
"fx_version 'cerulean'",
|
||||||
"node_version '22'",
|
"node_version '22'",
|
||||||
"use_experimental_fxv2_oal 'yes'",
|
"use_experimental_fxv2_oal 'yes'",
|
||||||
|
"'source/server/nui_build_check.lua'",
|
||||||
|
"'source/html/sounds/**'",
|
||||||
"ui_page 'source/html/index.html'",
|
"ui_page 'source/html/index.html'",
|
||||||
]) {
|
]) {
|
||||||
if (!manifest.includes(requiredFragment)) {
|
if (!manifest.includes(requiredFragment)) {
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ as project instructions, not as optional documentation.
|
|||||||
proof.
|
proof.
|
||||||
- Do not hand-edit generated frontend output.
|
- Do not hand-edit generated frontend output.
|
||||||
|
|
||||||
|
## Phone Configurator parity (mandatory)
|
||||||
|
|
||||||
|
- Every new, changed, renamed, moved, or removed configurable value in
|
||||||
|
`sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be
|
||||||
|
reflected in the in-game Phone Configurator in the same change. Config-only
|
||||||
|
changes without matching Configurator support are incomplete and must not be
|
||||||
|
committed or merged.
|
||||||
|
- Keep the Configurator's runtime schema, defaults, value types, fixed versus
|
||||||
|
extensible collection rules, labels, descriptions, English and German
|
||||||
|
locales, SQL persistence, validation, and save/load roundtrip aligned with
|
||||||
|
the Lua configuration.
|
||||||
|
- The Configurator must remain complete when file-based configuration is
|
||||||
|
disabled. Do not introduce a setting that can only be managed by editing Lua
|
||||||
|
after `Config.PhoneConfigurator.Enabled` is enabled.
|
||||||
|
- Add or update contract/fixture coverage so configuration parity regressions
|
||||||
|
fail automated checks.
|
||||||
|
|
||||||
## Working method and verification
|
## Working method and verification
|
||||||
|
|
||||||
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||||
•
|
•
|
||||||
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
|
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
|
||||||
•
|
•
|
||||||
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
|
|||||||
|
|
||||||
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
||||||
|
|
||||||
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
|
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
|
||||||
|
|
||||||
## Why Sky Phone stands out
|
## Why Sky Phone stands out
|
||||||
|
|
||||||
@@ -160,8 +160,8 @@ Start the selected voice resource before Sky Phone.
|
|||||||
|
|
||||||
## Quick installation
|
## Quick installation
|
||||||
|
|
||||||
1. Copy the resource into your FiveM resources directory.
|
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
|
||||||
2. Keep the resource folder name `sky_phone`.
|
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
|
||||||
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
||||||
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
||||||
5. Add the required inventory items.
|
5. Add the required inventory items.
|
||||||
@@ -182,6 +182,13 @@ Replace the example framework, inventory, and voice resources with the providers
|
|||||||
|
|
||||||
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
||||||
|
|
||||||
|
### How players open the phone
|
||||||
|
|
||||||
|
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
|
||||||
|
- Players can use that inventory item or press the configured keybind (default: `F1`).
|
||||||
|
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
|
||||||
|
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Customer settings are organized in:
|
Customer settings are organized in:
|
||||||
@@ -211,6 +218,34 @@ The files contain clearly separated sections for:
|
|||||||
|
|
||||||
Restart `sky_phone` after changing Lua configuration.
|
Restart `sky_phone` after changing Lua configuration.
|
||||||
|
|
||||||
|
### In-game phone configurator
|
||||||
|
|
||||||
|
Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
Config.PhoneConfigurator = {
|
||||||
|
Enabled = true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates
|
||||||
|
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone
|
||||||
|
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
|
||||||
|
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
|
||||||
|
applies the new server, client, media, app, item, command, provider, animation, and UI values through
|
||||||
|
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
|
||||||
|
|
||||||
|
Every `Config.*` value from `config.lua`, including server-only sections, and every value from
|
||||||
|
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains
|
||||||
|
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
|
||||||
|
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
|
||||||
|
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
|
||||||
|
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
|
||||||
|
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
|
||||||
|
|
||||||
|
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||||
|
shown only as configured and are replaced only when an administrator enters a new value.
|
||||||
|
|
||||||
### Language
|
### Language
|
||||||
|
|
||||||
Available locales:
|
Available locales:
|
||||||
@@ -344,7 +379,7 @@ With unique phones, using an inventory item selects that exact handset whenever
|
|||||||
|
|
||||||
| SIM mode | Behavior |
|
| SIM mode | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
|
||||||
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
||||||
|
|
||||||
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
||||||
@@ -534,7 +569,11 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
|
|||||||
|
|
||||||
### Companies
|
### Companies
|
||||||
|
|
||||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
|
Company jobs, public profiles, service numbers, services, permissions, locations, and default
|
||||||
|
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
|
||||||
|
shipped jobs: add any number of company IDs in the in-game configurator and fill the freely
|
||||||
|
configurable `Job` value in the automatically generated full company template. Existing job keys can
|
||||||
|
also be removed; the remaining Companies settings stay available as normal individual fields.
|
||||||
|
|
||||||
### Weazel News
|
### Weazel News
|
||||||
|
|
||||||
@@ -602,12 +641,20 @@ pnpm build
|
|||||||
|
|
||||||
### The phone item does nothing
|
### The phone item does nothing
|
||||||
|
|
||||||
|
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
|
||||||
- Confirm the framework and inventory are supported and started first.
|
- Confirm the framework and inventory are supported and started first.
|
||||||
- Confirm the item name matches `Config.Phone.Item`.
|
- Confirm the item name matches `Config.Phone.Item`.
|
||||||
- Confirm the item is usable.
|
- Confirm the item is usable.
|
||||||
- In unique mode, confirm the phone is non-stackable.
|
- In unique mode, confirm the phone is non-stackable.
|
||||||
- Check the server console for inventory adapter warnings.
|
- Check the server console for inventory adapter warnings.
|
||||||
|
|
||||||
|
### The resource starts but the phone UI is missing
|
||||||
|
|
||||||
|
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
|
||||||
|
- Install the latest published release package rather than GitHub's automatically generated source archive.
|
||||||
|
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
|
||||||
|
- Developers working from source must run the frontend production build before starting the resource.
|
||||||
|
|
||||||
### Calls connect without audio
|
### Calls connect without audio
|
||||||
|
|
||||||
- Confirm the configured voice resource is running.
|
- Confirm the configured voice resource is running.
|
||||||
|
|||||||
@@ -26,6 +26,100 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
## Inter 4.1
|
||||||
|
|
||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||||
|
DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
## Framework7 documentation placeholder media
|
## Framework7 documentation placeholder media
|
||||||
|
|
||||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||||
|
|||||||
+121
-11
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { SkyProvider } from '@/ui'
|
import { SkyProvider } from '@/ui'
|
||||||
|
import AdminPanel from '@/components/AdminPanel.vue'
|
||||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||||
@@ -56,6 +57,7 @@ import { useNotesStore } from '@/stores/notes'
|
|||||||
import { useMemosStore } from '@/stores/memos'
|
import { useMemosStore } from '@/stores/memos'
|
||||||
import { useWeatherStore } from '@/stores/weather'
|
import { useWeatherStore } from '@/stores/weather'
|
||||||
import { useEasyShareStore } from '@/stores/easyshare'
|
import { useEasyShareStore } from '@/stores/easyshare'
|
||||||
|
import { useRadioStore } from '@/stores/radio'
|
||||||
import {
|
import {
|
||||||
useNotificationsStore,
|
useNotificationsStore,
|
||||||
type PhoneNotification,
|
type PhoneNotification,
|
||||||
@@ -69,19 +71,26 @@ import type {
|
|||||||
CompanyChangedPayload,
|
CompanyChangedPayload,
|
||||||
CompanyUnreadCounts,
|
CompanyUnreadCounts,
|
||||||
} from '@/types/companies'
|
} from '@/types/companies'
|
||||||
import type { PhoneCall } from '@/types/phone'
|
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
|
||||||
|
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||||
import type { EasyShareEvent } from '@/types/easyshare'
|
import type { EasyShareEvent } from '@/types/easyshare'
|
||||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||||
import type { CityWarnEventData } from '@/types/citywarn'
|
import type { CityWarnEventData } from '@/types/citywarn'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import {
|
||||||
|
installPhoneAudioController,
|
||||||
|
setPhoneOutputVolume,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
import { formatTimer } from '@/utils/clock'
|
import { formatTimer } from '@/utils/clock'
|
||||||
import { parsePhonePreferences } from '@/utils/preferences'
|
import { parsePhonePreferences } from '@/utils/preferences'
|
||||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||||
|
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||||
import SpringboardView from '@/views/SpringboardView.vue'
|
import SpringboardView from '@/views/SpringboardView.vue'
|
||||||
|
|
||||||
type AppMessage = {
|
type AppMessage = {
|
||||||
|
openHome?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
data?:
|
data?:
|
||||||
| CalendarReminderData
|
| CalendarReminderData
|
||||||
@@ -107,8 +116,13 @@ type AppMessage = {
|
|||||||
| CustomAppCatalogEventData
|
| CustomAppCatalogEventData
|
||||||
| CustomAppEventData
|
| CustomAppEventData
|
||||||
| NavigationEventData
|
| NavigationEventData
|
||||||
|
| AdminPanelOpenPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminPanelOpenPayload = Required<
|
||||||
|
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
||||||
|
>
|
||||||
|
|
||||||
type CustomAppCatalogEventData = {
|
type CustomAppCatalogEventData = {
|
||||||
apps?: unknown
|
apps?: unknown
|
||||||
}
|
}
|
||||||
@@ -126,6 +140,7 @@ type NavigationEventData = {
|
|||||||
type SimPickerPayload = {
|
type SimPickerPayload = {
|
||||||
choices: SimPhoneChoice[]
|
choices: SimPhoneChoice[]
|
||||||
number: string
|
number: string
|
||||||
|
phoneNumberFormat?: PhoneNumberFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||||
@@ -313,6 +328,7 @@ const notes = useNotesStore()
|
|||||||
const memos = useMemosStore()
|
const memos = useMemosStore()
|
||||||
const weather = useWeatherStore()
|
const weather = useWeatherStore()
|
||||||
const easyShare = useEasyShareStore()
|
const easyShare = useEasyShareStore()
|
||||||
|
const radio = useRadioStore()
|
||||||
const notifications = useNotificationsStore()
|
const notifications = useNotificationsStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -335,13 +351,21 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
|
|||||||
'minesweeper',
|
'minesweeper',
|
||||||
'number-merge',
|
'number-merge',
|
||||||
])
|
])
|
||||||
|
const isDynamicIslandGalleryRoute = computed(
|
||||||
|
() => isDevelopment && route.name === 'development-dynamic-islands',
|
||||||
|
)
|
||||||
const isDevelopmentRoute = computed(
|
const isDevelopmentRoute = computed(
|
||||||
() => isDevelopment && route.name === 'development-sky-ui',
|
() =>
|
||||||
|
isDevelopment &&
|
||||||
|
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
|
||||||
)
|
)
|
||||||
const appTransitionName = computed(() =>
|
const appTransitionName = computed(() =>
|
||||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||||
)
|
)
|
||||||
const isLocked = ref(false)
|
const isLocked = ref(false)
|
||||||
|
const adminPanelOpen = ref(
|
||||||
|
isDevelopment && developmentParameters.has('adminPanel'),
|
||||||
|
)
|
||||||
const springboardEditing = ref(false)
|
const springboardEditing = ref(false)
|
||||||
const isUnlocking = ref(false)
|
const isUnlocking = ref(false)
|
||||||
const passcodeBusy = ref(false)
|
const passcodeBusy = ref(false)
|
||||||
@@ -356,9 +380,12 @@ const setupPreviewDismissed = ref(false)
|
|||||||
const setupDevelopmentSkipped = ref(false)
|
const setupDevelopmentSkipped = ref(false)
|
||||||
const setupAppearanceSelected = ref(false)
|
const setupAppearanceSelected = ref(false)
|
||||||
const pendingUnlockRoute = ref<string | null>(null)
|
const pendingUnlockRoute = ref<string | null>(null)
|
||||||
|
const openHomeRequested = ref(false)
|
||||||
const unlockedServicesLoaded = ref(false)
|
const unlockedServicesLoaded = ref(false)
|
||||||
const controlCenterOpened = ref(false)
|
const controlCenterOpened = ref(false)
|
||||||
const activitySuspended = ref(false)
|
const activitySuspended = ref(false)
|
||||||
|
const dynamicIslandExpanded = ref(false)
|
||||||
|
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
|
||||||
const simPicker = ref<SimPickerPayload | null>(null)
|
const simPicker = ref<SimPickerPayload | null>(null)
|
||||||
const setupRequired = computed(
|
const setupRequired = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -432,6 +459,12 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
|||||||
}))
|
}))
|
||||||
const phoneStageStyle = computed<CSSProperties>(() => ({
|
const phoneStageStyle = computed<CSSProperties>(() => ({
|
||||||
...phoneResolutionStyle.value,
|
...phoneResolutionStyle.value,
|
||||||
|
'--phone-live-activity-peek-height':
|
||||||
|
dynamicIslandActivity.value === 'music'
|
||||||
|
? '190px'
|
||||||
|
: dynamicIslandActivity.value === 'recording'
|
||||||
|
? '132px'
|
||||||
|
: '112px',
|
||||||
visibility: activitySuspended.value ? 'hidden' : 'visible',
|
visibility: activitySuspended.value ? 'hidden' : 'visible',
|
||||||
}))
|
}))
|
||||||
const phoneDisplayStyle = computed<CSSProperties>(() => ({
|
const phoneDisplayStyle = computed<CSSProperties>(() => ({
|
||||||
@@ -449,6 +482,7 @@ let unlockTimer: number | undefined
|
|||||||
let passcodeLockTimer: number | undefined
|
let passcodeLockTimer: number | undefined
|
||||||
let hardwareVolumeHudTimer: number | undefined
|
let hardwareVolumeHudTimer: number | undefined
|
||||||
let unlockedServicesIdle: number | undefined
|
let unlockedServicesIdle: number | undefined
|
||||||
|
let removePhoneAudioController: (() => void) | undefined
|
||||||
let phoneClosePending = false
|
let phoneClosePending = false
|
||||||
let simPickerClosePending = false
|
let simPickerClosePending = false
|
||||||
|
|
||||||
@@ -468,6 +502,7 @@ function getViewportScale(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||||
|
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||||
if (payload.device?.imei) {
|
if (payload.device?.imei) {
|
||||||
companies.bindDeviceScope(
|
companies.bindDeviceScope(
|
||||||
payload.device.imei,
|
payload.device.imei,
|
||||||
@@ -604,6 +639,8 @@ function loadUnlockedPhoneData(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function completePhoneSetup(): void {
|
function completePhoneSetup(): void {
|
||||||
|
const requestedRoute = pendingUnlockRoute.value
|
||||||
|
pendingUnlockRoute.value = null
|
||||||
setupPreviewDismissed.value = true
|
setupPreviewDismissed.value = true
|
||||||
setupAppearanceSelected.value = false
|
setupAppearanceSelected.value = false
|
||||||
isLocked.value = false
|
isLocked.value = false
|
||||||
@@ -611,7 +648,7 @@ function completePhoneSetup(): void {
|
|||||||
passcodeVisible.value = false
|
passcodeVisible.value = false
|
||||||
passcodeRequired.value = false
|
passcodeRequired.value = false
|
||||||
controlCenterOpened.value = false
|
controlCenterOpened.value = false
|
||||||
void router.replace('/')
|
void router.replace(requestedRoute ?? '/')
|
||||||
loadUnlockedPhoneData()
|
loadUnlockedPhoneData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +732,15 @@ function openDevelopmentPayphonePreview(): void {
|
|||||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||||
|
|
||||||
if (event.data?.type === 'custom-apps:catalog') {
|
if (event.data?.type === 'admin:open') {
|
||||||
|
const data = event.data.data as AdminPanelOpenPayload | undefined
|
||||||
|
if (data?.lang && data.locales && data.fallbackLocales) {
|
||||||
|
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
|
||||||
|
}
|
||||||
|
adminPanelOpen.value = true
|
||||||
|
} else if (event.data?.type === 'admin:close') {
|
||||||
|
adminPanelOpen.value = false
|
||||||
|
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||||
appCatalog.replaceCatalog(event.data.data)
|
appCatalog.replaceCatalog(event.data.data)
|
||||||
const catalogPayload = event.data.data as
|
const catalogPayload = event.data.data as
|
||||||
| { apps?: unknown; debug?: unknown }
|
| { apps?: unknown; debug?: unknown }
|
||||||
@@ -740,7 +785,12 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
isPhoneAppId(data.appId) &&
|
isPhoneAppId(data.appId) &&
|
||||||
appStore.isInstalled(data.appId)
|
appStore.isInstalled(data.appId)
|
||||||
) {
|
) {
|
||||||
void router.push(`/apps/${data.appId}`)
|
const requestedRoute = `/apps/${data.appId}`
|
||||||
|
if (setupRequired.value || isLocked.value) {
|
||||||
|
pendingUnlockRoute.value = requestedRoute
|
||||||
|
} else {
|
||||||
|
void router.push(requestedRoute)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[Navigation] Ignored an unavailable app target.')
|
console.error('[Navigation] Ignored an unavailable app target.')
|
||||||
}
|
}
|
||||||
@@ -758,6 +808,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (event.data?.type === 'app:open') {
|
} else if (event.data?.type === 'app:open') {
|
||||||
|
openHomeRequested.value = event.data.openHome === true
|
||||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||||
void syncNavigationState().then(() => nuiCall('ui:opened'))
|
void syncNavigationState().then(() => nuiCall('ui:opened'))
|
||||||
} else if (event.data?.type === 'device:updated') {
|
} else if (event.data?.type === 'device:updated') {
|
||||||
@@ -1198,7 +1249,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
loadUnlockedPhoneData()
|
loadUnlockedPhoneData()
|
||||||
}
|
}
|
||||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
const payload = event.data.data as unknown as SimPickerPayload
|
||||||
|
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||||
|
simPicker.value = payload
|
||||||
} else if (event.data?.type === 'sim:picker-close') {
|
} else if (event.data?.type === 'sim:picker-close') {
|
||||||
simPicker.value = null
|
simPicker.value = null
|
||||||
}
|
}
|
||||||
@@ -1473,6 +1526,7 @@ function onFocusOut(event: FocusEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
removePhoneAudioController = installPhoneAudioController()
|
||||||
document.addEventListener('focusin', onFocusIn)
|
document.addEventListener('focusin', onFocusIn)
|
||||||
document.addEventListener('focusout', onFocusOut)
|
document.addEventListener('focusout', onFocusOut)
|
||||||
window.addEventListener('message', onMessage)
|
window.addEventListener('message', onMessage)
|
||||||
@@ -1587,6 +1641,23 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => Boolean(dynamicIslandActivity.value || calls.activeCall),
|
||||||
|
(active) => {
|
||||||
|
void nuiCall('ui:live-activity', { active })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hardwareAlertVolume,
|
||||||
|
(volume) => {
|
||||||
|
setPhoneOutputVolume(volume / 100)
|
||||||
|
if (radio.data.connected) void radio.setVolume(volume)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => phone.isOpen,
|
() => phone.isOpen,
|
||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
@@ -1619,7 +1690,8 @@ watch(
|
|||||||
}
|
}
|
||||||
isLocked.value = setupRequired.value
|
isLocked.value = setupRequired.value
|
||||||
? false
|
? false
|
||||||
: !isDevelopment || developmentLockScreenPreview
|
: developmentLockScreenPreview ||
|
||||||
|
(!isDevelopment && phone.security.enabled)
|
||||||
passcodeRequired.value = isLocked.value && phone.security.enabled
|
passcodeRequired.value = isLocked.value && phone.security.enabled
|
||||||
unlockedServicesLoaded.value = false
|
unlockedServicesLoaded.value = false
|
||||||
controlCenterOpened.value = false
|
controlCenterOpened.value = false
|
||||||
@@ -1637,8 +1709,18 @@ watch(
|
|||||||
startPasscodeLock(passcodeRetrySeconds.value)
|
startPasscodeLock(passcodeRetrySeconds.value)
|
||||||
}
|
}
|
||||||
phone.setLaunchOrigin(null)
|
phone.setLaunchOrigin(null)
|
||||||
if (isLocked.value || setupRequired.value) void router.replace('/')
|
if (setupRequired.value) {
|
||||||
else loadUnlockedPhoneData()
|
void router.replace('/')
|
||||||
|
} else if (openHomeRequested.value) {
|
||||||
|
openHomeRequested.value = false
|
||||||
|
if (isLocked.value) pendingUnlockRoute.value = '/'
|
||||||
|
else {
|
||||||
|
void router.replace('/')
|
||||||
|
loadUnlockedPhoneData()
|
||||||
|
}
|
||||||
|
} else if (!isLocked.value) {
|
||||||
|
loadUnlockedPhoneData()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1650,6 +1732,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
removePhoneAudioController?.()
|
||||||
updateTextInputFocus(false)
|
updateTextInputFocus(false)
|
||||||
cancelUnlockedPhoneDataLoad()
|
cancelUnlockedPhoneDataLoad()
|
||||||
weather.stop()
|
weather.stop()
|
||||||
@@ -1672,6 +1755,15 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<SkyProvider
|
||||||
|
v-if="adminPanelOpen"
|
||||||
|
dark
|
||||||
|
:safe-areas="false"
|
||||||
|
accent="#74d66f"
|
||||||
|
accent-soft="rgba(116, 214, 111, 0.14)"
|
||||||
|
>
|
||||||
|
<AdminPanel @close="adminPanelOpen = false" />
|
||||||
|
</SkyProvider>
|
||||||
<PhoneMediaCapture />
|
<PhoneMediaCapture />
|
||||||
<PhoneMemoRecorder />
|
<PhoneMemoRecorder />
|
||||||
<RadioHud />
|
<RadioHud />
|
||||||
@@ -1688,12 +1780,15 @@ onBeforeUnmount(() => {
|
|||||||
phone.isOpen ||
|
phone.isOpen ||
|
||||||
notifications.current ||
|
notifications.current ||
|
||||||
calls.activeCall ||
|
calls.activeCall ||
|
||||||
|
dynamicIslandActivity ||
|
||||||
notifications.devicePreviews.length
|
notifications.devicePreviews.length
|
||||||
"
|
"
|
||||||
class="phone-stage"
|
class="phone-stage"
|
||||||
:class="{
|
:class="{
|
||||||
'phone-stage--browser-preview': isBrowserPreview,
|
'phone-stage--browser-preview': isBrowserPreview,
|
||||||
'phone-stage--landscape': phone.cameraLandscape,
|
'phone-stage--landscape': phone.cameraLandscape,
|
||||||
|
'phone-stage--live-activity':
|
||||||
|
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
|
||||||
'phone-stage--peek': notifications.isPeeking,
|
'phone-stage--peek': notifications.isPeeking,
|
||||||
}"
|
}"
|
||||||
:style="phoneStageStyle"
|
:style="phoneStageStyle"
|
||||||
@@ -1713,14 +1808,25 @@ onBeforeUnmount(() => {
|
|||||||
@open="openNotificationPreview"
|
@open="openNotificationPreview"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="phone.isOpen || notifications.current || calls.activeCall"
|
v-if="
|
||||||
|
phone.isOpen ||
|
||||||
|
notifications.current ||
|
||||||
|
calls.activeCall ||
|
||||||
|
dynamicIslandActivity
|
||||||
|
"
|
||||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
id="phone-home-drag-portal"
|
||||||
|
class="phone-home-drag-portal"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||||
<section
|
<section
|
||||||
class="phone-device"
|
class="phone-device"
|
||||||
:class="{
|
:class="{
|
||||||
'phone-app--light': !displayedDarkMode,
|
'phone-app--light': !displayedDarkMode,
|
||||||
|
'phone-device--island-expanded': dynamicIslandExpanded,
|
||||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||||
}"
|
}"
|
||||||
:aria-label="phone.t('Common.phone')"
|
:aria-label="phone.t('Common.phone')"
|
||||||
@@ -1832,7 +1938,6 @@ onBeforeUnmount(() => {
|
|||||||
@control-center="toggleControlCenter"
|
@control-center="toggleControlCenter"
|
||||||
@lock="lockPhone"
|
@lock="lockPhone"
|
||||||
/>
|
/>
|
||||||
<PhoneDynamicIsland v-if="!setupRequired" />
|
|
||||||
<SpringboardView
|
<SpringboardView
|
||||||
v-if="!isDevelopmentRoute && !setupRequired"
|
v-if="!isDevelopmentRoute && !setupRequired"
|
||||||
@edit-mode-change="springboardEditing = $event"
|
@edit-mode-change="springboardEditing = $event"
|
||||||
@@ -1918,6 +2023,11 @@ onBeforeUnmount(() => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
|
<PhoneDynamicIsland
|
||||||
|
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
|
||||||
|
@expanded-change="dynamicIslandExpanded = $event"
|
||||||
|
@live-activity-change="dynamicIslandActivity = $event"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||||
const mainCss = readFileSync(
|
const mainCss = readFileSync(
|
||||||
new URL('./assets/main.css', import.meta.url),
|
new URL('./assets/main.css', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -22,15 +22,32 @@ describe('browser development preview contract', () => {
|
|||||||
|
|
||||||
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
||||||
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
||||||
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
|
expect(source).toContain(
|
||||||
|
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
|
||||||
|
)
|
||||||
expect(source).toContain("developmentParameters.has('setupPreview')")
|
expect(source).toContain("developmentParameters.has('setupPreview')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads authenticated app data without replacing direct app routes', () => {
|
it('restores the active route after the lock screen', () => {
|
||||||
expect(source).toContain(
|
expect(source).toMatch(
|
||||||
|
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
|
||||||
|
)
|
||||||
|
expect(source).not.toContain(
|
||||||
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
||||||
)
|
)
|
||||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
})
|
||||||
|
|
||||||
|
it('opens Space-triggered live activities on Home or the enabled lock screen', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
'openHomeRequested.value = event.data.openHome === true',
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
"if (isLocked.value) pendingUnlockRoute.value = '/'",
|
||||||
|
)
|
||||||
|
expect(source).toContain("void router.replace('/')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires the passcode again after a full device lock', () => {
|
it('requires the passcode again after a full device lock', () => {
|
||||||
@@ -75,6 +92,10 @@ describe('browser development preview contract', () => {
|
|||||||
|
|
||||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||||
expect(source).toContain('phone-resolution-canvas--primary')
|
expect(source).toContain('phone-resolution-canvas--primary')
|
||||||
|
expect(source).toMatch(
|
||||||
|
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||||
|
)
|
||||||
|
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||||
expect(source).toContain("'--phone-rendered-height'")
|
expect(source).toContain("'--phone-rendered-height'")
|
||||||
expect(source).toContain("'--phone-rendered-width'")
|
expect(source).toContain("'--phone-rendered-width'")
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
@@ -100,7 +121,9 @@ describe('browser development preview contract', () => {
|
|||||||
expect(source).toContain("developmentParameters.has('browserPreview')")
|
expect(source).toContain("developmentParameters.has('browserPreview')")
|
||||||
expect(source).toContain('import.meta.env.DEV ||')
|
expect(source).toContain('import.meta.env.DEV ||')
|
||||||
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
|
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
|
||||||
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
|
expect(source).toContain(
|
||||||
|
'return (availableScale * 0.94) / PHONE_BASE_SCALE',
|
||||||
|
)
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
|
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+130
-99
@@ -211,9 +211,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
:root {
|
:root {
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
font-feature-settings:
|
||||||
sans-serif;
|
'liga' 1,
|
||||||
|
'calt' 1;
|
||||||
|
font-optical-sizing: auto;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -234,7 +236,9 @@ body,
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
@@ -259,6 +263,20 @@ button {
|
|||||||
transform: translateY(calc(100% - 190px));
|
transform: translateY(calc(100% - 190px));
|
||||||
transform-origin: right bottom;
|
transform-origin: right bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device {
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(
|
||||||
|
calc(100% - var(--phone-live-activity-peek-height, 145px))
|
||||||
|
);
|
||||||
|
transform-origin: right bottom;
|
||||||
|
}
|
||||||
|
.phone-stage--live-activity
|
||||||
|
.phone-device
|
||||||
|
> :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island),
|
||||||
|
.phone-stage--live-activity .phone-screen > * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
.phone-lift-enter-active {
|
.phone-lift-enter-active {
|
||||||
transition: opacity 0.52s linear;
|
transition: opacity 0.52s linear;
|
||||||
}
|
}
|
||||||
@@ -281,6 +299,18 @@ button {
|
|||||||
height: var(--phone-rendered-height, 844px);
|
height: var(--phone-rendered-height, 844px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phone-home-drag-portal {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 70;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-home-drag-portal * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.phone-resolution-canvas {
|
.phone-resolution-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -384,7 +414,7 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgb(255 255 255 / 16%);
|
border: 1px solid rgb(255 255 255 / 16%);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
color: #0a84ff;
|
color: #a9abb2;
|
||||||
background: rgb(47 47 51 / 98%);
|
background: rgb(47 47 51 / 98%);
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -672,6 +702,9 @@ button {
|
|||||||
transition: transform 280ms var(--sky-ease-out, ease-out);
|
transition: transform 280ms var(--sky-ease-out, ease-out);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
.phone-device--island-expanded .phone-notification {
|
||||||
|
top: 130px !important;
|
||||||
|
}
|
||||||
.phone-notification__icon {
|
.phone-notification__icon {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
@@ -795,78 +828,6 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.phone-dynamic-island {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 98;
|
|
||||||
top: 12px;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 300px;
|
|
||||||
min-height: 72px;
|
|
||||||
padding: 10px 12px 10px 16px;
|
|
||||||
border: 1px solid rgb(255 255 255 / 10%);
|
|
||||||
border-radius: 28px;
|
|
||||||
color: #fff;
|
|
||||||
background: #050505;
|
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller span,
|
|
||||||
.phone-dynamic-island__caller strong {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller span {
|
|
||||||
color: rgb(255 255 255 / 58%);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 14px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller strong {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__actions .sky-button--icon-only {
|
|
||||||
width: 44px;
|
|
||||||
min-width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__actions svg {
|
|
||||||
width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__answer {
|
|
||||||
--sky-app-accent: #34c759;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island-enter-active,
|
|
||||||
.phone-dynamic-island-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 180ms ease,
|
|
||||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
.phone-dynamic-island-enter-from,
|
|
||||||
.phone-dynamic-island-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) scale(0.86);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.phone-dynamic-island-enter-active,
|
|
||||||
.phone-dynamic-island-leave-active {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.phone-home-indicator {
|
.phone-home-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 90;
|
z-index: 90;
|
||||||
@@ -1341,8 +1302,7 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #f5f5f7;
|
color: #f5f5f7;
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
|
||||||
}
|
}
|
||||||
.darkchat-page button,
|
.darkchat-page button,
|
||||||
.darkchat-page input,
|
.darkchat-page input,
|
||||||
@@ -2677,8 +2637,30 @@ button {
|
|||||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.springboard--home-dragging .springboard-page--apps {
|
.home-drag-layer {
|
||||||
overflow: visible;
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.home-drag-layer * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
.home-drag-position {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.home-drag-ghost {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 1;
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.app-grid {
|
.app-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2713,6 +2695,9 @@ button {
|
|||||||
transition: transform var(--springboard-page-duration)
|
transition: transform var(--springboard-page-duration)
|
||||||
var(--springboard-page-easing);
|
var(--springboard-page-easing);
|
||||||
}
|
}
|
||||||
|
.app-icon-item--drag-source {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
.app-icon-remove {
|
.app-icon-remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
@@ -2838,7 +2823,6 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
.app-icon--calculator {
|
.app-icon--calculator {
|
||||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||||
@@ -4178,8 +4162,8 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 218px;
|
min-height: 206px;
|
||||||
padding: 1px 0 16px;
|
padding: 1px 0 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.weather-location {
|
.weather-location {
|
||||||
@@ -4231,7 +4215,7 @@ button {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--sky-space-2);
|
gap: var(--sky-space-2);
|
||||||
margin-bottom: var(--sky-space-4);
|
margin-bottom: var(--sky-space-3);
|
||||||
}
|
}
|
||||||
.weather-detail-card,
|
.weather-detail-card,
|
||||||
.weather-panel {
|
.weather-panel {
|
||||||
@@ -4242,18 +4226,21 @@ button {
|
|||||||
0 8px 22px rgba(5, 16, 32, 0.14);
|
0 8px 22px rgba(5, 16, 32, 0.14);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.weather-detail-card {
|
.weather-details > .weather-detail-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 21px 1fr;
|
grid-template-columns: 21px 1fr;
|
||||||
gap: 4px 7px;
|
grid-template-rows: auto auto;
|
||||||
min-height: 94px;
|
align-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 76px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px;
|
padding: 11px 12px;
|
||||||
border-radius: calc(var(--sky-radius-card) - 6px);
|
border-radius: calc(var(--sky-radius-card) - 6px);
|
||||||
}
|
}
|
||||||
.weather-detail-card svg {
|
.weather-detail-card svg {
|
||||||
grid-row: span 2;
|
grid-row: span 2;
|
||||||
margin-top: 2px;
|
align-self: center;
|
||||||
|
margin-top: 0;
|
||||||
color: #b7d9ec;
|
color: #b7d9ec;
|
||||||
}
|
}
|
||||||
.weather-detail-card:nth-child(1) svg {
|
.weather-detail-card:nth-child(1) svg {
|
||||||
@@ -4273,7 +4260,7 @@ button {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.weather-detail-card strong {
|
.weather-detail-card strong {
|
||||||
align-self: end;
|
align-self: auto;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 680;
|
font-weight: 680;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
@@ -4281,7 +4268,7 @@ button {
|
|||||||
.weather-detail-card:nth-child(4) strong {
|
.weather-detail-card:nth-child(4) strong {
|
||||||
color: var(--weather-accent-cyan);
|
color: var(--weather-accent-cyan);
|
||||||
}
|
}
|
||||||
.weather-panel {
|
.weather-scroll > .weather-panel {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--sky-radius-card);
|
border-radius: var(--sky-radius-card);
|
||||||
@@ -4315,10 +4302,11 @@ button {
|
|||||||
min-height: 108px;
|
min-height: 108px;
|
||||||
padding: 7px 2px 4px;
|
padding: 7px 2px 4px;
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: var(--sky-radius-control);
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.weather-hour:first-child {
|
.weather-hour:first-child {
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
|
border-radius: var(--sky-radius-control);
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: rgba(255, 255, 255, 0.07);
|
||||||
}
|
}
|
||||||
.weather-hour span {
|
.weather-hour span {
|
||||||
@@ -6611,9 +6599,47 @@ button {
|
|||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
.messages-media-picker__gifs--masonry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.messages-gif-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.messages-gif-column {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.messages-media-picker__gifs--masonry .messages-gif-result {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(60 60 67 / 10%);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--sky-surface-muted);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.messages-media-picker__gifs--masonry .messages-gif-result img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
.messages-media-picker__gifs .messages-gif-more {
|
.messages-media-picker__gifs .messages-gif-more {
|
||||||
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
grid-column: 1 / -1;
|
flex: 0 0 auto;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
color: var(--ios-blue);
|
color: var(--ios-blue);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -6935,6 +6961,12 @@ button {
|
|||||||
.phone-app.dark .messages-media-picker__gifs button {
|
.phone-app.dark .messages-media-picker__gifs button {
|
||||||
background: #2c2c2e;
|
background: #2c2c2e;
|
||||||
}
|
}
|
||||||
|
.phone-app.dark
|
||||||
|
.messages-media-picker__gifs--masonry
|
||||||
|
.messages-gif-result {
|
||||||
|
border-color: rgb(255 255 255 / 10%);
|
||||||
|
background: var(--sky-surface-muted);
|
||||||
|
}
|
||||||
.phone-app.dark .messages-media-picker__gifs .messages-gif-more,
|
.phone-app.dark .messages-media-picker__gifs .messages-gif-more,
|
||||||
.phone-app.dark .messages-media-picker__gifs .messages-gif-error button {
|
.phone-app.dark .messages-media-picker__gifs .messages-gif-error button {
|
||||||
background: rgb(10 132 255 / 20%);
|
background: rgb(10 132 255 / 20%);
|
||||||
@@ -7549,18 +7581,17 @@ button {
|
|||||||
min-height: var(--springboard-grid-row);
|
min-height: var(--springboard-grid-row);
|
||||||
}
|
}
|
||||||
.app-icon-button {
|
.app-icon-button {
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
.app-icon-label {
|
.app-icon-label {
|
||||||
min-height: 15px;
|
min-height: var(--sky-home-label-height);
|
||||||
font-size: 11.5px;
|
font-size: var(--sky-home-label-font-size);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
line-height: 15px;
|
line-height: var(--sky-home-label-height);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.springboard-edit-add {
|
.springboard-edit-add {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const callsServer = readFileSync(
|
|||||||
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
|
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
).replace(/\r\n/g, '\n')
|
).replace(/\r\n/g, '\n')
|
||||||
|
const phoneServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
const companiesStore = readFileSync(
|
const companiesStore = readFileSync(
|
||||||
new URL('./stores/companies.ts', import.meta.url),
|
new URL('./stores/companies.ts', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -85,3 +89,35 @@ describe('Companies outbound service-line call contract', () => {
|
|||||||
expect(startCompanyCall).not.toContain('data.callerNumber')
|
expect(startCompanyCall).not.toContain('data.callerNumber')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Companies background call availability contract', () => {
|
||||||
|
it('keeps call availability enabled after the phone UI closes', () => {
|
||||||
|
const closeDevice = sourceBlock(
|
||||||
|
phoneServer,
|
||||||
|
`Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`,
|
||||||
|
`Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(closeDevice).toContain('sessions[source] = nil')
|
||||||
|
expect(closeDevice).not.toContain(
|
||||||
|
'SkyPhoneCompanies.ClearCallAvailability(source)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes background calls only to an owned phone with the same registered SIM', () => {
|
||||||
|
const getCallTargets = sourceBlock(
|
||||||
|
companiesServer,
|
||||||
|
'function SkyPhoneCompanies.GetCallTargets(',
|
||||||
|
'\n\nlocal function profile_row(',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)')
|
||||||
|
expect(getCallTargets).toContain(
|
||||||
|
'SkyPhone.FindDeviceSlots(source, readiness.imei)',
|
||||||
|
)
|
||||||
|
expect(getCallTargets).toContain('device.sim_id == readiness.sim_id')
|
||||||
|
expect(getCallTargets).toContain('device.sim_type == "registered"')
|
||||||
|
expect(getCallTargets).toContain('device.registered_at ~= nil')
|
||||||
|
expect(getCallTargets).not.toContain('current_device(source, true)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('./AdminPanel.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const agentInstructions = readFileSync(
|
||||||
|
new URL('../../../AGENTS.md', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||||
|
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||||
|
const store = readFileSync(
|
||||||
|
new URL('../stores/admin.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const server = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const phoneServer = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const persistenceServer = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/server/phone_persistence.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const simServer = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const phoneClient = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const focusClient = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const bridge = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const config = readFileSync(
|
||||||
|
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const schema = readFileSync(
|
||||||
|
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const manifest = readFileSync(
|
||||||
|
new URL('../../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configuratorServer = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/server/phone_configurator.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configuratorClient = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaImportServer = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/media_import.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configuratorValueEditor = readFileSync(
|
||||||
|
new URL('./AdminConfigValueEditor.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configInputWidth = readFileSync(
|
||||||
|
new URL('../directives/configInputWidth.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('standalone admin panel contracts', () => {
|
||||||
|
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||||
|
expect(apps).not.toContain("id: 'admin'")
|
||||||
|
expect(app).toContain("event.data?.type === 'admin:open'")
|
||||||
|
expect(app).toContain('v-if="adminPanelOpen"')
|
||||||
|
expect(app).toContain('<AdminPanel')
|
||||||
|
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||||
|
expect(source).toContain('class="admin-panel-overlay"')
|
||||||
|
expect(source).toContain('class="admin-panel-rail"')
|
||||||
|
expect(source).toContain('class="admin-panel-directory"')
|
||||||
|
expect(source).toContain('class="admin-panel-editor"')
|
||||||
|
expect(source).toContain('pointer-events: auto')
|
||||||
|
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a compact transparent shell with dedicated admin workspaces', () => {
|
||||||
|
expect(source).toContain('background: transparent')
|
||||||
|
expect(source).toContain('width: min(76vw, 1220px)')
|
||||||
|
expect(source).toContain('height: min(74vh, 700px)')
|
||||||
|
expect(source).toContain('--admin-row-hover: linear-gradient')
|
||||||
|
expect(source).toContain('--admin-row-active: linear-gradient')
|
||||||
|
expect(source).toContain('background: var(--admin-nav-active)')
|
||||||
|
expect(source).not.toContain('admin-panel-brand__mark')
|
||||||
|
expect(source).not.toContain('admin-panel-profile-heading__status')
|
||||||
|
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||||
|
expect(source).not.toContain("t('overview.recent')")
|
||||||
|
|
||||||
|
for (const tab of [
|
||||||
|
'overview',
|
||||||
|
'players',
|
||||||
|
'devices',
|
||||||
|
'apps',
|
||||||
|
'accounts',
|
||||||
|
'messages',
|
||||||
|
'calls',
|
||||||
|
'moderation',
|
||||||
|
'audit',
|
||||||
|
'configurator',
|
||||||
|
]) {
|
||||||
|
expect(source).toContain(`selectTab('${tab}')`)
|
||||||
|
expect(source).toContain(`t('tabs.${tab}')`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||||
|
expect(source).not.toContain('<RefreshCw')
|
||||||
|
expect(source).not.toContain("kind: 'refresh'")
|
||||||
|
expect(source).toContain("'sky-phone-admin-accent'")
|
||||||
|
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||||
|
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||||
|
expect(source).toContain("'--admin-accent': accentColor")
|
||||||
|
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||||
|
expect(source).toContain(
|
||||||
|
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||||
|
)
|
||||||
|
expect(source).toContain('class="admin-panel-color-value"')
|
||||||
|
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||||
|
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||||
|
expect(source).toContain('class="admin-panel-font-select"')
|
||||||
|
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||||
|
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||||
|
expect(source).not.toContain('type="color"')
|
||||||
|
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||||
|
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||||
|
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'background: var(--admin-toggle-on)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stages app changes locally and saves them only from the toolbar action', () => {
|
||||||
|
expect(source).toContain('const drafts = ref<')
|
||||||
|
expect(source).toContain('@click="saveChanges"')
|
||||||
|
expect(source).toContain("t('editor.noAutoSave')")
|
||||||
|
expect(store).toContain("'admin:save-apps'")
|
||||||
|
expect(store).not.toContain("'admin:set-app'")
|
||||||
|
expect(server).toContain(
|
||||||
|
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connects every operation through the standard NUI callback bridge', () => {
|
||||||
|
expect(bridge).toContain('admin = [[')
|
||||||
|
for (const endpoint of [
|
||||||
|
'admin:bootstrap',
|
||||||
|
'admin:player',
|
||||||
|
'admin:save-apps',
|
||||||
|
'admin:reveal-password',
|
||||||
|
'admin:activity',
|
||||||
|
'admin:reset-passcode',
|
||||||
|
'admin:change-number',
|
||||||
|
'admin:factory-reset',
|
||||||
|
'admin:configurator',
|
||||||
|
'admin:save-configurator',
|
||||||
|
]) {
|
||||||
|
expect(store).toContain(endpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('protects activity views and device moderation with ownership and audit checks', () => {
|
||||||
|
for (const endpoint of [
|
||||||
|
'activity',
|
||||||
|
'reset-passcode',
|
||||||
|
'change-number',
|
||||||
|
'factory-reset',
|
||||||
|
]) {
|
||||||
|
expect(server).toContain(
|
||||||
|
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(server).toContain('data.kind ~= "messages"')
|
||||||
|
expect(server).toContain('data.kind ~= "calls"')
|
||||||
|
expect(server).toContain('"view_messages"')
|
||||||
|
expect(server).toContain('"view_calls"')
|
||||||
|
expect(server).toContain('"reset_passcode"')
|
||||||
|
expect(server).toContain('"change_number"')
|
||||||
|
expect(server).toContain('"factory_reset"')
|
||||||
|
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
|
||||||
|
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
|
||||||
|
expect(persistenceServer).toContain(
|
||||||
|
'function SkyPhonePersistence.FactoryReset(imei)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authorizes every server request without requiring a phone session', () => {
|
||||||
|
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||||
|
expect(server).toContain(
|
||||||
|
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||||
|
)
|
||||||
|
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||||
|
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||||
|
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||||
|
expect(config).toContain('Config.AdminPanel = {')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens directly from the configurable command with dedicated focus', () => {
|
||||||
|
expect(config).toContain('Command = "phonepanel"')
|
||||||
|
expect(server).toContain('local command_name = Config.AdminPanel.Command')
|
||||||
|
expect(server).toContain(
|
||||||
|
'RegisterCommand(command_name, function(command_source)',
|
||||||
|
)
|
||||||
|
expect(server).toContain(
|
||||||
|
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||||
|
)
|
||||||
|
expect(phoneServer).not.toContain(
|
||||||
|
'RegisterCommand(Config.AdminPanel.Command',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
|
||||||
|
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
|
||||||
|
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates ownership, app policy, and revision before a batch mutation', () => {
|
||||||
|
expect(server).toContain('find_owned_device(target_source, data.imei)')
|
||||||
|
expect(server).toContain('app_metadata(change.appId)')
|
||||||
|
expect(server).toContain('error = "device_not_owned"')
|
||||||
|
expect(server).toContain('error = "app_protected"')
|
||||||
|
expect(server).toContain('AND `revision` = ?')
|
||||||
|
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gates plaintext account reveals behind confirmation and audit logging', () => {
|
||||||
|
expect(source).toContain('revealDialogImei')
|
||||||
|
expect(source).toContain("t('credentials.revealTitle')")
|
||||||
|
expect(server).toContain('"reveal_account_password"')
|
||||||
|
expect(server).toContain('write_audit(')
|
||||||
|
expect(server).not.toContain('passcode_hash` AS')
|
||||||
|
expect(schema).toContain(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the SQL phone configurator before framework-owned configuration is read', () => {
|
||||||
|
expect(agentInstructions).toContain('Phone Configurator parity (mandatory)')
|
||||||
|
expect(agentInstructions).toContain(
|
||||||
|
'changes without matching Configurator support are incomplete',
|
||||||
|
)
|
||||||
|
expect(config).toMatch(
|
||||||
|
/Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*true[\s\S]*?Config\.Bridge\s*=/,
|
||||||
|
)
|
||||||
|
expect(schema).toContain(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/client/phone_configurator.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/bridge/client/framework.lua'"))
|
||||||
|
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||||
|
expect(configuratorServer).toContain('configurator_enabled')
|
||||||
|
expect(configuratorServer).toMatch(
|
||||||
|
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'default_media = serialize_value(Config.Media)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'build_sections("config", stored_config',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('build_sections("media", stored_media')
|
||||||
|
expect(configuratorServer).toContain('sensitive_path')
|
||||||
|
expect(configuratorServer).toContain('restore_redacted_values')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'{ __skyType = "map", entries = entries }',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('validate_structured_value')
|
||||||
|
expect(configuratorServer).toContain('validate_locked_structure')
|
||||||
|
expect(configuratorServer).toContain('build_structure(default_value')
|
||||||
|
expect(configuratorServer).toContain('path == "Companies.Definitions"')
|
||||||
|
expect(configuratorServer).toContain('flatten_company_fields')
|
||||||
|
expect(configuratorServer).toContain('structure.mutableKeys')
|
||||||
|
expect(configuratorServer).toContain('local function empty_structure(')
|
||||||
|
expect(configuratorServer).toContain('template = items[1]')
|
||||||
|
expect(configuratorServer).toContain('structure.template')
|
||||||
|
expect(configuratorServer).toContain('if not structure.fields[key] then')
|
||||||
|
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
|
||||||
|
expect(configuratorServer).not.toContain('Config.Media = client_payload')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'apply_runtime_table(Config[key], value)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'apply_runtime_table(Config.Media, runtime_media)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('local function read_stored_row()')
|
||||||
|
expect(configuratorServer).toContain('local function apply_stored_row(row)')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'persisted_row.media_payload ~= media_encoded',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'Phone configurator SQL verification failed',
|
||||||
|
)
|
||||||
|
expect(server).not.toContain('ExecuteCommand(("restart %s")')
|
||||||
|
expect(configuratorServer).not.toContain('clear_runtime_table')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'TriggerEvent("sky_phone:configurator:serverUpdated", revision)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'function SkyPhoneConfigurator.Broadcast(target)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('SkyPhoneConfigurator.Broadcast(-1)')
|
||||||
|
expect(configuratorServer).toContain('through the internal runtime refresh')
|
||||||
|
expect(configuratorClient).toContain(
|
||||||
|
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
|
||||||
|
)
|
||||||
|
expect(configuratorClient).toContain(
|
||||||
|
'apply_runtime_table(Config[key], value)',
|
||||||
|
)
|
||||||
|
expect(configuratorClient).not.toContain('clear_runtime_table')
|
||||||
|
expect(configuratorClient).toContain(
|
||||||
|
'TriggerEvent("sky_phone:configurator:updated"',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toMatch(
|
||||||
|
/AddEventHandler\("sky_phone:configurator:updated"[\s\S]*?SkyPhoneLocales\.Resolve\(Config\.Bridge\.Locale\)[\s\S]*?SkyPhoneApps\.SendCatalog\(\)[\s\S]*?type = "device:updated"/,
|
||||||
|
)
|
||||||
|
expect(server).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||||
|
)
|
||||||
|
expect(phoneServer).toContain('register_configured_phone_item()')
|
||||||
|
expect(phoneServer).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||||
|
)
|
||||||
|
expect(simServer).toContain('refresh_sim_types()')
|
||||||
|
expect(simServer).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||||
|
)
|
||||||
|
expect(mediaImportServer).toContain('local website = {}')
|
||||||
|
expect(mediaImportServer).toContain('website._adapter = adapter')
|
||||||
|
expect(mediaImportServer).not.toContain('definition._adapter = adapter')
|
||||||
|
expect(mediaImportServer).toMatch(
|
||||||
|
/AddEventHandler\("sky_phone:configurator:serverUpdated"[\s\S]*?if initialized then[\s\S]*?build_registry\(\)/,
|
||||||
|
)
|
||||||
|
expect(source).toContain('class="admin-panel-rail__configurator"')
|
||||||
|
expect(source).toContain(
|
||||||
|
'.admin-panel-rail .admin-panel-rail__configurator',
|
||||||
|
)
|
||||||
|
expect(source).toContain('<AdminConfigValueEditor')
|
||||||
|
expect(source).toContain('function configuratorFieldRepeatsSection(')
|
||||||
|
expect(source).toContain('v-if="!configuratorFieldRepeatsSection(field)"')
|
||||||
|
expect(source).toContain('class="admin-panel-config-scopes"')
|
||||||
|
expect(source).toContain("selectConfiguratorScope('config')")
|
||||||
|
expect(source).toContain("selectConfiguratorScope('media')")
|
||||||
|
expect(source).not.toContain('class="admin-panel-config-meta"')
|
||||||
|
expect(configuratorValueEditor).toContain('function addListRow()')
|
||||||
|
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'const canExtendTable = computed(',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'tableStructure.value.mutableKeys === true',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'const usesFixedTableLayout = computed(',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
"'is-fixed-table': usesFixedTableLayout",
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain('v-if="!usesFixedTableLayout"')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'.config-structured-editor.is-fixed-table',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain('v-else-if="canExtendTable"')
|
||||||
|
expect(configuratorValueEditor).toContain('function removeListRow(')
|
||||||
|
expect(configuratorValueEditor).toContain('function removeTableField(')
|
||||||
|
expect(configuratorValueEditor).toContain('function addMapEntry()')
|
||||||
|
expect(configuratorValueEditor).toContain('function updateMapKey(')
|
||||||
|
expect(configuratorValueEditor).toContain('fixedMapEntryStructure(current)')
|
||||||
|
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
|
||||||
|
expect(configuratorValueEditor).toContain('function listItemStructure(')
|
||||||
|
expect(configuratorValueEditor).toContain('const listTemplate = computed(')
|
||||||
|
expect(configuratorValueEditor).toContain('function structureTypeLabel(')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__fixed-type"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'const rootTableTabs = computed<RootTableTab[]>(',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__tabs"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__tab-panel"',
|
||||||
|
)
|
||||||
|
expect(source).toContain(':tab-label="configuratorSubtabLabel"')
|
||||||
|
expect(source).toContain('configurator.table.subtabs.${key}')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'props.tabLabel?.(key, tableValue.value[key]) ?? key',
|
||||||
|
)
|
||||||
|
expect(source).toContain('v-config-input-width')
|
||||||
|
expect(configuratorValueEditor).toContain('v-config-input-width')
|
||||||
|
expect(configInputWidth).toContain("input.addEventListener('input'")
|
||||||
|
expect(configInputWidth).toContain('input.scrollWidth')
|
||||||
|
expect(source).toContain('filter: drop-shadow')
|
||||||
|
expect(source).toContain("input[type='number']::-webkit-inner-spin-button")
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
"input[type='number']::-webkit-inner-spin-button",
|
||||||
|
)
|
||||||
|
expect(source).toContain('justify-self: start')
|
||||||
|
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||||
|
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||||
|
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||||
|
expect(configuratorValueEditor).toContain('function blankFromStructure(')
|
||||||
|
expect(source).toContain("'is-structured': field.type === 'json'")
|
||||||
|
expect(configuratorValueEditor).toContain('function isStructuredValue(')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__add-field is-list"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__actions"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain('function toggleStructuredEntry(')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__section-toggle"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(':aria-expanded=')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'@media (prefers-reduced-motion: reduce)',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'.config-structured-editor__property.has-structured-value',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).not.toContain('<textarea')
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -30,11 +30,13 @@ const props = withDefaults(
|
|||||||
app: PhoneAppDefinition
|
app: PhoneAppDefinition
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
editMode?: boolean
|
editMode?: boolean
|
||||||
|
externalDragVisual?: boolean
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
compact: false,
|
compact: false,
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
externalDragVisual: false,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,14 +65,14 @@ let dragStartPage = 0
|
|||||||
let dragPageWidth = 0
|
let dragPageWidth = 0
|
||||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||||
const dragStyle = computed(() =>
|
const dragStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
const dragPointerStyle = computed(() =>
|
const dragPointerStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
|
|||||||
class="app-icon-item"
|
class="app-icon-item"
|
||||||
:class="{
|
:class="{
|
||||||
'app-icon-item--compact': compact,
|
'app-icon-item--compact': compact,
|
||||||
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||||
'app-icon-item--dragging': isDragging,
|
'app-icon-item--dragging': isDragging,
|
||||||
'app-icon-item--editing': editMode,
|
'app-icon-item--editing': editMode,
|
||||||
}"
|
}"
|
||||||
@@ -325,7 +328,10 @@ onBeforeUnmount(() => {
|
|||||||
class="app-icon"
|
class="app-icon"
|
||||||
:class="[
|
:class="[
|
||||||
app.iconClass,
|
app.iconClass,
|
||||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
{
|
||||||
|
'app-icon--image':
|
||||||
|
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
:style="iconStyle"
|
:style="iconStyle"
|
||||||
>
|
>
|
||||||
@@ -334,7 +340,7 @@ onBeforeUnmount(() => {
|
|||||||
<b>{{ calendarDay }}</b>
|
<b>{{ calendarDay }}</b>
|
||||||
</span>
|
</span>
|
||||||
<img
|
<img
|
||||||
v-else-if="!iconFailed"
|
v-else-if="!iconFailed && app.iconImage"
|
||||||
:src="app.iconImage"
|
:src="app.iconImage"
|
||||||
alt=""
|
alt=""
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ const props = withDefaults(
|
|||||||
apps: PhoneAppDefinition[]
|
apps: PhoneAppDefinition[]
|
||||||
defaultName: string
|
defaultName: string
|
||||||
editMode?: boolean
|
editMode?: boolean
|
||||||
|
externalDragVisual?: boolean
|
||||||
folder: HomeFolder
|
folder: HomeFolder
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
externalDragVisual: false,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
|
|||||||
|
|
||||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||||
const dragStyle = computed(() =>
|
const dragStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
const dragPointerStyle = computed(() =>
|
const dragPointerStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||||
}
|
}
|
||||||
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
class="home-folder-item app-icon-item"
|
class="home-folder-item app-icon-item"
|
||||||
:class="{
|
:class="{
|
||||||
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||||
'app-icon-item--dragging': isDragging,
|
'app-icon-item--dragging': isDragging,
|
||||||
'app-icon-item--editing': editMode,
|
'app-icon-item--editing': editMode,
|
||||||
'home-folder-item--dragging': isDragging,
|
'home-folder-item--dragging': isDragging,
|
||||||
|
|||||||
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
|||||||
z-index: 70;
|
z-index: 70;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-folder-backdrop {
|
.home-folder-backdrop {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||||
|
|
||||||
type PayphoneState =
|
type PayphoneState =
|
||||||
@@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = []
|
|||||||
function prepareButtonSounds(): void {
|
function prepareButtonSounds(): void {
|
||||||
if (buttonSounds.length) return
|
if (buttonSounds.length) return
|
||||||
for (let index = 0; index < 4; index += 1) {
|
for (let index = 0; index < 4; index += 1) {
|
||||||
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
|
const sound = registerPhoneMediaElement(
|
||||||
|
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
|
||||||
|
)
|
||||||
sound.preload = 'auto'
|
sound.preload = 'auto'
|
||||||
sound.volume = 0.55
|
sound.volume = 0.55
|
||||||
buttonSounds.push(sound)
|
buttonSounds.push(sound)
|
||||||
@@ -399,7 +402,7 @@ onBeforeUnmount(() => {
|
|||||||
rgb(30 38 42 / 35%),
|
rgb(30 38 42 / 35%),
|
||||||
rgb(0 0 0 / 84%) 72%
|
rgb(0 0 0 / 84%) 72%
|
||||||
);
|
);
|
||||||
font-family: 'Segoe UI', Arial, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import { describe, expect, it } from 'vitest'
|
|||||||
const source = readFileSync(
|
const source = readFileSync(
|
||||||
new URL('./PhoneDynamicIsland.vue', import.meta.url),
|
new URL('./PhoneDynamicIsland.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
|
const appSource = readFileSync(
|
||||||
|
new URL('../App.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
|
const mainCss = readFileSync(
|
||||||
|
new URL('../assets/main.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
)
|
)
|
||||||
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
|
||||||
const recorderSource = readFileSync(
|
const recorderSource = readFileSync(
|
||||||
new URL('./PhoneMemoRecorder.vue', import.meta.url),
|
new URL('./PhoneMemoRecorder.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -19,16 +26,23 @@ const clockSource = readFileSync(
|
|||||||
new URL('../views/apps/ClockApp.vue', import.meta.url),
|
new URL('../views/apps/ClockApp.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
|
const serverMemosSource = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
describe('Phone Dynamic Island contract', () => {
|
describe('Phone Dynamic Island contract', () => {
|
||||||
it('renders once at phone shell level instead of forcing calls into Phone', () => {
|
it('renders once at phone shell level instead of forcing calls into Phone', () => {
|
||||||
expect(appSource).toContain(
|
expect(appSource).toContain(
|
||||||
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
|
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
|
||||||
)
|
)
|
||||||
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
|
|
||||||
expect(appSource).toContain(
|
expect(appSource).toContain(
|
||||||
'phone.isOpen || notifications.current || calls.activeCall',
|
"'phone-device--island-expanded': dynamicIslandExpanded",
|
||||||
)
|
)
|
||||||
|
expect(appSource).toMatch(
|
||||||
|
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
|
||||||
|
)
|
||||||
|
expect(appSource).toContain('dynamicIslandActivity ||')
|
||||||
expect(appSource).not.toContain(
|
expect(appSource).not.toContain(
|
||||||
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
|
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
|
||||||
)
|
)
|
||||||
@@ -45,7 +59,44 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
expect(source).toContain('call.answeredAt ?? call.startedAt')
|
expect(source).toContain('call.answeredAt ?? call.startedAt')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides an activity in its owning foreground app but restores it after closing', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
'if (!currentActivity || !phone.isOpen) return currentActivity',
|
||||||
|
)
|
||||||
|
expect(source).toContain("activeAppId.value === 'phone'")
|
||||||
|
expect(source).toContain(
|
||||||
|
"currentActivity === 'recording' && activeAppId.value === 'memos'",
|
||||||
|
)
|
||||||
|
expect(source).toContain("activeAppId.value === 'clock'")
|
||||||
|
expect(source).toContain(
|
||||||
|
"currentActivity === 'music' && activeAppId.value === 'music'",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only a compact frame and island for background live activities', () => {
|
||||||
|
expect(source).toContain("'live-activity-change': [activity:")
|
||||||
|
expect(source).toContain("emit('live-activity-change', nextActivity)")
|
||||||
|
expect(appSource).toContain(
|
||||||
|
"'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)",
|
||||||
|
)
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/,
|
||||||
|
)
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/,
|
||||||
|
)
|
||||||
|
expect(appSource).toContain("? '190px'")
|
||||||
|
expect(appSource).toContain("? '132px'")
|
||||||
|
expect(appSource).toContain(": '112px'")
|
||||||
|
expect(source).toContain(
|
||||||
|
"!phone.isOpen ||\n activity.value === 'incoming-call' ||",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
|
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
"if (music.isPlaying && music.currentTrack) return 'music'",
|
||||||
|
)
|
||||||
expect(source).toContain('@click.stop="music.previous()"')
|
expect(source).toContain('@click.stop="music.previous()"')
|
||||||
expect(source).toContain('@click.stop="music.toggle()"')
|
expect(source).toContain('@click.stop="music.toggle()"')
|
||||||
expect(source).toContain('@click.stop="music.next()"')
|
expect(source).toContain('@click.stop="music.next()"')
|
||||||
@@ -53,9 +104,31 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
expect(source).toContain('clock.pauseTimer(Date.now())')
|
expect(source).toContain('clock.pauseTimer(Date.now())')
|
||||||
expect(source).toContain('clock.pauseStopwatch(Date.now())')
|
expect(source).toContain('clock.pauseStopwatch(Date.now())')
|
||||||
expect(source).toContain('clock.addLap(Date.now())')
|
expect(source).toContain('clock.addLap(Date.now())')
|
||||||
|
expect(source).not.toContain('phone-dynamic-island__lap')
|
||||||
|
expect(source).toContain('phone-dynamic-island__stopwatch-meta')
|
||||||
|
expect(source).toContain('{{ stopwatchLapLabel }}')
|
||||||
|
expect(source).toContain('{{ stopwatchLapValue }}')
|
||||||
|
expect(source).toContain('{{ stopwatchTotalDisplay }}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps recorder state available across app changes', () => {
|
it('matches the reference music player and timer control layouts', () => {
|
||||||
|
expect(source).toContain('phone-dynamic-island__music-equalizer')
|
||||||
|
expect(source).toContain('phone-dynamic-island__progress-track')
|
||||||
|
expect(source).toContain('{{ musicElapsedLabel }}')
|
||||||
|
expect(source).toContain('{{ musicRemainingLabel }}')
|
||||||
|
expect(source).not.toContain('Airplay')
|
||||||
|
expect(source).toContain('<X aria-hidden="true" />')
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
|
||||||
|
)
|
||||||
|
expect(source).toContain('justify-content: center')
|
||||||
|
expect(source).toContain('gap: 34px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps recorder state available across app changes and phone closes', () => {
|
||||||
expect(recorderSource).toContain(
|
expect(recorderSource).toContain(
|
||||||
"message.type === 'memo:recordStateRequest'",
|
"message.type === 'memo:recordStateRequest'",
|
||||||
)
|
)
|
||||||
@@ -65,6 +138,15 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
expect(memosSource).not.toContain(
|
expect(memosSource).not.toContain(
|
||||||
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
|
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
|
||||||
)
|
)
|
||||||
|
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
|
||||||
|
expect(recorderSource).not.toContain(
|
||||||
|
'if (!isOpen || deviceSessionChanged) cancelRecording()',
|
||||||
|
)
|
||||||
|
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
|
||||||
|
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
|
||||||
|
expect(serverMemosSource).toContain(
|
||||||
|
'SkyPhone.FindDeviceSlots(source, imei)[1]',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens both clock live activities on the correct clock tab', () => {
|
it('opens both clock live activities on the correct clock tab', () => {
|
||||||
@@ -75,13 +157,68 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens activities by tapping the island without a separate expand icon', () => {
|
||||||
|
expect(source).toContain('@click.stop="toggleExpanded"')
|
||||||
|
expect(source).toContain('@click.stop="openActivity"')
|
||||||
|
expect(source).not.toContain('Maximize2')
|
||||||
|
expect(source).not.toContain('phone-dynamic-island__open-icon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
|
||||||
|
expect(source).toContain('ref="islandElement"')
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.addEventListener('scroll', collapseExpanded, true)",
|
||||||
|
)
|
||||||
|
expect(source).toContain('islandElement.value?.contains(event.target)')
|
||||||
|
expect(source).toContain('expanded.value = false')
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.removeEventListener('scroll', collapseExpanded, true)",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('animates state changes and moves popup notifications below expanded UI', () => {
|
it('animates state changes and moves popup notifications below expanded UI', () => {
|
||||||
expect(source).toContain('<Transition name="phone-dynamic-island">')
|
expect(source).toContain('<Transition name="phone-dynamic-island">')
|
||||||
expect(source).toContain(
|
expect(source).toContain(
|
||||||
'<Transition name="phone-dynamic-island-content" mode="out-in">',
|
'<Transition name="phone-dynamic-island-content" mode="out-in">',
|
||||||
)
|
)
|
||||||
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
|
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
|
||||||
expect(source).toContain('~ .phone-notification-provider')
|
expect(source).toContain("emit('expanded-change', false)")
|
||||||
|
expect(mainCss).toContain(
|
||||||
|
'.phone-device--island-expanded .phone-notification',
|
||||||
|
)
|
||||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders below the top edge and above the physical camera frame', () => {
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
|
||||||
|
)
|
||||||
|
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps compact and expanded islands close to the physical camera proportions', () => {
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toContain('box-sizing: border-box')
|
||||||
|
expect(source).toContain('padding: 8px 16px 8px 10px')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
|||||||
|
|
||||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||||
type PendingVideo = { blob: Blob; fileName: string }
|
type PendingVideo = { blob: Blob; fileName: string }
|
||||||
|
type UploadFailureDebug = {
|
||||||
|
correlationId: string
|
||||||
|
message: string
|
||||||
|
stage: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
const pendingVideos = new Map<string, PendingVideo>()
|
const pendingVideos = new Map<string, PendingVideo>()
|
||||||
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
|
|||||||
try {
|
try {
|
||||||
activeRecorder.stop()
|
activeRecorder.stop()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
console.error(
|
||||||
|
'[Camera] Could not stop the failed media recorder.',
|
||||||
|
error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
const activeRecorder = recorder
|
const activeRecorder = recorder
|
||||||
removeRecorderErrorListener = bindMediaRecorderError(
|
removeRecorderErrorListener = bindMediaRecorderError(
|
||||||
activeRecorder,
|
activeRecorder,
|
||||||
() =>
|
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||||
generation === recordingGeneration && recorder === activeRecorder,
|
|
||||||
(event) => {
|
(event) => {
|
||||||
console.error('[Camera] Media recorder failed while recording.', event)
|
console.error('[Camera] Media recorder failed while recording.', event)
|
||||||
cleanupRecording()
|
cleanupRecording()
|
||||||
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
async function failUpload(
|
||||||
await nuiCall('media:failUpload', { error, requestId })
|
requestId: string,
|
||||||
|
error: string,
|
||||||
|
debug: UploadFailureDebug,
|
||||||
|
): Promise<void> {
|
||||||
|
console.error('[Sky Phone Media] Upload failed.', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
detail: debug.message,
|
||||||
|
error,
|
||||||
|
stage: debug.stage,
|
||||||
|
status: debug.status,
|
||||||
|
})
|
||||||
|
const response = await nuiCall('media:failUpload', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
debugMessage: debug.message,
|
||||||
|
debugStage: debug.stage,
|
||||||
|
debugStatus: debug.status,
|
||||||
|
error,
|
||||||
|
requestId,
|
||||||
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
error: response.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||||
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
|||||||
blob = await capturePhotoBlob(ready)
|
blob = await capturePhotoBlob(ready)
|
||||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
await failUpload(ready.requestId, 'capture_failed')
|
await failUpload(ready.requestId, 'capture_failed', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stage: 'capture',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||||
|
bytes: blob.size,
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
mimeType: blob.type,
|
||||||
|
type: ready.mediaType,
|
||||||
|
})
|
||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', blob, fileName)
|
form.append('file', blob, fileName)
|
||||||
form.append(
|
|
||||||
'metadata',
|
|
||||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
|
||||||
)
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
ready.uploadTimeoutMs ?? 25000,
|
ready.uploadTimeoutMs ?? 25000,
|
||||||
)
|
)
|
||||||
|
let debugStage = 'provider_request'
|
||||||
|
let debugStatus: number | undefined
|
||||||
try {
|
try {
|
||||||
const response = await fetch(ready.presignedUrl, {
|
const response = await fetch(ready.presignedUrl, {
|
||||||
body: form,
|
body: form,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
debugStatus = response.status
|
||||||
|
debugStage = 'provider_response'
|
||||||
|
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
const body = JSON.parse(text) as {
|
const body = JSON.parse(text) as {
|
||||||
data?: { id?: string; url?: string }
|
data?: { id?: string; url?: string }
|
||||||
|
error?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
message?: string
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
const uploaded = body.data ?? body
|
const uploaded = body.data ?? body
|
||||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||||
throw new Error('upload_failed')
|
throw new Error(
|
||||||
|
(typeof body.error === 'string' && body.error) ||
|
||||||
|
(typeof body.message === 'string' && body.message) ||
|
||||||
|
'upload_failed',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
await nuiCall('media:completeUpload', {
|
debugStage = 'completion_callback'
|
||||||
|
const completion = await nuiCall('media:completeUpload', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
remoteId: uploaded.id,
|
remoteId: uploaded.id,
|
||||||
requestId: ready.requestId,
|
requestId: ready.requestId,
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
})
|
})
|
||||||
|
if (!completion.success) {
|
||||||
|
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||||
|
{
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
},
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await failUpload(
|
await failUpload(
|
||||||
ready.requestId,
|
ready.requestId,
|
||||||
error instanceof DOMException && error.name === 'AbortError'
|
error instanceof DOMException && error.name === 'AbortError'
|
||||||
? 'upload_timeout'
|
? 'upload_timeout'
|
||||||
: 'upload_failed',
|
: 'upload_failed',
|
||||||
|
{
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stage: debugStage,
|
||||||
|
status: debugStatus,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (message.type === 'media:uploadReady') {
|
} else if (message.type === 'media:uploadReady') {
|
||||||
void uploadReady(message.data as UploadReady)
|
const ready = message.data as UploadReady
|
||||||
|
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
type: ready.mediaType,
|
||||||
|
})
|
||||||
|
void uploadReady(ready)
|
||||||
} else if (message.type === 'media:uploadResult') {
|
} else if (message.type === 'media:uploadResult') {
|
||||||
const correlationId = String(message.data?.correlationId ?? '')
|
const correlationId = String(message.data?.correlationId ?? '')
|
||||||
if (correlationId) pendingVideos.delete(correlationId)
|
if (correlationId) pendingVideos.delete(correlationId)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
|
|||||||
let currentState: MemoRecorderStateName = 'idle'
|
let currentState: MemoRecorderStateName = 'idle'
|
||||||
let currentElapsedMs = 0
|
let currentElapsedMs = 0
|
||||||
let currentCorrelationId = ''
|
let currentCorrelationId = ''
|
||||||
|
let recordingDeviceImei = ''
|
||||||
let removeRecorderErrorListener: (() => void) | null = null
|
let removeRecorderErrorListener: (() => void) | null = null
|
||||||
|
|
||||||
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
||||||
@@ -153,6 +154,7 @@ function resetRecordingData(): void {
|
|||||||
pausedStartedAt = 0
|
pausedStartedAt = 0
|
||||||
totalPausedMs = 0
|
totalPausedMs = 0
|
||||||
currentElapsedMs = 0
|
currentElapsedMs = 0
|
||||||
|
recordingDeviceImei = ''
|
||||||
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +191,8 @@ function failRecording(error: string, generation = recordingGeneration): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||||
if (!phone.isOpen) {
|
const deviceImei = phone.device?.imei
|
||||||
|
if (!phone.isOpen || !deviceImei) {
|
||||||
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,6 +220,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
metadata = { note: '', pinned: false, title: '' }
|
metadata = { note: '', pinned: false, title: '' }
|
||||||
updateMetadata(data)
|
updateMetadata(data)
|
||||||
resetRecordingData()
|
resetRecordingData()
|
||||||
|
recordingDeviceImei = deviceImei
|
||||||
postRecorderState('starting')
|
postRecorderState('starting')
|
||||||
try {
|
try {
|
||||||
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
||||||
@@ -340,6 +344,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
if (generation !== recordingGeneration) return
|
if (generation !== recordingGeneration) return
|
||||||
const waveform = compressedWaveform()
|
const waveform = compressedWaveform()
|
||||||
const finalMetadata = { ...metadata }
|
const finalMetadata = { ...metadata }
|
||||||
|
const finalDeviceImei = recordingDeviceImei
|
||||||
const exceededSizeLimit = recordingTooLarge
|
const exceededSizeLimit = recordingTooLarge
|
||||||
cleanupRecorder(false)
|
cleanupRecorder(false)
|
||||||
resetRecordingData()
|
resetRecordingData()
|
||||||
@@ -355,10 +360,12 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
||||||
const uploadData = {
|
const uploadData = {
|
||||||
correlationId,
|
correlationId,
|
||||||
|
deviceImei: finalDeviceImei,
|
||||||
durationMs,
|
durationMs,
|
||||||
mimeType,
|
mimeType,
|
||||||
note: finalMetadata.note,
|
note: finalMetadata.note,
|
||||||
pinned: finalMetadata.pinned,
|
pinned: finalMetadata.pinned,
|
||||||
|
sizeBytes: blob.size,
|
||||||
title: finalMetadata.title,
|
title: finalMetadata.title,
|
||||||
waveform,
|
waveform,
|
||||||
}
|
}
|
||||||
@@ -452,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
|||||||
pending.requestId = ready.requestId
|
pending.requestId = ready.requestId
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', pending.blob, pending.fileName)
|
form.append('file', pending.blob, pending.fileName)
|
||||||
form.append(
|
|
||||||
'metadata',
|
|
||||||
JSON.stringify({
|
|
||||||
captureToken: ready.captureToken,
|
|
||||||
purpose: 'memo',
|
|
||||||
source: 'sky_phone',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
pending.abortController = controller
|
pending.abortController = controller
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
@@ -569,18 +568,9 @@ function onMessage(event: MessageEvent): void {
|
|||||||
onMounted(() => window.addEventListener('message', onMessage))
|
onMounted(() => window.addEventListener('message', onMessage))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() =>
|
() => phone.device?.imei ?? null,
|
||||||
[
|
(imei, previousImei) => {
|
||||||
phone.isOpen,
|
if (previousImei && imei !== previousImei) cancelRecording()
|
||||||
phone.device?.imei ?? null,
|
|
||||||
phone.deviceSessionToken,
|
|
||||||
] as const,
|
|
||||||
([isOpen, imei, sessionToken], previous) => {
|
|
||||||
const deviceSessionChanged =
|
|
||||||
previous !== undefined &&
|
|
||||||
previous[0] &&
|
|
||||||
(previous[1] !== imei || previous[2] !== sessionToken)
|
|
||||||
if (!isOpen || deviceSessionChanged) cancelRecording()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-hud[data-horizontal='left'] {
|
.radio-hud[data-horizontal='left'] {
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ describe('SpringboardWidget UI contract', () => {
|
|||||||
expect(source).toContain('music.progress.value')
|
expect(source).toContain('music.progress.value')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fades empty music artwork downward while keeping its message above the fade', () => {
|
||||||
|
expect(source).toContain('.home-widget--music-empty::after')
|
||||||
|
expect(source).toContain(
|
||||||
|
'.home-widget--music-empty .widget-music-placeholder',
|
||||||
|
)
|
||||||
|
expect(source).toContain('-webkit-mask-image: linear-gradient(')
|
||||||
|
expect(source).toMatch(/\.widget-music-empty\s*{[\s\S]*?z-index:\s*2;/)
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'sunny',
|
'sunny',
|
||||||
'clear',
|
'clear',
|
||||||
|
|||||||
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
|
|||||||
backdrop-filter: blur(26px) saturate(125%);
|
backdrop-filter: blur(26px) saturate(125%);
|
||||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
|
||||||
'Segoe UI', sans-serif;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
@@ -1076,6 +1074,39 @@ onBeforeUnmount(() => {
|
|||||||
background: rgb(39 39 42 / 95%);
|
background: rgb(39 39 42 / 95%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-widget--music-empty::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
inset: 0;
|
||||||
|
content: '';
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 34%,
|
||||||
|
rgb(20 20 22 / 18%) 62%,
|
||||||
|
rgb(16 16 18 / 82%) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-widget--music-empty .widget-album,
|
||||||
|
.home-widget--music-empty .widget-music-placeholder {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#000 0%,
|
||||||
|
#000 42%,
|
||||||
|
rgb(0 0 0 / 42%) 72%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#000 0%,
|
||||||
|
#000 42%,
|
||||||
|
rgb(0 0 0 / 42%) 72%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.home-widget-shell--large .home-widget--music {
|
.home-widget-shell--large .home-widget--music {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1168,12 +1199,12 @@ onBeforeUnmount(() => {
|
|||||||
.widget-music-empty {
|
.widget-music-empty {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 15px;
|
right: 15px;
|
||||||
bottom: 11px;
|
bottom: 14px;
|
||||||
left: 15px;
|
left: 15px;
|
||||||
z-index: 0;
|
z-index: 2;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgb(255 255 255 / 72%);
|
color: rgb(255 255 255 / 72%);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 17px;
|
line-height: 17px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ export function getPhoneAppLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||||
|
if (app.adminOnly) return false
|
||||||
return app.kind === 'external'
|
return app.kind === 'external'
|
||||||
? app.removable && !app.defaultInstalled
|
? app.removable && !app.defaultInstalled
|
||||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Directive } from 'vue'
|
||||||
|
|
||||||
|
const inputListeners = new WeakMap<HTMLInputElement, () => void>()
|
||||||
|
const MINIMUM_INPUT_WIDTH = 42
|
||||||
|
|
||||||
|
function syncInputWidth(input: HTMLInputElement): void {
|
||||||
|
input.style.width = '1px'
|
||||||
|
input.style.width = `${Math.max(MINIMUM_INPUT_WIDTH, Math.ceil(input.scrollWidth) + 2)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vConfigInputWidth: Directive<HTMLInputElement> = {
|
||||||
|
mounted(input) {
|
||||||
|
const listener = () => syncInputWidth(input)
|
||||||
|
inputListeners.set(input, listener)
|
||||||
|
input.addEventListener('input', listener)
|
||||||
|
syncInputWidth(input)
|
||||||
|
},
|
||||||
|
updated(input) {
|
||||||
|
syncInputWidth(input)
|
||||||
|
},
|
||||||
|
beforeUnmount(input) {
|
||||||
|
const listener = inputListeners.get(input)
|
||||||
|
if (listener) input.removeEventListener('input', listener)
|
||||||
|
inputListeners.delete(input)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
||||||
|
|
||||||
type Tone = {
|
type Tone = {
|
||||||
@@ -46,7 +48,10 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
|
|||||||
oscillator.type = tone.type
|
oscillator.type = tone.type
|
||||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||||
gain.gain.setValueAtTime(0.0001, start)
|
gain.gain.setValueAtTime(0.0001, start)
|
||||||
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
|
||||||
|
start + 0.012,
|
||||||
|
)
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||||
oscillator.connect(gain)
|
oscillator.connect(gain)
|
||||||
gain.connect(audioContext.destination)
|
gain.connect(audioContext.destination)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
||||||
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
||||||
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
||||||
@@ -28,7 +30,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const players = Array.from({ length: 3 }, () => {
|
const players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(soundUrls[sound])
|
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.82
|
player.volume = 0.82
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
export type NeonDropSound =
|
export type NeonDropSound =
|
||||||
| 'clear'
|
| 'clear'
|
||||||
| 'drop'
|
| 'drop'
|
||||||
@@ -48,7 +50,10 @@ function playSequence(sound: NeonDropSound): void {
|
|||||||
oscillator.type = type
|
oscillator.type = type
|
||||||
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
||||||
gain.gain.setValueAtTime(0.0001, start + delay)
|
gain.gain.setValueAtTime(0.0001, start + delay)
|
||||||
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
|
||||||
|
start + delay + 0.008,
|
||||||
|
)
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
||||||
oscillator.connect(gain)
|
oscillator.connect(gain)
|
||||||
gain.connect(context.destination)
|
gain.connect(context.destination)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
||||||
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
||||||
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
||||||
@@ -18,7 +20,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const players = Array.from({ length: 3 }, () => {
|
const players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(soundUrls[sound])
|
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.82
|
player.volume = 0.82
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
||||||
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
||||||
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
||||||
@@ -11,7 +13,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
|
|||||||
let players = pools.get(sound)
|
let players = pools.get(sound)
|
||||||
if (!players) {
|
if (!players) {
|
||||||
players = Array.from({ length: 3 }, () => {
|
players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(urls[sound])
|
const player = registerPhoneMediaElement(new Audio(urls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.84
|
player.volume = 0.84
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
||||||
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
||||||
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
||||||
@@ -18,7 +20,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const players = Array.from({ length: 3 }, () => {
|
const players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(soundUrls[sound])
|
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.84
|
player.volume = 0.84
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const mediaConfig = readFileSync(
|
||||||
|
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const manifest = readFileSync(
|
||||||
|
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaProviderConfig = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../sky_phone/source/server/media_provider_config.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaImportAdapter = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const memoServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaCapture = readFileSync(
|
||||||
|
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const memoRecorder = readFileSync(
|
||||||
|
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('FiveManage server configuration contract', () => {
|
||||||
|
it('keeps the provider token in the server-only media config', () => {
|
||||||
|
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||||
|
expect(mediaProviderConfig).toContain(
|
||||||
|
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||||
|
)
|
||||||
|
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||||
|
)
|
||||||
|
expect(mediaImportAdapter).toContain(
|
||||||
|
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||||
|
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||||
|
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||||
|
expect(mediaCapture).not.toContain("form.append('path'")
|
||||||
|
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||||
|
expect(memoRecorder).toContain(
|
||||||
|
"form.append('file', pending.blob, pending.fileName)",
|
||||||
|
)
|
||||||
|
expect(memoRecorder).not.toContain("form.append('path'")
|
||||||
|
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'Accepting the direct FiveManage upload response',
|
||||||
|
)
|
||||||
|
expect(mediaServer).toContain('remote_id = remote_id')
|
||||||
|
expect(mediaServer).toContain('url = uploaded_url')
|
||||||
|
expect(mediaServer).not.toContain('"HEAD"')
|
||||||
|
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allowlists the FiveManage API and media hosts', () => {
|
||||||
|
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||||
|
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates and preserves the recorded memo size before upload', () => {
|
||||||
|
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||||
|
expect(memoServer).toContain(
|
||||||
|
'local size_bytes = tonumber(data.sizeBytes)',
|
||||||
|
)
|
||||||
|
expect(memoServer).toContain(
|
||||||
|
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||||
|
)
|
||||||
|
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||||
|
expect(mediaServer).toContain('size = state.size_bytes')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const mediaCapture = readFileSync(
|
||||||
|
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('media upload diagnostics contracts', () => {
|
||||||
|
it('forwards browser upload failure context to the server log', () => {
|
||||||
|
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||||
|
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||||
|
expect(mediaServer).toContain('Client-reported upload failure')
|
||||||
|
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||||
|
const audioSource = readFileSync(
|
||||||
|
new URL('./utils/phoneAudio.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mainCss = readFileSync(
|
||||||
|
new URL('./assets/main.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const musicSource = readFileSync(
|
||||||
|
new URL('./stores/music.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('phone audio output contract', () => {
|
||||||
|
it('applies the hardware volume to app media and live YouTube playback', () => {
|
||||||
|
expect(appSource).toContain('installPhoneAudioController()')
|
||||||
|
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
|
||||||
|
expect(appSource).toContain(
|
||||||
|
'if (radio.data.connected) void radio.setVolume(volume)',
|
||||||
|
)
|
||||||
|
expect(audioSource).toContain(
|
||||||
|
"document.addEventListener('play', onMediaPlay, true)",
|
||||||
|
)
|
||||||
|
expect(audioSource).toContain('localVolume * outputVolume')
|
||||||
|
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
|
||||||
|
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the hardware speaker symbol in grey', () => {
|
||||||
|
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const readResourceFile = (path: string) =>
|
const readResourceFile = (path: string) =>
|
||||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||||
const readFrontendFile = (path: string) =>
|
const readFrontendFile = (path: string) =>
|
||||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||||
|
|
||||||
@@ -34,9 +34,12 @@ describe('phone inventory contracts', () => {
|
|||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
|
|
||||||
expect(phoneServer).toContain(
|
expect(phoneServer).toContain(
|
||||||
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
|
'Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||||
|
)
|
||||||
|
expect(phoneServer).toContain('if Config.Phone.Item == item_name then')
|
||||||
|
expect(phoneServer).toContain(
|
||||||
|
'if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||||
)
|
)
|
||||||
expect(phoneServer).toContain('if not usable_registered then')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
||||||
@@ -221,8 +224,12 @@ describe('phone inventory contracts', () => {
|
|||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
|
|
||||||
expect(config).toContain('Keybind = "F1"')
|
expect(config).toContain('Keybind = "F1"')
|
||||||
|
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
|
||||||
expect(phoneClient).toContain(
|
expect(phoneClient).toContain(
|
||||||
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
|
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'if active_key_mapping_command == command_name then',
|
||||||
)
|
)
|
||||||
expect(phoneClient).toContain(
|
expect(phoneClient).toContain(
|
||||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||||
@@ -232,6 +239,42 @@ describe('phone inventory contracts', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies development command changes immediately without a resource restart', () => {
|
||||||
|
const phoneClient = readResourceFile('source/client/main.lua')
|
||||||
|
|
||||||
|
expect(phoneClient).toContain('local active_development_command = nil')
|
||||||
|
expect(phoneClient).toContain('refresh_development_command = function()')
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain('RegisterCommand(command_name, function()')
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'if active_development_command == command_name and Config.Phone.DevelopmentCommand then',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toMatch(
|
||||||
|
/AddEventHandler\("sky_phone:configurator:updated", function\(\)[\s\S]*?refresh_development_command\(\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a running live activity with Space without affecting normal gameplay', () => {
|
||||||
|
const phoneClient = readResourceFile('source/client/main.lua')
|
||||||
|
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'RegisterCommand("sky_phone_live_activity_open"',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'if not live_activity_active or is_open or open_requested then',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain('"SPACE"')
|
||||||
|
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
|
|
||||||
@@ -246,7 +289,7 @@ describe('phone inventory contracts', () => {
|
|||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
const migration = readResourceFile('source/server/db_migrate.lua')
|
const migration = readResourceFile('source/server/db_migrate.lua')
|
||||||
|
|
||||||
expect(phoneServer).toContain('if not unique_phones then')
|
expect(phoneServer).toContain('if Config.Phone.Unique == false then')
|
||||||
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
||||||
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
||||||
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
||||||
|
|||||||
@@ -29,4 +29,10 @@ describe('neutral phone navigation contract', () => {
|
|||||||
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
||||||
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defers command-driven app routes until setup or device unlock completes', () => {
|
||||||
|
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
|
||||||
|
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
|
||||||
|
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
|
|||||||
name: 'development-sky-ui',
|
name: 'development-sky-ui',
|
||||||
path: '/development/sky-ui/:demo?',
|
path: '/development/sky-ui/:demo?',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: () =>
|
||||||
|
import('@/views/development/PhoneDynamicIslandGallery.vue'),
|
||||||
|
name: 'development-dynamic-islands',
|
||||||
|
path: '/development/dynamic-islands',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminAuditEntry,
|
||||||
|
AdminActivityResponse,
|
||||||
|
AdminBootstrap,
|
||||||
|
AdminCallActivity,
|
||||||
|
AdminConfigurator,
|
||||||
|
AdminConfiguratorChange,
|
||||||
|
AdminCredential,
|
||||||
|
AdminMessageActivity,
|
||||||
|
AdminPlayerDetail,
|
||||||
|
AdminPlayerSummary,
|
||||||
|
AdminStats,
|
||||||
|
} from '@/types/admin'
|
||||||
|
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||||
|
|
||||||
|
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||||
|
|
||||||
|
export const useAdminStore = defineStore('admin', {
|
||||||
|
state: () => ({
|
||||||
|
actionKey: '',
|
||||||
|
activityKey: '',
|
||||||
|
audit: [] as AdminAuditEntry[],
|
||||||
|
configurator: null as AdminConfigurator | null,
|
||||||
|
configuratorLoading: false,
|
||||||
|
detailLoading: false,
|
||||||
|
error: '',
|
||||||
|
initialized: false,
|
||||||
|
loading: false,
|
||||||
|
players: [] as AdminPlayerSummary[],
|
||||||
|
revealedCredentials: {} as Record<string, AdminCredential>,
|
||||||
|
deviceActivity: {} as Record<
|
||||||
|
string,
|
||||||
|
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
|
||||||
|
>,
|
||||||
|
selectedPlayer: null as AdminPlayerDetail | null,
|
||||||
|
stats: { ...EMPTY_STATS },
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async load(): Promise<boolean> {
|
||||||
|
this.loading = true
|
||||||
|
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
|
||||||
|
this.loading = false
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.players = response.data.players
|
||||||
|
this.stats = response.data.stats
|
||||||
|
this.audit = response.data.audit
|
||||||
|
this.error = ''
|
||||||
|
this.initialized = true
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async openPlayer(source: number): Promise<boolean> {
|
||||||
|
this.detailLoading = true
|
||||||
|
this.revealedCredentials = {}
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.detailLoading = false
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
this.error = ''
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
closePlayer(): void {
|
||||||
|
this.selectedPlayer = null
|
||||||
|
this.revealedCredentials = {}
|
||||||
|
},
|
||||||
|
async saveApps(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
revision: number,
|
||||||
|
changes: Array<{ appId: string; installed: boolean }>,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:save`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
|
||||||
|
changes,
|
||||||
|
imei,
|
||||||
|
revision,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async revealPassword(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
): Promise<NuiResponse<AdminCredential>> {
|
||||||
|
this.actionKey = `${imei}:password`
|
||||||
|
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
|
||||||
|
imei,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.revealedCredentials[imei] = response.data
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async loadActivity(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
kind: 'messages' | 'calls',
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.activityKey = `${imei}:${kind}`
|
||||||
|
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
|
||||||
|
imei,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.activityKey = ''
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const activity = this.deviceActivity[imei] ?? {}
|
||||||
|
if (response.data.kind === 'messages') {
|
||||||
|
activity.messages = response.data.entries
|
||||||
|
} else {
|
||||||
|
activity.calls = response.data.entries
|
||||||
|
}
|
||||||
|
this.deviceActivity[imei] = activity
|
||||||
|
this.error = ''
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async loadConfigurator(): Promise<boolean> {
|
||||||
|
this.configuratorLoading = true
|
||||||
|
const response = await nuiCall<AdminConfigurator>('admin:configurator')
|
||||||
|
this.configuratorLoading = false
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.configurator = response.data
|
||||||
|
this.error = ''
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async saveConfigurator(
|
||||||
|
changes: AdminConfiguratorChange[],
|
||||||
|
): Promise<NuiResponse<AdminConfigurator>> {
|
||||||
|
const current = this.configurator
|
||||||
|
if (!current) return { error: 'request_failed', success: false }
|
||||||
|
|
||||||
|
this.actionKey = 'configurator:save'
|
||||||
|
const response = await nuiCall<AdminConfigurator>(
|
||||||
|
'admin:save-configurator',
|
||||||
|
{
|
||||||
|
changes,
|
||||||
|
revision: current.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.configurator = response.data
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
if (response.data) this.configurator = response.data
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async resetPasscode(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:reset-passcode`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>(
|
||||||
|
'admin:reset-passcode',
|
||||||
|
{ imei, source },
|
||||||
|
)
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
delete this.revealedCredentials[imei]
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async changeNumber(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:change-number`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
|
||||||
|
imei,
|
||||||
|
phoneNumber,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
delete this.deviceActivity[imei]
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async factoryReset(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:factory-reset`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
|
||||||
|
imei,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
delete this.deviceActivity[imei]
|
||||||
|
delete this.revealedCredentials[imei]
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -64,6 +64,48 @@ describe('app store', () => {
|
|||||||
expect(apps.isInstalled('snake')).toBe(false)
|
expect(apps.isInstalled('snake')).toBe(false)
|
||||||
expect(apps.isInstalled('health')).toBe(true)
|
expect(apps.isInstalled('health')).toBe(true)
|
||||||
expect(apps.isInstalled('citywarn')).toBe(true)
|
expect(apps.isInstalled('citywarn')).toBe(true)
|
||||||
|
expect(apps.homeLayout.dock).toEqual([
|
||||||
|
'phone',
|
||||||
|
'messages',
|
||||||
|
'camera',
|
||||||
|
'clock',
|
||||||
|
])
|
||||||
|
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||||
|
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the retired admin app from persisted phone layouts', () => {
|
||||||
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
apps.hydrate({ claimedApps: ['admin'] })
|
||||||
|
expect(apps.homeLayout.grid).not.toContain('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||||
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
apps.hydrate({
|
||||||
|
homeLayout: {
|
||||||
|
dock: ['phone', 'messages', 'camera', 'clock'],
|
||||||
|
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
|
||||||
|
hidden: [],
|
||||||
|
pageCount: 1,
|
||||||
|
version: HOME_LAYOUT_VERSION,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(apps.homeLayout.dock).toEqual([
|
||||||
|
'phone',
|
||||||
|
'messages',
|
||||||
|
'camera',
|
||||||
|
'clock',
|
||||||
|
])
|
||||||
|
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||||
|
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||||
|
}
|
||||||
|
expect(apps.homeLayout.grid).toContain('calculator')
|
||||||
|
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes old automatic apps unless the player installed them', () => {
|
it('removes old automatic apps unless the player installed them', () => {
|
||||||
@@ -320,7 +362,7 @@ describe('app store', () => {
|
|||||||
const apps = useAppStoreStore()
|
const apps = useAppStoreStore()
|
||||||
apps.hydrate(null)
|
apps.hydrate(null)
|
||||||
mocks.phone.saveDeviceNamespace.mockClear()
|
mocks.phone.saveDeviceNamespace.mockClear()
|
||||||
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
|
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
|
||||||
expect(sourceIndex).toBeGreaterThanOrEqual(0)
|
expect(sourceIndex).toBeGreaterThanOrEqual(0)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -329,7 +371,7 @@ describe('app store', () => {
|
|||||||
HOME_GRID_PAGE_SIZE,
|
HOME_GRID_PAGE_SIZE,
|
||||||
]),
|
]),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
|
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
|
||||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -386,18 +428,18 @@ describe('app store', () => {
|
|||||||
mocks.phone.saveDeviceNamespace.mockClear()
|
mocks.phone.saveDeviceNamespace.mockClear()
|
||||||
|
|
||||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||||
const clockIndex = apps.homeLayout.grid.indexOf('clock')
|
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||||
const folderId = apps.createHomeFolder(
|
const folderId = apps.createHomeFolder(
|
||||||
'grid',
|
'grid',
|
||||||
notesIndex,
|
notesIndex,
|
||||||
'grid',
|
'grid',
|
||||||
clockIndex,
|
settingsIndex,
|
||||||
'Utilities',
|
'Utilities',
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(folderId).toBeTruthy()
|
expect(folderId).toBeTruthy()
|
||||||
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
||||||
'clock',
|
'settings',
|
||||||
'notes',
|
'notes',
|
||||||
])
|
])
|
||||||
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
||||||
@@ -405,7 +447,7 @@ describe('app store', () => {
|
|||||||
apps.moveHomeFolderApp(folderId!, 2, 0)
|
apps.moveHomeFolderApp(folderId!, 2, 0)
|
||||||
apps.renameHomeFolder(folderId!, 'Work')
|
apps.renameHomeFolder(folderId!, 'Work')
|
||||||
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
||||||
apps: ['mail', 'notes', 'clock'],
|
apps: ['mail', 'notes', 'settings'],
|
||||||
name: 'Work',
|
name: 'Work',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
parseHomeLayout,
|
parseHomeLayout,
|
||||||
reflowHomeGridForWidgetChange,
|
reflowHomeGridForWidgetChange,
|
||||||
renameHomeFolder,
|
renameHomeFolder,
|
||||||
|
removeDockGridDuplicates,
|
||||||
removeHomeApp,
|
removeHomeApp,
|
||||||
restoreHomeApp,
|
restoreHomeApp,
|
||||||
type HomeArea,
|
type HomeArea,
|
||||||
@@ -45,7 +46,7 @@ const pendingInstallations = new WeakMap<
|
|||||||
>()
|
>()
|
||||||
|
|
||||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||||
return [...PHONE_APPS]
|
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||||
.map((app) => app.id)
|
.map((app) => app.id)
|
||||||
}
|
}
|
||||||
@@ -57,11 +58,12 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||||
return PHONE_APPS.filter((app) =>
|
return PHONE_APPS.filter((app) => {
|
||||||
isExternalPhoneApp(app)
|
if (app.adminOnly) return false
|
||||||
|
return isExternalPhoneApp(app)
|
||||||
? app.defaultInstalled
|
? app.defaultInstalled
|
||||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||||
).map((app) => app.id)
|
}).map((app) => app.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||||
@@ -236,13 +238,15 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
layoutVersion === 5 ||
|
layoutVersion === 5 ||
|
||||||
layoutVersion === 6
|
layoutVersion === 6
|
||||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||||
? data.claimedApps.filter(
|
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
|
||||||
(id): id is LaunchablePhoneAppId =>
|
if (typeof id !== 'string') return false
|
||||||
typeof id === 'string' &&
|
const app = getPhoneApp(id)
|
||||||
(isPhoneAppId(id) ||
|
if (app?.adminOnly) return false
|
||||||
(supportsPersistedExternalApps &&
|
return (
|
||||||
isValidExternalPhoneAppId(id))),
|
isPhoneAppId(id) ||
|
||||||
)
|
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
|
||||||
|
)
|
||||||
|
})
|
||||||
: []
|
: []
|
||||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||||
@@ -263,12 +267,16 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
getDefaultGridIds(),
|
getDefaultGridIds(),
|
||||||
getDefaultDockIds(),
|
getDefaultDockIds(),
|
||||||
)
|
)
|
||||||
this.homeLayout = parseHomeLayout(
|
const parsedHomeLayout = parseHomeLayout(
|
||||||
data?.homeLayout,
|
data?.homeLayout,
|
||||||
defaults,
|
defaults,
|
||||||
installedIds,
|
installedIds,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
|
||||||
|
const removedDockGridDuplicates =
|
||||||
|
normalizedHomeLayout !== parsedHomeLayout
|
||||||
|
this.homeLayout = normalizedHomeLayout
|
||||||
const protectedHiddenAppIds =
|
const protectedHiddenAppIds =
|
||||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||||
for (const appId of protectedHiddenAppIds) {
|
for (const appId of protectedHiddenAppIds) {
|
||||||
@@ -292,6 +300,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
this.hydrated = true
|
this.hydrated = true
|
||||||
if (
|
if (
|
||||||
protectedHiddenAppIds.length ||
|
protectedHiddenAppIds.length ||
|
||||||
|
removedDockGridDuplicates ||
|
||||||
removedLegacyDefaults ||
|
removedLegacyDefaults ||
|
||||||
layoutVersion === 2 ||
|
layoutVersion === 2 ||
|
||||||
layoutVersion === 3 ||
|
layoutVersion === 3 ||
|
||||||
@@ -302,9 +311,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||||
|
const app = getPhoneApp(appId)
|
||||||
|
if (app?.adminOnly) return false
|
||||||
if (this.uninstalledApps.includes(appId)) return false
|
if (this.uninstalledApps.includes(appId)) return false
|
||||||
if (this.claimedApps.includes(appId)) return true
|
if (this.claimedApps.includes(appId)) return true
|
||||||
const app = getPhoneApp(appId)
|
|
||||||
if (!app) return false
|
if (!app) return false
|
||||||
return isExternalPhoneApp(app)
|
return isExternalPhoneApp(app)
|
||||||
? app.defaultInstalled
|
? app.defaultInstalled
|
||||||
@@ -320,11 +330,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
getDefaultDockIds(),
|
getDefaultDockIds(),
|
||||||
)
|
)
|
||||||
const previous = JSON.stringify(this.homeLayout)
|
const previous = JSON.stringify(this.homeLayout)
|
||||||
this.homeLayout = parseHomeLayout(
|
this.homeLayout = removeDockGridDuplicates(
|
||||||
this.homeLayout,
|
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
|
||||||
defaults,
|
|
||||||
installedIds,
|
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const appId of [...this.homeLayout.hidden]) {
|
for (const appId of [...this.homeLayout.hidden]) {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type {
|
|||||||
MusicTrack,
|
MusicTrack,
|
||||||
} from '@/types/music'
|
} from '@/types/music'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import {
|
||||||
|
getPhoneOutputVolume,
|
||||||
|
registerPhoneMediaElement,
|
||||||
|
subscribePhoneOutputVolume,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
|
|
||||||
export type YouTubePlayer = {
|
export type YouTubePlayer = {
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
@@ -48,7 +53,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const audio = new Audio()
|
const audio = registerPhoneMediaElement(new Audio())
|
||||||
audio.preload = 'auto'
|
audio.preload = 'auto'
|
||||||
const YOUTUBE_API_TIMEOUT_MS = 12000
|
const YOUTUBE_API_TIMEOUT_MS = 12000
|
||||||
let audioBound = false
|
let audioBound = false
|
||||||
@@ -59,6 +64,11 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
|
|||||||
let youtubePlayer: YouTubePlayer | null = null
|
let youtubePlayer: YouTubePlayer | null = null
|
||||||
let youtubeProgressTimer: number | null = null
|
let youtubeProgressTimer: number | null = null
|
||||||
|
|
||||||
|
subscribePhoneOutputVolume((volume) => {
|
||||||
|
const localVolume = useMusicStore().volume
|
||||||
|
youtubePlayer?.setVolume(localVolume * volume * 100)
|
||||||
|
})
|
||||||
|
|
||||||
export function musicTrackKey(
|
export function musicTrackKey(
|
||||||
track: Pick<MusicTrack, 'id' | 'source'>,
|
track: Pick<MusicTrack, 'id' | 'source'>,
|
||||||
): string {
|
): string {
|
||||||
@@ -276,7 +286,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
|||||||
const api = await loadYouTubeApi()
|
const api = await loadYouTubeApi()
|
||||||
const store = useMusicStore()
|
const store = useMusicStore()
|
||||||
if (youtubePlayer) {
|
if (youtubePlayer) {
|
||||||
youtubePlayer.setVolume(store.volume * 100)
|
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
|
||||||
youtubePlayer.loadVideoById(videoId)
|
youtubePlayer.loadVideoById(videoId)
|
||||||
youtubePlayer.playVideo()
|
youtubePlayer.playVideo()
|
||||||
startYoutubeProgress()
|
startYoutubeProgress()
|
||||||
@@ -319,7 +329,9 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
onReady: (event) => {
|
onReady: (event) => {
|
||||||
youtubePlayer = event.target
|
youtubePlayer = event.target
|
||||||
event.target.setVolume(useMusicStore().volume * 100)
|
event.target.setVolume(
|
||||||
|
useMusicStore().volume * getPhoneOutputVolume() * 100,
|
||||||
|
)
|
||||||
event.target.playVideo()
|
event.target.playVideo()
|
||||||
startYoutubeProgress()
|
startYoutubeProgress()
|
||||||
resolve()
|
resolve()
|
||||||
@@ -554,7 +566,7 @@ export const useMusicStore = defineStore('music', {
|
|||||||
setVolume(value: number): void {
|
setVolume(value: number): void {
|
||||||
this.volume = Math.max(0, Math.min(1, value))
|
this.volume = Math.max(0, Math.min(1, value))
|
||||||
audio.volume = this.volume
|
audio.volume = this.volume
|
||||||
youtubePlayer?.setVolume(this.volume * 100)
|
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
|
||||||
},
|
},
|
||||||
stop(): void {
|
stop(): void {
|
||||||
stopActiveMedia()
|
stopActiveMedia()
|
||||||
|
|||||||
@@ -118,6 +118,29 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parseTable([])
|
parseTable([])
|
||||||
|
while (position < tokens.length) {
|
||||||
|
const assignment = tokens.findIndex(
|
||||||
|
(token, index) => index >= position && token.kind === '=',
|
||||||
|
)
|
||||||
|
if (assignment < 0) break
|
||||||
|
const nui = tokens.findIndex(
|
||||||
|
(token, index) =>
|
||||||
|
index >= position && index < assignment && token.value === 'Nui',
|
||||||
|
)
|
||||||
|
if (nui < 0) {
|
||||||
|
position = assignment + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const path = [
|
||||||
|
'Nui',
|
||||||
|
...tokens
|
||||||
|
.slice(nui + 1, assignment)
|
||||||
|
.filter((token) => token.kind === 'word')
|
||||||
|
.map((token) => token.value),
|
||||||
|
]
|
||||||
|
position = assignment + 1
|
||||||
|
parseValue(path)
|
||||||
|
}
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+599
-39
@@ -38,6 +38,7 @@ export type PhoneOpenPayload = {
|
|||||||
locales?: LocaleTree
|
locales?: LocaleTree
|
||||||
memos?: DeviceBootstrap['memos']
|
memos?: DeviceBootstrap['memos']
|
||||||
notes?: DeviceBootstrap['notes']
|
notes?: DeviceBootstrap['notes']
|
||||||
|
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
|
||||||
player?: DeviceBootstrap['player']
|
player?: DeviceBootstrap['player']
|
||||||
security?: DeviceSecurity
|
security?: DeviceSecurity
|
||||||
token?: string
|
token?: string
|
||||||
@@ -638,7 +639,8 @@ const cryptoFallbackLocales = {
|
|||||||
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
||||||
handle_taken: 'That VaultX handle is already taken.',
|
handle_taken: 'That VaultX handle is already taken.',
|
||||||
profile_exists: 'This character already owns a VaultX profile.',
|
profile_exists: 'This character already owns a VaultX profile.',
|
||||||
invalid_password: 'Password must be 8–72 characters.',
|
invalid_password:
|
||||||
|
'Use 8–72 characters with uppercase, lowercase, a number, and a special character.',
|
||||||
password_mismatch: 'The passwords do not match.',
|
password_mismatch: 'The passwords do not match.',
|
||||||
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
||||||
invalid_credentials: 'The password is incorrect.',
|
invalid_credentials: 'The password is incorrect.',
|
||||||
@@ -805,7 +807,404 @@ const citywarnFallbackLocales = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminPanelFallbackLocales = {
|
||||||
|
name: 'Phone Admin',
|
||||||
|
subtitle: 'Administration',
|
||||||
|
navigation: 'Admin navigation',
|
||||||
|
refresh: 'Refresh admin data',
|
||||||
|
loading: 'Loading protected data...',
|
||||||
|
tabs: {
|
||||||
|
overview: 'Overview',
|
||||||
|
players: 'Players',
|
||||||
|
devices: 'Devices',
|
||||||
|
apps: 'Apps',
|
||||||
|
accounts: 'Accounts',
|
||||||
|
messages: 'Messages',
|
||||||
|
calls: 'Calls',
|
||||||
|
moderation: 'Moderation',
|
||||||
|
audit: 'Audit',
|
||||||
|
configurator: 'Phone configurator',
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
eyebrow: 'Server',
|
||||||
|
title: 'Dashboard',
|
||||||
|
body: 'Players, devices, apps, and phone data.',
|
||||||
|
stats: 'Server phone statistics',
|
||||||
|
online: 'Online',
|
||||||
|
devices: 'Devices',
|
||||||
|
accounts: 'Accounts',
|
||||||
|
audit: 'Audit entries',
|
||||||
|
control: 'Navigation',
|
||||||
|
features: 'Modules',
|
||||||
|
featuresBody: 'Open an administration module.',
|
||||||
|
recent: 'Recent activity',
|
||||||
|
playerFeature: 'Identity, finances, job, and duty',
|
||||||
|
deviceFeature: 'IMEI, SIM, number, and activity',
|
||||||
|
appFeature: 'Install or remove phone apps',
|
||||||
|
accountFeature: 'Account access and protected credentials',
|
||||||
|
messageFeature: 'Review recent SMS activity',
|
||||||
|
callFeature: 'Review recent call activity',
|
||||||
|
moderationFeature: 'Reset access, number, or device data',
|
||||||
|
auditFeature: 'Review sensitive admin actions',
|
||||||
|
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
eyebrow: 'Appearance',
|
||||||
|
title: 'Interface',
|
||||||
|
body: 'Personalize colors and typography across the admin workspace.',
|
||||||
|
colors: {
|
||||||
|
emerald: 'Emerald',
|
||||||
|
blue: 'Blue',
|
||||||
|
violet: 'Violet',
|
||||||
|
orange: 'Orange',
|
||||||
|
red: 'Red',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
customColor: 'Custom color',
|
||||||
|
customColorBody: 'Choose any RGB accent or enter its channel values.',
|
||||||
|
red: 'R',
|
||||||
|
green: 'G',
|
||||||
|
blue: 'B',
|
||||||
|
hex: 'HEX color',
|
||||||
|
value: 'value',
|
||||||
|
fontFamily: 'Font family',
|
||||||
|
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
|
||||||
|
fontSize: 'Font size',
|
||||||
|
fontSizeBody: 'Scale text and controls for comfortable readability.',
|
||||||
|
fonts: {
|
||||||
|
inter: 'Inter',
|
||||||
|
system: 'System',
|
||||||
|
classic: 'Classic',
|
||||||
|
verdana: 'Verdana',
|
||||||
|
tahoma: 'Tahoma',
|
||||||
|
trebuchet: 'Trebuchet',
|
||||||
|
georgia: 'Georgia',
|
||||||
|
mono: 'Monospace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configurator: {
|
||||||
|
context: 'Runtime configuration',
|
||||||
|
eyebrow: 'System tool',
|
||||||
|
sections: 'Configuration',
|
||||||
|
search: 'Search settings or paths',
|
||||||
|
configScope: 'config.lua',
|
||||||
|
mediaScope: 'media.lua',
|
||||||
|
noResults: 'No matching settings',
|
||||||
|
loading: 'Loading SQL configuration...',
|
||||||
|
title: 'Phone configurator',
|
||||||
|
body: 'Manage phone and media settings from the protected admin workspace.',
|
||||||
|
disabledTitle: 'SQL configuration is not active',
|
||||||
|
disabledBody:
|
||||||
|
'Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.',
|
||||||
|
manualSave: 'Manual save',
|
||||||
|
refreshNotice:
|
||||||
|
'Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.',
|
||||||
|
fieldCount: '{count} fields',
|
||||||
|
secretConfigured: 'Secret configured · enter a replacement',
|
||||||
|
invalidValue: 'Check the highlighted table or number value.',
|
||||||
|
saved: 'SQL configuration saved and applied.',
|
||||||
|
descriptions: {
|
||||||
|
featureToggle: 'Turns {name} on or off.',
|
||||||
|
boolean: 'Controls whether {name} is allowed.',
|
||||||
|
number: 'Sets the numeric value for {name}.',
|
||||||
|
text: 'Sets the text value used for {name}.',
|
||||||
|
optionalText:
|
||||||
|
'Sets the optional value for {name}; switch it off to disable it.',
|
||||||
|
list: 'Manages all entries used for {name}.',
|
||||||
|
table: 'Groups the related settings for {name}.',
|
||||||
|
credential: 'Stores the protected credential used by {name}.',
|
||||||
|
url: 'Sets the URL or endpoint used by {name}.',
|
||||||
|
hosts: 'Defines which domains are allowed for {name}.',
|
||||||
|
milliseconds: 'Sets the timing for {name} in milliseconds.',
|
||||||
|
seconds: 'Sets the timing for {name} in seconds.',
|
||||||
|
rateLimit: 'Limits how many {name} actions are allowed per minute.',
|
||||||
|
byteLimit: 'Sets the maximum data size allowed for {name}.',
|
||||||
|
textLimit: 'Sets the maximum text length allowed for {name}.',
|
||||||
|
distance: 'Sets the world distance used for {name}.',
|
||||||
|
coordinates: 'Sets the world coordinates or orientation for {name}.',
|
||||||
|
gameAsset: 'Sets the GTA model or prop used for {name}.',
|
||||||
|
animation: 'Sets the animation asset used for {name}.',
|
||||||
|
access: 'Defines the jobs, groups or permission level for {name}.',
|
||||||
|
integration: 'Selects the connected framework or provider for {name}.',
|
||||||
|
path: 'Sets the storage or resource path used for {name}.',
|
||||||
|
color: 'Sets the interface color used for {name}.',
|
||||||
|
displayText: 'Sets the text shown to players for {name}.',
|
||||||
|
phoneNumber: 'Sets the phone or service number used for {name}.',
|
||||||
|
routing: 'Controls how incoming requests are routed for {name}.',
|
||||||
|
command: 'Sets the chat command used to open or run {name}.',
|
||||||
|
locale: 'Selects the language used for {name}.',
|
||||||
|
debug: 'Controls detailed diagnostic output for {name}.',
|
||||||
|
mediaQuality: 'Sets the media quality or volume used for {name}.',
|
||||||
|
amount: 'Sets the maximum or displayed amount for {name}.',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
list: 'List',
|
||||||
|
table: 'Key table',
|
||||||
|
vector: 'Vector',
|
||||||
|
entry: 'Entry',
|
||||||
|
general: 'General',
|
||||||
|
subtabs: {
|
||||||
|
AdminGroups: 'Admin Groups',
|
||||||
|
Dictionaries: 'Dictionaries',
|
||||||
|
Clips: 'Clips',
|
||||||
|
Transforms: 'Transforms',
|
||||||
|
RateLimits: 'Rate Limits',
|
||||||
|
Publishers: 'Publishers',
|
||||||
|
ExternalPingResources: 'External Ping Resources',
|
||||||
|
Markets: 'Markets',
|
||||||
|
TrustedAdapters: 'Trusted Adapters',
|
||||||
|
AllowedDisappearTimers: 'Allowed Disappear Timers',
|
||||||
|
MusicTracks: 'Music Tracks',
|
||||||
|
VehicleImages: 'Vehicle Images',
|
||||||
|
Custom: 'Custom',
|
||||||
|
Valet: 'Valet',
|
||||||
|
AutoPriority: 'Automatic Priority',
|
||||||
|
Camera: 'Camera',
|
||||||
|
Categories: 'Categories',
|
||||||
|
Districts: 'Districts',
|
||||||
|
PhotoGradients: 'Photo Gradients',
|
||||||
|
LbPhone: 'LB Phone',
|
||||||
|
Tracks: 'Tracks',
|
||||||
|
Props: 'Props',
|
||||||
|
CustomLocations: 'Custom Locations',
|
||||||
|
Animation: 'Animation',
|
||||||
|
ReportReasons: 'Report Reasons',
|
||||||
|
DisplayName: 'Display Name',
|
||||||
|
Hud: 'HUD',
|
||||||
|
Badge: 'Badge',
|
||||||
|
LockedChannels: 'Locked Channels',
|
||||||
|
NumberGroups: 'Number Groups',
|
||||||
|
CustomFare: 'Custom Fare',
|
||||||
|
DriverJobs: 'Driver Jobs',
|
||||||
|
Services: 'Services',
|
||||||
|
QuickLocations: 'Quick Locations',
|
||||||
|
AllowedJobs: 'Allowed Jobs',
|
||||||
|
Websites: 'Websites',
|
||||||
|
},
|
||||||
|
addRow: 'Add row',
|
||||||
|
addField: 'Add field',
|
||||||
|
remove: 'Remove',
|
||||||
|
emptyList: 'No rows yet. Add the first row with plus.',
|
||||||
|
emptyTable: 'No fields yet. Add the first key below.',
|
||||||
|
keyPlaceholder: 'New key',
|
||||||
|
convertToList: 'Use list',
|
||||||
|
convertToMap: 'Use typed key table',
|
||||||
|
convertToTable: 'Use key table',
|
||||||
|
types: {
|
||||||
|
string: 'Text',
|
||||||
|
number: 'Number',
|
||||||
|
boolean: 'Switch',
|
||||||
|
list: 'List',
|
||||||
|
table: 'Table',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
players: {
|
||||||
|
eyebrow: 'Active sessions',
|
||||||
|
title: 'Online players',
|
||||||
|
online: 'Online now',
|
||||||
|
empty: 'No players found',
|
||||||
|
emptyBody: 'Adjust the search or refresh the live player list.',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
players: 'Search name, ID, job, or number',
|
||||||
|
apps: 'Search apps',
|
||||||
|
clear: 'Clear search',
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
character: 'Character profile',
|
||||||
|
data: 'Player data overview',
|
||||||
|
cash: 'Cash',
|
||||||
|
bank: 'Bank',
|
||||||
|
job: 'Job',
|
||||||
|
duty: 'Duty',
|
||||||
|
onDuty: 'On duty',
|
||||||
|
offDuty: 'Off duty',
|
||||||
|
identity: 'Identity',
|
||||||
|
playerData: 'Player data',
|
||||||
|
identifier: 'Character identifier',
|
||||||
|
birthdate: 'Birthdate',
|
||||||
|
grade: 'Job grade',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
eyebrow: 'Device control',
|
||||||
|
title: 'Phones',
|
||||||
|
body: 'Inspect every phone assigned to the selected player.',
|
||||||
|
choose: 'Choose phone',
|
||||||
|
empty: 'No phone found',
|
||||||
|
emptyBody: 'This player currently has no phone device that can be managed.',
|
||||||
|
noNumber: 'No phone number',
|
||||||
|
noSim: 'No SIM',
|
||||||
|
imei: 'IMEI',
|
||||||
|
updated: 'Last activity',
|
||||||
|
apps: 'Claimed apps',
|
||||||
|
account: 'Linked account',
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
eyebrow: 'Protected data',
|
||||||
|
title: 'Credentials',
|
||||||
|
email: 'iFruit email',
|
||||||
|
password: 'iFruit password',
|
||||||
|
reveal: 'Reveal',
|
||||||
|
copy: 'Copy password',
|
||||||
|
copied: 'Password copied.',
|
||||||
|
noAccount: 'No iFruit account is linked to this phone.',
|
||||||
|
passcode: 'Device passcode',
|
||||||
|
passcodeHashed: '{length}-digit PIN · securely hashed and not recoverable',
|
||||||
|
passcodeDisabled: 'No passcode configured',
|
||||||
|
revealTitle: 'Reveal protected password?',
|
||||||
|
revealBody:
|
||||||
|
'This action is server-authorized, rate-limited, and written to the admin audit log.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirmReveal: 'Reveal password',
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
eyebrow: 'Remote management',
|
||||||
|
title: 'App access',
|
||||||
|
description:
|
||||||
|
'Stage app access for this device. Nothing changes until you save.',
|
||||||
|
installed: 'Installed',
|
||||||
|
available: 'Available',
|
||||||
|
protected: 'System app',
|
||||||
|
changes: '{count} pending changes',
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
protected: 'Protected activity',
|
||||||
|
messagesTitle: 'Messages',
|
||||||
|
messagesBody: 'Recent SMS activity for the selected SIM.',
|
||||||
|
callsTitle: 'Calls',
|
||||||
|
callsBody: 'Recent call activity for the selected SIM.',
|
||||||
|
loading: 'Loading activity...',
|
||||||
|
incoming: 'Incoming',
|
||||||
|
outgoing: 'Outgoing',
|
||||||
|
mediaMessage: '{type} message',
|
||||||
|
noMessages: 'No message activity found.',
|
||||||
|
noCalls: 'No call activity found.',
|
||||||
|
status: {
|
||||||
|
completed: 'Completed',
|
||||||
|
missed: 'Missed',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
busy: 'Busy',
|
||||||
|
unanswered: 'Unanswered',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
failed: 'Failed',
|
||||||
|
ringing: 'Ringing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderation: {
|
||||||
|
eyebrow: 'Device administration',
|
||||||
|
title: 'Moderation actions',
|
||||||
|
body: 'Every action is server-authorized, rate-limited, and audited.',
|
||||||
|
resetPasscode: 'Reset passcode',
|
||||||
|
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
|
||||||
|
changeNumber: 'Change number',
|
||||||
|
changeNumberBody: 'Assign a new unique number to the current SIM.',
|
||||||
|
factoryReset: 'Factory reset',
|
||||||
|
factoryResetBody: 'Clear local device data and disconnect the account.',
|
||||||
|
saveFirst: 'Save or discard pending app changes first.',
|
||||||
|
phoneNumber: 'New phone number',
|
||||||
|
phoneNumberPlaceholder: 'Enter the full configured number',
|
||||||
|
typeToConfirm: 'Type {word} to confirm the factory reset.',
|
||||||
|
confirmWord: 'RESET',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
'reset-passcodeSuccess': 'Passcode reset.',
|
||||||
|
'change-numberSuccess': 'Phone number changed.',
|
||||||
|
'factory-resetSuccess': 'Phone factory reset completed.',
|
||||||
|
dialogs: {
|
||||||
|
'reset-passcodeTitle': 'Reset device passcode?',
|
||||||
|
'reset-passcodeBody':
|
||||||
|
'The player can unlock this phone without the previous PIN afterward.',
|
||||||
|
'change-numberTitle': 'Change phone number?',
|
||||||
|
'change-numberBody':
|
||||||
|
'The new number must match the configured server number format and be unique.',
|
||||||
|
'factory-resetTitle': 'Factory reset this phone?',
|
||||||
|
'factory-resetBody':
|
||||||
|
'This clears local device data, app settings, security, and the linked account. This cannot be undone.',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
'reset-passcode': 'Reset passcode',
|
||||||
|
'change-number': 'Change number',
|
||||||
|
'factory-reset': 'Factory reset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
brand: 'SKY PHONE',
|
||||||
|
workspace: 'ADMIN',
|
||||||
|
players: 'Player directory',
|
||||||
|
audit: 'Audit log',
|
||||||
|
selectPlayer:
|
||||||
|
'Select a player to inspect identity, devices, credentials, and app access.',
|
||||||
|
save: 'Save changes',
|
||||||
|
saveHint: 'Apply pending changes',
|
||||||
|
saved: 'Changes saved.',
|
||||||
|
unsaved: 'Unsaved changes',
|
||||||
|
close: 'Close admin panel',
|
||||||
|
refresh: 'Refresh live data',
|
||||||
|
online: 'LIVE',
|
||||||
|
profile: 'PROFILE',
|
||||||
|
financial: 'FINANCIAL',
|
||||||
|
device: 'DEVICE',
|
||||||
|
security: 'SECURITY',
|
||||||
|
noAutoSave: 'Manual save',
|
||||||
|
noAutoSaveBody: 'Changes stay local until the green check is pressed.',
|
||||||
|
discardTitle: 'Discard unsaved changes?',
|
||||||
|
discardBody:
|
||||||
|
'Your staged app or configuration changes have not been saved.',
|
||||||
|
keepEditing: 'Keep editing',
|
||||||
|
discard: 'Discard changes',
|
||||||
|
saveFailed: 'Some changes could not be saved.',
|
||||||
|
noSelection: 'No player selected',
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
eyebrow: 'Accountability',
|
||||||
|
title: 'Audit trail',
|
||||||
|
body: 'Sensitive reveals and remote app changes are recorded here.',
|
||||||
|
empty: 'No admin actions yet',
|
||||||
|
emptyBody: 'Protected actions will appear here after they are performed.',
|
||||||
|
by: '{actor} · target ID {target}',
|
||||||
|
actions: {
|
||||||
|
grant_app: 'App installed',
|
||||||
|
revoke_app: 'App removed',
|
||||||
|
reveal_account_password: 'Password revealed',
|
||||||
|
view_messages: 'Messages viewed',
|
||||||
|
view_calls: 'Calls viewed',
|
||||||
|
reset_passcode: 'Passcode reset',
|
||||||
|
change_number: 'Phone number changed',
|
||||||
|
factory_reset: 'Phone factory reset',
|
||||||
|
save_configuration: 'Configuration saved',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
not_authorized: 'You do not have access to the admin panel.',
|
||||||
|
rate_limited: 'Too many admin requests. Please wait.',
|
||||||
|
player_unavailable: 'That player is no longer online.',
|
||||||
|
device_not_owned: 'That phone no longer belongs to the selected player.',
|
||||||
|
invalid_app: 'That app is not registered on the server.',
|
||||||
|
app_protected: 'This system app cannot be removed.',
|
||||||
|
revision_conflict:
|
||||||
|
'The data changed in the meantime. Reopen the section and try again.',
|
||||||
|
configurator_disabled: 'Enable the phone configurator in config.lua first.',
|
||||||
|
invalid_field: 'That configuration field is no longer available.',
|
||||||
|
invalid_value: 'A configuration value is invalid.',
|
||||||
|
account_not_found: 'No iFruit account is linked to this phone.',
|
||||||
|
invalid_phone_number:
|
||||||
|
'Enter a phone number in the configured server format.',
|
||||||
|
phone_number_unchanged: 'This SIM already uses that phone number.',
|
||||||
|
phone_number_taken: 'That phone number is already assigned.',
|
||||||
|
no_sim: 'This phone has no SIM that can be changed.',
|
||||||
|
passcode_not_set: 'This phone has no passcode configured.',
|
||||||
|
device_not_found: 'This phone no longer exists.',
|
||||||
|
metadata_unsupported: 'The phone inventory metadata could not be updated.',
|
||||||
|
invalid_request: 'The admin request was invalid.',
|
||||||
|
request_failed: 'The admin request failed.',
|
||||||
|
default: 'The admin panel is temporarily unavailable.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const defaultLocales: LocaleTree = {
|
const defaultLocales: LocaleTree = {
|
||||||
|
AdminPanel: adminPanelFallbackLocales,
|
||||||
Apps: {
|
Apps: {
|
||||||
citywarn: citywarnFallbackLocales,
|
citywarn: citywarnFallbackLocales,
|
||||||
crypto: cryptoFallbackLocales,
|
crypto: cryptoFallbackLocales,
|
||||||
@@ -2086,7 +2485,7 @@ const defaultLocales: LocaleTree = {
|
|||||||
to: 'To:',
|
to: 'To:',
|
||||||
connectPrivately: 'Connect privately',
|
connectPrivately: 'Connect privately',
|
||||||
newChatBody:
|
newChatBody:
|
||||||
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
|
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
|
||||||
darkIdOrInvite: 'Dark-ID or invitation code',
|
darkIdOrInvite: 'Dark-ID or invitation code',
|
||||||
continue: 'Continue',
|
continue: 'Continue',
|
||||||
contacts: 'DarkChat Contacts',
|
contacts: 'DarkChat Contacts',
|
||||||
@@ -2432,46 +2831,190 @@ const defaultLocales: LocaleTree = {
|
|||||||
'neon-drop': 'Neon block-dropping puzzle',
|
'neon-drop': 'Neon block-dropping puzzle',
|
||||||
},
|
},
|
||||||
previews: {
|
previews: {
|
||||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
citywarn: {
|
||||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
first: 'Live alerts',
|
||||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
second: 'Safety zones',
|
||||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
third: 'Incident updates',
|
||||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
},
|
||||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
crypto: {
|
||||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
first: 'Synthetic markets',
|
||||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
second: 'Portfolio',
|
||||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
third: 'Wallet transfers',
|
||||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
},
|
||||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
health: {
|
||||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
first: 'Activity rings',
|
||||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
second: 'Medical ID',
|
||||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
third: 'Health records',
|
||||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
},
|
||||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
'weazel-news': {
|
||||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
first: 'Top stories',
|
||||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
second: 'Local reports',
|
||||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
third: 'Breaking news',
|
||||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
},
|
||||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
companies: {
|
||||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
first: 'Business directory',
|
||||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
second: 'Job requests',
|
||||||
|
third: 'Services',
|
||||||
|
},
|
||||||
|
music: {
|
||||||
|
first: 'Now playing',
|
||||||
|
second: 'Playlists',
|
||||||
|
third: 'Music library',
|
||||||
|
},
|
||||||
|
picstagram: {
|
||||||
|
first: 'Photo feed',
|
||||||
|
second: 'Stories',
|
||||||
|
third: 'Profiles',
|
||||||
|
},
|
||||||
|
feather: {
|
||||||
|
first: 'Short posts',
|
||||||
|
second: 'Following feed',
|
||||||
|
third: 'Conversations',
|
||||||
|
},
|
||||||
|
fliptok: {
|
||||||
|
first: 'Video feed',
|
||||||
|
second: 'Creator tools',
|
||||||
|
third: 'Trends',
|
||||||
|
},
|
||||||
|
flare: {
|
||||||
|
first: 'Discover people',
|
||||||
|
second: 'Matches',
|
||||||
|
third: 'Live moments',
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
first: 'Upcoming events',
|
||||||
|
second: 'Day planner',
|
||||||
|
third: 'Reminders',
|
||||||
|
},
|
||||||
|
radio: {
|
||||||
|
first: 'Live channels',
|
||||||
|
second: 'Team radio',
|
||||||
|
third: 'Favorites',
|
||||||
|
},
|
||||||
|
'local-pages': {
|
||||||
|
first: 'Local pages',
|
||||||
|
second: 'Reviews',
|
||||||
|
third: 'City discovery',
|
||||||
|
},
|
||||||
|
crewlink: {
|
||||||
|
first: 'Crew roster',
|
||||||
|
second: 'Shared locations',
|
||||||
|
third: 'Coordination',
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
first: 'Recent calls',
|
||||||
|
second: 'Contacts',
|
||||||
|
third: 'Voicemail',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
first: 'Conversations',
|
||||||
|
second: 'Media sharing',
|
||||||
|
third: 'Quick replies',
|
||||||
|
},
|
||||||
|
darkchat: {
|
||||||
|
first: 'Private chats',
|
||||||
|
second: 'Secure groups',
|
||||||
|
third: 'Invitations',
|
||||||
|
},
|
||||||
|
garage: {
|
||||||
|
first: 'Vehicle list',
|
||||||
|
second: 'Parking locations',
|
||||||
|
third: 'Valet',
|
||||||
|
},
|
||||||
|
house: {
|
||||||
|
first: 'Property access',
|
||||||
|
second: 'Residents',
|
||||||
|
third: 'Management',
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
first: 'Live navigation',
|
||||||
|
second: 'Nearby places',
|
||||||
|
third: 'Route guidance',
|
||||||
|
},
|
||||||
|
skyride: {
|
||||||
|
first: 'Ride booking',
|
||||||
|
second: 'Driver tracking',
|
||||||
|
third: 'Trip history',
|
||||||
|
},
|
||||||
|
banking: {
|
||||||
|
first: 'Account balance',
|
||||||
|
second: 'Transfers',
|
||||||
|
third: 'Transactions',
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
first: 'Open invoices',
|
||||||
|
second: 'Payment requests',
|
||||||
|
third: 'Payment history',
|
||||||
|
},
|
||||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
memos: {
|
||||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
first: 'Voice recordings',
|
||||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
second: 'Playback',
|
||||||
|
third: 'Favorites',
|
||||||
|
},
|
||||||
|
calculator: {
|
||||||
|
first: 'Basic calculation',
|
||||||
|
second: 'Scientific tools',
|
||||||
|
third: 'History',
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
first: 'Photo mode',
|
||||||
|
second: 'Video capture',
|
||||||
|
third: 'Zoom controls',
|
||||||
|
},
|
||||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
weather: {
|
||||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
first: 'Current weather',
|
||||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
second: 'Hourly forecast',
|
||||||
|
third: 'Seven-day outlook',
|
||||||
|
},
|
||||||
|
photos: {
|
||||||
|
first: 'Media library',
|
||||||
|
second: 'Albums',
|
||||||
|
third: 'Shared media',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
first: 'Device controls',
|
||||||
|
second: 'Privacy',
|
||||||
|
third: 'Personalization',
|
||||||
|
},
|
||||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
memory: {
|
||||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
first: 'Matched pairs',
|
||||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
second: 'Best time',
|
||||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
third: 'Card themes',
|
||||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
},
|
||||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
'number-merge': {
|
||||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
first: 'Highest tile',
|
||||||
|
second: 'Score',
|
||||||
|
third: 'Strategy grid',
|
||||||
|
},
|
||||||
|
minesweeper: {
|
||||||
|
first: 'Mine counter',
|
||||||
|
second: 'Best time',
|
||||||
|
third: 'Difficulty',
|
||||||
|
},
|
||||||
|
'tower-stack': {
|
||||||
|
first: 'Tower height',
|
||||||
|
second: 'Perfect drops',
|
||||||
|
third: 'High score',
|
||||||
|
},
|
||||||
|
'sky-flappy': {
|
||||||
|
first: 'Flight score',
|
||||||
|
second: 'Best run',
|
||||||
|
third: 'Obstacles',
|
||||||
|
},
|
||||||
|
citymarkt: {
|
||||||
|
first: 'Listings',
|
||||||
|
second: 'Categories',
|
||||||
|
third: 'Saved offers',
|
||||||
|
},
|
||||||
|
'neon-drop': {
|
||||||
|
first: 'Lines cleared',
|
||||||
|
second: 'Level',
|
||||||
|
third: 'Neon pieces',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
recommended: 'Recommended',
|
recommended: 'Recommended',
|
||||||
@@ -3036,6 +3579,8 @@ const defaultLocales: LocaleTree = {
|
|||||||
viewRides: 'View Ride Options',
|
viewRides: 'View Ride Options',
|
||||||
change: 'Change',
|
change: 'Change',
|
||||||
requestRide: 'Request SkyRide',
|
requestRide: 'Request SkyRide',
|
||||||
|
playerDriverNotice:
|
||||||
|
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
|
||||||
serviceMeta: '{eta} min away · {seats} seats',
|
serviceMeta: '{eta} min away · {seats} seats',
|
||||||
distanceMeters: '{distance} m',
|
distanceMeters: '{distance} m',
|
||||||
distanceKilometers: '{distance} km',
|
distanceKilometers: '{distance} km',
|
||||||
@@ -3106,7 +3651,8 @@ const defaultLocales: LocaleTree = {
|
|||||||
cancelled: 'Cancelled',
|
cancelled: 'Cancelled',
|
||||||
},
|
},
|
||||||
statusBody: {
|
statusBody: {
|
||||||
searching: 'We are matching you with a nearby driver.',
|
searching:
|
||||||
|
'Your request is waiting for an available player driver to accept it.',
|
||||||
accepted: 'Your driver is preparing to pick you up.',
|
accepted: 'Your driver is preparing to pick you up.',
|
||||||
driver_arriving: 'Your driver is on the way to your pickup.',
|
driver_arriving: 'Your driver is on the way to your pickup.',
|
||||||
arrived: 'Your driver is waiting at the pickup point.',
|
arrived: 'Your driver is waiting at the pickup point.',
|
||||||
@@ -4012,6 +4558,11 @@ const defaultLocales: LocaleTree = {
|
|||||||
invalid_media_type: 'The uploaded media type is invalid.',
|
invalid_media_type: 'The uploaded media type is invalid.',
|
||||||
invalid_upload: 'The upload could not be verified.',
|
invalid_upload: 'The upload could not be verified.',
|
||||||
invalid_upload_token: 'The upload session is no longer valid.',
|
invalid_upload_token: 'The upload session is no longer valid.',
|
||||||
|
media_provider_failed: 'The camera upload service is unavailable.',
|
||||||
|
media_provider_rate_limited:
|
||||||
|
'The camera upload service is busy. Try again shortly.',
|
||||||
|
media_provider_unauthorized:
|
||||||
|
'The configured FiveManage API key was rejected.',
|
||||||
missing_config: 'Camera uploads are not configured.',
|
missing_config: 'Camera uploads are not configured.',
|
||||||
microphone_unavailable:
|
microphone_unavailable:
|
||||||
'Allow microphone access or mute the microphone before recording.',
|
'Allow microphone access or mute the microphone before recording.',
|
||||||
@@ -5183,6 +5734,15 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
this.cameraLandscape = false
|
this.cameraLandscape = false
|
||||||
this.isOpen = false
|
this.isOpen = false
|
||||||
},
|
},
|
||||||
|
setLocale(
|
||||||
|
lang: string,
|
||||||
|
locales: LocaleTree,
|
||||||
|
fallbackLocales: LocaleTree,
|
||||||
|
): void {
|
||||||
|
this.lang = lang
|
||||||
|
this.locales = locales
|
||||||
|
this.fallbackLocales = fallbackLocales
|
||||||
|
},
|
||||||
open(payload: PhoneOpenPayload = {}): void {
|
open(payload: PhoneOpenPayload = {}): void {
|
||||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||||
const nextToken = payload.token ?? this.deviceSessionToken
|
const nextToken = payload.token ?? this.deviceSessionToken
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ describe('test data seeding contracts', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns before registering the test-data command when disabled', () => {
|
it('refreshes the test-data command when its runtime configuration changes', () => {
|
||||||
expect(
|
expect(testData).toContain('local function refresh_test_data_command()')
|
||||||
testData.indexOf('if not Config.TestData.Enabled then'),
|
expect(testData).toContain(
|
||||||
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
|
'active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil',
|
||||||
|
)
|
||||||
|
expect(testData).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)',
|
||||||
|
)
|
||||||
|
expect(testData).not.toContain('RegisterCommand(Config.TestData.Command')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
export type AdminStats = {
|
||||||
|
accounts: number
|
||||||
|
devices: number
|
||||||
|
online: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPlayerSummary = {
|
||||||
|
deviceCount: number
|
||||||
|
grade: number
|
||||||
|
identifier: string
|
||||||
|
job: string
|
||||||
|
name: string
|
||||||
|
onDuty: boolean
|
||||||
|
phoneNumber: string | null
|
||||||
|
serverName: string
|
||||||
|
source: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminDevice = {
|
||||||
|
account: {
|
||||||
|
email: string
|
||||||
|
id: number
|
||||||
|
passwordAvailable: boolean
|
||||||
|
} | null
|
||||||
|
apps: {
|
||||||
|
claimed: string[]
|
||||||
|
revision: number
|
||||||
|
uninstalled: string[]
|
||||||
|
}
|
||||||
|
createdAt: string
|
||||||
|
imei: string
|
||||||
|
name: string
|
||||||
|
number: string | null
|
||||||
|
security: {
|
||||||
|
enabled: boolean
|
||||||
|
failedAttempts: number
|
||||||
|
length: number | null
|
||||||
|
lockedUntil: number
|
||||||
|
}
|
||||||
|
simRegistered: boolean
|
||||||
|
simType: string | null
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPlayerDetail = {
|
||||||
|
birthdate: string
|
||||||
|
devices: AdminDevice[]
|
||||||
|
firstName: string
|
||||||
|
identifier: string
|
||||||
|
job: {
|
||||||
|
grade: number
|
||||||
|
gradeLabel: string
|
||||||
|
label: string
|
||||||
|
name: string
|
||||||
|
onDuty: boolean
|
||||||
|
}
|
||||||
|
lastName: string
|
||||||
|
money: {
|
||||||
|
bank: number
|
||||||
|
cash: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
name: string
|
||||||
|
serverName: string
|
||||||
|
source: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminAuditEntry = {
|
||||||
|
action: string
|
||||||
|
actorName: string
|
||||||
|
createdAt: string
|
||||||
|
details: Record<string, unknown>
|
||||||
|
deviceImei: string | null
|
||||||
|
id: number
|
||||||
|
targetIdentifier: string
|
||||||
|
targetSource: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminBootstrap = {
|
||||||
|
audit: AdminAuditEntry[]
|
||||||
|
players: AdminPlayerSummary[]
|
||||||
|
stats: AdminStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminCredential = {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminMessageActivity = {
|
||||||
|
body: string
|
||||||
|
createdAt: string
|
||||||
|
direction: 'incoming' | 'outgoing'
|
||||||
|
id: string
|
||||||
|
messageType: string
|
||||||
|
otherNumber: string
|
||||||
|
readAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminCallActivity = {
|
||||||
|
answeredAt: string | null
|
||||||
|
direction: 'incoming' | 'outgoing'
|
||||||
|
durationSeconds: number
|
||||||
|
endedAt: string | null
|
||||||
|
id: string
|
||||||
|
otherNumber: string
|
||||||
|
startedAt: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminActivityResponse =
|
||||||
|
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
||||||
|
| { entries: AdminCallActivity[]; kind: 'calls' }
|
||||||
|
|
||||||
|
export type AdminConfiguratorField = {
|
||||||
|
configured?: boolean
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
structure?: AdminConfiguratorStructure
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
sensitive: boolean
|
||||||
|
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfiguratorStructure =
|
||||||
|
| {
|
||||||
|
kind: 'list'
|
||||||
|
items: AdminConfiguratorStructure[]
|
||||||
|
template?: AdminConfiguratorStructure
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
fields: Record<string, AdminConfiguratorStructure>
|
||||||
|
kind: 'table'
|
||||||
|
mutableKeys?: boolean
|
||||||
|
template?: AdminConfiguratorStructure
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
entries: Array<{
|
||||||
|
key: number | string
|
||||||
|
keyType: 'number' | 'string'
|
||||||
|
structure: AdminConfiguratorStructure
|
||||||
|
}>
|
||||||
|
keyType?: 'number' | 'string'
|
||||||
|
kind: 'map'
|
||||||
|
template?: AdminConfiguratorStructure
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'value'
|
||||||
|
valueType: 'boolean' | 'number' | 'string'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'optionalString'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'vector'
|
||||||
|
vectorType: 'vector2' | 'vector3' | 'vector4'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfiguratorSection = {
|
||||||
|
fields: AdminConfiguratorField[]
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfigurator = {
|
||||||
|
enabled: boolean
|
||||||
|
revision: number
|
||||||
|
sections: AdminConfiguratorSection[]
|
||||||
|
updatedAt: string | null
|
||||||
|
updatedBy: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfiguratorChange = {
|
||||||
|
path: string
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export type AppLaunchOrigin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PhoneAppDefinitionBase = {
|
type PhoneAppDefinitionBase = {
|
||||||
|
adminOnly?: boolean
|
||||||
category: PhoneAppCategory
|
category: PhoneAppCategory
|
||||||
dockOrder: number | null
|
dockOrder: number | null
|
||||||
gridOrder: number
|
gridOrder: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Note } from '@/utils/notes'
|
import type { Note } from '@/utils/notes'
|
||||||
import type { MemoDto } from '@/types/memos'
|
import type { MemoDto } from '@/types/memos'
|
||||||
import type { PhoneSim } from '@/types/phone'
|
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
|
||||||
|
|
||||||
export type DeviceDataEntry<T = unknown> = {
|
export type DeviceDataEntry<T = unknown> = {
|
||||||
payload: T
|
payload: T
|
||||||
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
|
|||||||
device: PhoneDevice
|
device: PhoneDevice
|
||||||
memos: MemoDto[]
|
memos: MemoDto[]
|
||||||
notes: Note[]
|
notes: Note[]
|
||||||
|
phoneNumberFormat: PhoneNumberFormat
|
||||||
player: PhonePlayerIdentity
|
player: PhonePlayerIdentity
|
||||||
security: DeviceSecurity
|
security: DeviceSecurity
|
||||||
token: string
|
token: string
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type DynamicIslandActivity =
|
||||||
|
| 'call'
|
||||||
|
| 'incoming-call'
|
||||||
|
| 'music'
|
||||||
|
| 'recording'
|
||||||
|
| 'stopwatch'
|
||||||
|
| 'timer'
|
||||||
@@ -70,7 +70,6 @@ export type MediaImportResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UploadReady = {
|
export type UploadReady = {
|
||||||
captureToken: string
|
|
||||||
correlationId: string
|
correlationId: string
|
||||||
mediaType: MediaType
|
mediaType: MediaType
|
||||||
photo?: {
|
photo?: {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
|
|||||||
export type MemoUploadReady = {
|
export type MemoUploadReady = {
|
||||||
requestId: string
|
requestId: string
|
||||||
correlationId: string
|
correlationId: string
|
||||||
captureToken: string
|
|
||||||
presignedUrl: string
|
presignedUrl: string
|
||||||
uploadTimeoutMs?: number
|
uploadTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export type SimType = 'registered' | 'anonymous'
|
export type SimType = 'registered' | 'anonymous'
|
||||||
|
|
||||||
|
export type PhoneNumberFormat = {
|
||||||
|
groups: number[]
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
export type PhoneSim = {
|
export type PhoneSim = {
|
||||||
id: string
|
id: string
|
||||||
number: string
|
number: string
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||||
|
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
||||||
|
const mainCssSource = readFileSync(
|
||||||
|
new URL('../assets/main.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
function styleSources(directory: string): Array<{
|
||||||
|
file: string
|
||||||
|
source: string
|
||||||
|
}> {
|
||||||
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(directory, entry.name)
|
||||||
|
if (entry.isDirectory()) return styleSources(path)
|
||||||
|
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
||||||
|
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inter font contract', () => {
|
||||||
|
it('bundles normal and italic variable fonts for every supported weight', () => {
|
||||||
|
expect(
|
||||||
|
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
existsSync(
|
||||||
|
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
||||||
|
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
||||||
|
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the shared font to the document and native form controls', () => {
|
||||||
|
expect(mainCssSource).toMatch(
|
||||||
|
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
||||||
|
)
|
||||||
|
expect(mainCssSource).toMatch(
|
||||||
|
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not bypass the shared token with a generic system UI stack', () => {
|
||||||
|
const genericSystemStack =
|
||||||
|
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
||||||
|
const violations = styleSources(sourceDirectory)
|
||||||
|
.filter(({ source }) => genericSystemStack.test(source))
|
||||||
|
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
||||||
|
|
||||||
|
expect(violations).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -111,6 +111,18 @@
|
|||||||
color: var(--sky-text, #111827);
|
color: var(--sky-text, #111827);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sky-glass.sky-button--glass {
|
||||||
|
border-color: var(--sky-hairline, rgba(0, 0, 0, 0.2));
|
||||||
|
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||||
|
color: var(--sky-text, #000000);
|
||||||
|
box-shadow: var(--sky-shadow-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sky-glass.sky-button--glass:active:not(:disabled) {
|
||||||
|
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||||
|
filter: brightness(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
.sky-button--danger {
|
.sky-button--danger {
|
||||||
background: var(--sky-danger, #dc2626);
|
background: var(--sky-danger, #dc2626);
|
||||||
}
|
}
|
||||||
@@ -2733,6 +2745,9 @@ label.sky-list-item__row {
|
|||||||
|
|
||||||
.sky-fab--icon-only {
|
.sky-fab--icon-only {
|
||||||
width: var(--sky-touch-target, 44px);
|
width: var(--sky-touch-target, 44px);
|
||||||
|
height: var(--sky-touch-target, 44px);
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2741,6 +2756,16 @@ label.sky-list-item__row {
|
|||||||
color: var(--sky-text, #000000);
|
color: var(--sky-text, #000000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sky-glass.sky-fab--glass {
|
||||||
|
border: 1px solid var(--sky-hairline, rgba(0, 0, 0, 0.2));
|
||||||
|
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||||
|
color: var(--sky-text, #000000);
|
||||||
|
box-shadow: var(--sky-shadow-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sky-fab--glass .sky-fab__accent-layer,
|
||||||
|
.sky-fab--glass .sky-fab__dark-accent-layer,
|
||||||
|
.sky-fab--glass .sky-fab__surface-layer,
|
||||||
.sky-fab--neutral .sky-fab__accent-layer,
|
.sky-fab--neutral .sky-fab__accent-layer,
|
||||||
.sky-fab--neutral .sky-fab__dark-accent-layer,
|
.sky-fab--neutral .sky-fab__dark-accent-layer,
|
||||||
.sky-fab--neutral .sky-fab__surface-layer {
|
.sky-fab--neutral .sky-fab__surface-layer {
|
||||||
@@ -2755,6 +2780,10 @@ label.sky-list-item__row {
|
|||||||
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
|
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sky-glass.sky-fab--glass:active:not(:disabled) {
|
||||||
|
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||||
|
}
|
||||||
|
|
||||||
.sky-fab--disabled {
|
.sky-fab--disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
@@ -2825,6 +2854,8 @@ label.sky-list-item__row {
|
|||||||
.sky-glass--interactive {
|
.sky-glass--interactive {
|
||||||
min-width: var(--sky-touch-target, 44px);
|
min-width: var(--sky-touch-target, 44px);
|
||||||
min-height: var(--sky-touch-target, 44px);
|
min-height: var(--sky-touch-target, 44px);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||||
|
backdrop-filter: blur(18px) saturate(145%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ describe('SkyButton', () => {
|
|||||||
expect(html).toContain('Continue')
|
expect(html).toContain('Continue')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders interactive liquid glass buttons through the shared glass surface', async () => {
|
||||||
|
const html = await renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () =>
|
||||||
|
h(SkyButton, { glass: true, rounded: true }, () => 'Edit'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('sky-button--glass')
|
||||||
|
expect(html).toContain('sky-glass')
|
||||||
|
expect(html).toContain('sky-glass--interactive')
|
||||||
|
|
||||||
|
const controls = readFileSync(
|
||||||
|
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
expect(controls).toMatch(
|
||||||
|
/\.sky-glass\.sky-button--glass\s*\{[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||||
|
)
|
||||||
|
expect(controls).toMatch(
|
||||||
|
/\.sky-glass--interactive\s*\{[^}]*-webkit-backdrop-filter:\s*blur\(18px\) saturate\(145%\);[^}]*backdrop-filter:\s*blur\(18px\) saturate\(145%\);/s,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps focus and pressed feedback on the contextual accent', () => {
|
it('keeps focus and pressed feedback on the contextual accent', () => {
|
||||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import SkyGlass from './SkyGlass.vue'
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false })
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -9,6 +11,7 @@ const props = withDefaults(
|
|||||||
clear?: boolean
|
clear?: boolean
|
||||||
component?: 'a' | 'button'
|
component?: 'a' | 'button'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
glass?: boolean
|
||||||
href?: string
|
href?: string
|
||||||
iconOnly?: boolean
|
iconOnly?: boolean
|
||||||
inline?: boolean
|
inline?: boolean
|
||||||
@@ -26,6 +29,7 @@ const props = withDefaults(
|
|||||||
clear: false,
|
clear: false,
|
||||||
component: 'button',
|
component: 'button',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
glass: false,
|
||||||
href: undefined,
|
href: undefined,
|
||||||
iconOnly: false,
|
iconOnly: false,
|
||||||
inline: false,
|
inline: false,
|
||||||
@@ -44,6 +48,23 @@ const emit = defineEmits<{
|
|||||||
click: [event: MouseEvent]
|
click: [event: MouseEvent]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const buttonClasses = computed(() => [
|
||||||
|
`sky-button--${props.variant}`,
|
||||||
|
{
|
||||||
|
'sky-button--block': props.block,
|
||||||
|
'sky-button--clear': props.clear,
|
||||||
|
'sky-button--glass': props.glass,
|
||||||
|
'sky-button--icon-only': props.iconOnly,
|
||||||
|
'sky-button--inline': props.inline,
|
||||||
|
'sky-button--large': props.large,
|
||||||
|
'sky-button--outline': props.outline,
|
||||||
|
'sky-button--raised': props.raised,
|
||||||
|
'sky-button--rounded': props.rounded,
|
||||||
|
'sky-button--small': props.small && !props.large,
|
||||||
|
'sky-button--tonal': props.tonal,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const elementProps = computed<Record<string, unknown>>(() => {
|
const elementProps = computed<Record<string, unknown>>(() => {
|
||||||
if (props.component === 'a') {
|
if (props.component === 'a') {
|
||||||
return {
|
return {
|
||||||
@@ -71,25 +92,25 @@ function handleClick(event: MouseEvent): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<SkyGlass
|
||||||
|
v-if="glass"
|
||||||
|
:component="component"
|
||||||
|
v-bind="{ ...$attrs, ...elementProps }"
|
||||||
|
class="sky-button"
|
||||||
|
:class="buttonClasses"
|
||||||
|
:disabled="disabled"
|
||||||
|
:href="href"
|
||||||
|
:type="type"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SkyGlass>
|
||||||
<component
|
<component
|
||||||
|
v-else
|
||||||
:is="component"
|
:is="component"
|
||||||
v-bind="{ ...$attrs, ...elementProps }"
|
v-bind="{ ...$attrs, ...elementProps }"
|
||||||
class="sky-button"
|
class="sky-button"
|
||||||
:class="[
|
:class="buttonClasses"
|
||||||
`sky-button--${variant}`,
|
|
||||||
{
|
|
||||||
'sky-button--block': block,
|
|
||||||
'sky-button--clear': clear,
|
|
||||||
'sky-button--icon-only': iconOnly,
|
|
||||||
'sky-button--inline': inline,
|
|
||||||
'sky-button--large': large,
|
|
||||||
'sky-button--outline': outline,
|
|
||||||
'sky-button--raised': raised,
|
|
||||||
'sky-button--rounded': rounded,
|
|
||||||
'sky-button--small': small && !large,
|
|
||||||
'sky-button--tonal': tonal,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -73,4 +73,33 @@ describe('SkyFab', () => {
|
|||||||
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
|
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers a translucent glass variant for adjacent floating controls', async () => {
|
||||||
|
const html = await renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () => h(SkyFab, { ariaLabel: 'Create', variant: 'glass' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('sky-fab--glass')
|
||||||
|
|
||||||
|
const controls = readFileSync(
|
||||||
|
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
expect(controls).toMatch(
|
||||||
|
/\.sky-glass\.sky-fab--glass\s*\{[^}]*border:\s*1px solid var\(--sky-hairline[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps icon-only fabs perfectly square inside stretching toolbars', () => {
|
||||||
|
const controls = readFileSync(
|
||||||
|
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(controls).toMatch(
|
||||||
|
/\.sky-fab--icon-only\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*height:\s*var\(--sky-touch-target, 44px\);[^}]*flex:\s*none;[^}]*align-self:\s*center;/s,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const props = withDefaults(
|
|||||||
text?: string
|
text?: string
|
||||||
textPosition?: 'after' | 'before'
|
textPosition?: 'after' | 'before'
|
||||||
type?: 'button' | 'reset' | 'submit'
|
type?: 'button' | 'reset' | 'submit'
|
||||||
variant?: 'neutral' | 'primary'
|
variant?: 'glass' | 'neutral' | 'primary'
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
ariaLabel: '',
|
ariaLabel: '',
|
||||||
@@ -72,6 +72,7 @@ function handleClick(event: MouseEvent): void {
|
|||||||
class="sky-fab"
|
class="sky-fab"
|
||||||
:class="{
|
:class="{
|
||||||
'sky-fab--disabled': disabled,
|
'sky-fab--disabled': disabled,
|
||||||
|
'sky-fab--glass': variant === 'glass',
|
||||||
'sky-fab--icon-only': !hasText,
|
'sky-fab--icon-only': !hasText,
|
||||||
'sky-fab--neutral': variant === 'neutral',
|
'sky-fab--neutral': variant === 'neutral',
|
||||||
'sky-fab--with-text': hasText,
|
'sky-fab--with-text': hasText,
|
||||||
|
|||||||
Vendored
+2
-3
@@ -525,9 +525,8 @@
|
|||||||
height: var(--sky-widget-label-height);
|
height: var(--sky-widget-label-height);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--sky-widget-label-color, #fff);
|
color: var(--sky-widget-label-color, #fff);
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
font-size: var(--sky-home-label-font-size);
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
line-height: var(--sky-widget-label-height);
|
line-height: var(--sky-widget-label-height);
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--sky-device-pixel-ratio: 1;
|
--sky-device-pixel-ratio: 1;
|
||||||
--sky-hairline-scale: 1;
|
--sky-hairline-scale: 1;
|
||||||
@@ -26,9 +42,7 @@
|
|||||||
--sky-font-title: 17px;
|
--sky-font-title: 17px;
|
||||||
--sky-font-medium-title: 24px;
|
--sky-font-medium-title: 24px;
|
||||||
--sky-font-large-title: 34px;
|
--sky-font-large-title: 34px;
|
||||||
--sky-font-family:
|
--sky-font-family: 'Inter', Arial, sans-serif;
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
|
||||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
--sky-transition-fast: 100ms;
|
--sky-transition-fast: 100ms;
|
||||||
--sky-transition-normal: 200ms;
|
--sky-transition-normal: 200ms;
|
||||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -55,8 +69,10 @@
|
|||||||
--sky-shadow-thumb:
|
--sky-shadow-thumb:
|
||||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||||
|
--sky-home-label-font-size: 13px;
|
||||||
|
--sky-home-label-height: 16px;
|
||||||
--sky-widget-label-gap: 5px;
|
--sky-widget-label-gap: 5px;
|
||||||
--sky-widget-label-height: 15px;
|
--sky-widget-label-height: var(--sky-home-label-height);
|
||||||
--sky-widget-radius-small: 23px;
|
--sky-widget-radius-small: 23px;
|
||||||
--sky-widget-radius-medium: 25px;
|
--sky-widget-radius-medium: 25px;
|
||||||
--sky-widget-radius-large: 28px;
|
--sky-widget-radius-large: 28px;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||||
|
|
||||||
|
import {
|
||||||
|
configuratorDescriptionKey,
|
||||||
|
configuratorPathName,
|
||||||
|
describeConfiguratorValue,
|
||||||
|
} from './adminConfiguratorDescription'
|
||||||
|
|
||||||
|
describe('admin configurator descriptions', () => {
|
||||||
|
it('selects specific descriptions before generic value descriptions', () => {
|
||||||
|
expect(configuratorDescriptionKey('Bridge.CallbackTimeout', 15000)).toBe(
|
||||||
|
'milliseconds',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||||
|
'milliseconds',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
|
||||||
|
'access',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||||
|
'credential',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes structured values from their schema', () => {
|
||||||
|
const vector: AdminConfiguratorStructure = {
|
||||||
|
kind: 'vector',
|
||||||
|
vectorType: 'vector3',
|
||||||
|
}
|
||||||
|
const table: AdminConfiguratorStructure = {
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
}
|
||||||
|
expect(configuratorDescriptionKey('Location', {}, vector)).toBe(
|
||||||
|
'coordinates',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('Settings', {}, table)).toBe('table')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes a readable field name to the localized template', () => {
|
||||||
|
const translate = (key: string, params?: Record<string, string>) =>
|
||||||
|
`${key}:${params?.name}`
|
||||||
|
expect(
|
||||||
|
describeConfiguratorValue(
|
||||||
|
translate,
|
||||||
|
'CustomApps.MaximumStorageBytesPerApp',
|
||||||
|
262144,
|
||||||
|
),
|
||||||
|
).toBe('configurator.descriptions.byteLimit:Maximum Storage Bytes Per App')
|
||||||
|
expect(
|
||||||
|
describeConfiguratorValue(translate, 'Radio.AllowedJobs[2]', 'police'),
|
||||||
|
).toBe('configurator.descriptions.access:Allowed Jobs #2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('humanizes subtab keys', () => {
|
||||||
|
expect(configuratorPathName('ExternalPingResources')).toBe(
|
||||||
|
'External Ping Resources',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||||
|
|
||||||
|
type ConfiguratorDescriptionTranslator = (
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
) => string
|
||||||
|
|
||||||
|
export type AdminConfiguratorDescribe = (
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
structure?: AdminConfiguratorStructure,
|
||||||
|
label?: string,
|
||||||
|
) => string
|
||||||
|
|
||||||
|
const DESCRIPTION_RULES: Array<[RegExp, string]> = [
|
||||||
|
[/(?:^|\.)(?:apikey|token|password|secret)$/i, 'credential'],
|
||||||
|
[/(?:base|manifest|image|icon)?url$/i, 'url'],
|
||||||
|
[/(?:allowed)?(?:gif|media)?hosts?$/i, 'hosts'],
|
||||||
|
[
|
||||||
|
/(?:timeout|timeoutms|milliseconds|durationms|intervalms|pollms)$/i,
|
||||||
|
'milliseconds',
|
||||||
|
],
|
||||||
|
[/(?:timeoutseconds|seconds)$/i, 'seconds'],
|
||||||
|
[/perminute$/i, 'rateLimit'],
|
||||||
|
[/(?:maximum|max).*bytes/i, 'byteLimit'],
|
||||||
|
[/(?:maximum|max).*length$|length$/i, 'textLimit'],
|
||||||
|
[/(?:distance)$/i, 'distance'],
|
||||||
|
[/(?:location|position|rotation|coords|coordinates)$/i, 'coordinates'],
|
||||||
|
[/(?:model|prop|propmodel|customprop|replacementprop)$/i, 'gameAsset'],
|
||||||
|
[/(?:dictionary|dictionaries|clip|clips|pedclip|propclip)$/i, 'animation'],
|
||||||
|
[
|
||||||
|
/(?:permissions?|admingroups?|allowedjobs?|jobs?|minimumgrade|requiredace)$/i,
|
||||||
|
'access',
|
||||||
|
],
|
||||||
|
[/(?:framework|inventory|provider|voiceprovider|adapter)$/i, 'integration'],
|
||||||
|
[/(?:path)$/i, 'path'],
|
||||||
|
[/(?:color|colour|accent)$/i, 'color'],
|
||||||
|
[
|
||||||
|
/(?:label|name|title|description|address|district|locationlabel|devicename)$/i,
|
||||||
|
'displayText',
|
||||||
|
],
|
||||||
|
[/(?:number|callernumber|numberprefix)$/i, 'phoneNumber'],
|
||||||
|
[/(?:routing)$/i, 'routing'],
|
||||||
|
[/(?:command)$/i, 'command'],
|
||||||
|
[/(?:locale)$/i, 'locale'],
|
||||||
|
[/(?:debug)$/i, 'debug'],
|
||||||
|
[/(?:enabled|active|public|verified)$/i, 'featureToggle'],
|
||||||
|
[/(?:quality|bitratekbps|volume)$/i, 'mediaQuality'],
|
||||||
|
[
|
||||||
|
/(?:pagesize|batchsize|limit|count|maxselection|maximumplayers|samples|decimals)$/i,
|
||||||
|
'amount',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function configuratorPathName(path: string): string {
|
||||||
|
const listEntry = path.match(/^(.*)\[(\d+)\]$/)
|
||||||
|
const source = listEntry?.[1] ?? path
|
||||||
|
const segment = source.split('.').at(-1) ?? source
|
||||||
|
const name = segment
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
return listEntry ? `${name} #${listEntry[2]}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configuratorDescriptionKey(
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
structure?: AdminConfiguratorStructure,
|
||||||
|
): string {
|
||||||
|
const segment =
|
||||||
|
path
|
||||||
|
.replace(/\[\d+\]$/, '')
|
||||||
|
.split('.')
|
||||||
|
.at(-1) ?? path
|
||||||
|
const semanticRule = DESCRIPTION_RULES.find(([pattern]) =>
|
||||||
|
pattern.test(segment),
|
||||||
|
)
|
||||||
|
if (semanticRule) return semanticRule[1]
|
||||||
|
|
||||||
|
if (structure?.kind === 'vector') return 'coordinates'
|
||||||
|
if (structure?.kind === 'list' || Array.isArray(value)) return 'list'
|
||||||
|
if (structure?.kind === 'map' || structure?.kind === 'table') return 'table'
|
||||||
|
if (structure?.kind === 'optionalString') return 'optionalText'
|
||||||
|
if (value !== null && typeof value === 'object') return 'table'
|
||||||
|
if (typeof value === 'boolean') return 'boolean'
|
||||||
|
if (typeof value === 'number') return 'number'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeConfiguratorValue(
|
||||||
|
translate: ConfiguratorDescriptionTranslator,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
structure?: AdminConfiguratorStructure,
|
||||||
|
label?: string,
|
||||||
|
): string {
|
||||||
|
return translate(
|
||||||
|
`configurator.descriptions.${configuratorDescriptionKey(path, value, structure)}`,
|
||||||
|
{ name: label?.trim() || configuratorPathName(path) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ const previewModules = import.meta.glob<string>(
|
|||||||
|
|
||||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||||
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
const appId = path
|
||||||
|
.split('/')
|
||||||
|
.at(-1)
|
||||||
|
?.replace(/\.jpg$/, '')
|
||||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||||
return [appId, imageUrl]
|
return [appId, imageUrl]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ function escapeRegExp(value: string): string {
|
|||||||
describe('App Store preview catalog', () => {
|
describe('App Store preview catalog', () => {
|
||||||
it('contains a real captured screenshot for every built-in store app', () => {
|
it('contains a real captured screenshot for every built-in store app', () => {
|
||||||
const storeAppIds = PHONE_APPS.filter(
|
const storeAppIds = PHONE_APPS.filter(
|
||||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
(app) =>
|
||||||
|
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||||
).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
|
|
||||||
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
||||||
@@ -25,7 +26,8 @@ describe('App Store preview catalog', () => {
|
|||||||
|
|
||||||
it('provides specialized preview data for every built-in store app', () => {
|
it('provides specialized preview data for every built-in store app', () => {
|
||||||
const storeAppIds = PHONE_APPS.filter(
|
const storeAppIds = PHONE_APPS.filter(
|
||||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
(app) =>
|
||||||
|
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||||
).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
|
|
||||||
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
moveHomeFolderApp,
|
moveHomeFolderApp,
|
||||||
parseHomeLayout,
|
parseHomeLayout,
|
||||||
reflowHomeGridForWidgetChange,
|
reflowHomeGridForWidgetChange,
|
||||||
|
removeDockGridDuplicates,
|
||||||
removeHomeApp,
|
removeHomeApp,
|
||||||
renameHomeFolder,
|
renameHomeFolder,
|
||||||
restoreHomeApp,
|
restoreHomeApp,
|
||||||
@@ -78,6 +79,40 @@ describe('home layout', () => {
|
|||||||
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
|
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes dock apps from the grid and normalizes affected folders', () => {
|
||||||
|
const layout: HomeLayout = {
|
||||||
|
...defaults,
|
||||||
|
dock: [
|
||||||
|
'phone',
|
||||||
|
{
|
||||||
|
apps: ['messages', 'mail'],
|
||||||
|
id: 'folder-abcdef',
|
||||||
|
name: 'Dock',
|
||||||
|
type: 'folder',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
grid: [
|
||||||
|
'phone',
|
||||||
|
{
|
||||||
|
apps: ['messages', 'notes'],
|
||||||
|
id: 'folder-ghijkl',
|
||||||
|
name: 'Grid',
|
||||||
|
type: 'folder',
|
||||||
|
},
|
||||||
|
'mail',
|
||||||
|
'clock',
|
||||||
|
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = removeDockGridDuplicates(layout)
|
||||||
|
|
||||||
|
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
|
||||||
|
expect(normalized.dock).toEqual(layout.dock)
|
||||||
|
})
|
||||||
|
|
||||||
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
||||||
const layout = parseHomeLayout(
|
const layout = parseHomeLayout(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -505,6 +505,37 @@ export function createDefaultHomeLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
|
||||||
|
const dockAppIds = new Set<LaunchablePhoneAppId>()
|
||||||
|
for (const item of layout.dock) {
|
||||||
|
if (typeof item === 'string') dockAppIds.add(item)
|
||||||
|
if (isHomeFolder(item)) {
|
||||||
|
for (const appId of item.apps) dockAppIds.add(appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dockAppIds.size) return layout
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const grid = layout.grid.map((item): HomeSlot => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
if (!dockAppIds.has(item)) return item
|
||||||
|
changed = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!isHomeFolder(item)) return null
|
||||||
|
|
||||||
|
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
|
||||||
|
if (apps.length === item.apps.length) return cloneItem(item)
|
||||||
|
changed = true
|
||||||
|
return normalizeFolder({ ...item, apps })
|
||||||
|
})
|
||||||
|
if (!changed) return layout
|
||||||
|
|
||||||
|
const next = cloneLayout(layout)
|
||||||
|
next.grid = compactGridPages(grid)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
export function parseHomeLayout(
|
export function parseHomeLayout(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
defaults: HomeLayout,
|
defaults: HomeLayout,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
|
|
||||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||||
import {
|
import {
|
||||||
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
|
|||||||
expect(() => new Function(runtime)).not.toThrow()
|
expect(() => new Function(runtime)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies the LB iframe layout contract before revealing vendor apps', () => {
|
||||||
|
const document = createLbPhoneFrameDocument(
|
||||||
|
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
|
||||||
|
{
|
||||||
|
appName: 'radio-app',
|
||||||
|
localStorage: {},
|
||||||
|
resourceName: 'lb-radioapp',
|
||||||
|
settings: createLbPhoneHostSettings({
|
||||||
|
deviceName: 'Main phone',
|
||||||
|
isDarkMode: true,
|
||||||
|
language: 'en',
|
||||||
|
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||||
|
securityEnabled: false,
|
||||||
|
}),
|
||||||
|
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
|
||||||
|
expect(runtime).toBeTruthy()
|
||||||
|
|
||||||
|
const messageListeners: Array<(event: { data: unknown }) => void> = []
|
||||||
|
const readyListeners: Array<() => void> = []
|
||||||
|
const documentElement = { dataset: {}, style: {} }
|
||||||
|
const body = {
|
||||||
|
dataset: {},
|
||||||
|
style: { visibility: 'hidden' },
|
||||||
|
}
|
||||||
|
const sandbox = {
|
||||||
|
addEventListener(
|
||||||
|
eventName: string,
|
||||||
|
listener: (event: { data: unknown }) => void,
|
||||||
|
) {
|
||||||
|
if (eventName === 'message') messageListeners.push(listener)
|
||||||
|
},
|
||||||
|
componentsLoaded: undefined as boolean | undefined,
|
||||||
|
console,
|
||||||
|
document: {
|
||||||
|
addEventListener(eventName: string, listener: () => void) {
|
||||||
|
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
documentElement,
|
||||||
|
},
|
||||||
|
parent: { postMessage() {} },
|
||||||
|
}
|
||||||
|
|
||||||
|
runInNewContext(runtime ?? '', sandbox)
|
||||||
|
expect(body.style.visibility).toBe('hidden')
|
||||||
|
|
||||||
|
expect(readyListeners).toHaveLength(1)
|
||||||
|
readyListeners[0]?.()
|
||||||
|
expect(documentElement.style).toMatchObject({
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
width: '100%',
|
||||||
|
})
|
||||||
|
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
|
||||||
|
expect(body.style).toMatchObject({
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
visibility: 'visible',
|
||||||
|
width: '100%',
|
||||||
|
})
|
||||||
|
|
||||||
|
body.style.visibility = 'hidden'
|
||||||
|
expect(messageListeners).toHaveLength(1)
|
||||||
|
messageListeners[0]?.({ data: 'componentsLoaded' })
|
||||||
|
expect(body.style.visibility).toBe('visible')
|
||||||
|
expect(sandbox.componentsLoaded).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('persists isolated LB localStorage snapshots without app changes', () => {
|
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||||
const values = new Map<string, string>()
|
const values = new Map<string, string>()
|
||||||
const storage = {
|
const storage = {
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
|
|||||||
if (document.body) document.body.dataset.theme = theme;
|
if (document.body) document.body.dataset.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareDocument() {
|
||||||
|
Object.assign(document.documentElement.style, {
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
if (!document.body) return;
|
||||||
|
|
||||||
|
document.body.dataset.device = 'phone';
|
||||||
|
Object.assign(document.body.style, {
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
visibility: 'visible',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
globalThis.resourceName = config.resourceName;
|
globalThis.resourceName = config.resourceName;
|
||||||
globalThis.appName = config.appName;
|
globalThis.appName = config.appName;
|
||||||
globalThis.components = globalThis.components ?? {};
|
globalThis.components = globalThis.components ?? {};
|
||||||
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
|
|||||||
|
|
||||||
globalThis.addEventListener('message', (event) => {
|
globalThis.addEventListener('message', (event) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
if (message === 'componentsLoaded') {
|
||||||
|
globalThis.componentsLoaded = true;
|
||||||
|
prepareDocument();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!message || typeof message !== 'object') return;
|
if (!message || typeof message !== 'object') return;
|
||||||
|
|
||||||
if (message.type === 'sky-phone:lb-settings') {
|
if (message.type === 'sky-phone:lb-settings') {
|
||||||
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applySettings(config.settings);
|
applySettings(config.settings);
|
||||||
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
prepareDocument();
|
||||||
|
applySettings(globalThis.settings);
|
||||||
|
}, { once: true });
|
||||||
`
|
`
|
||||||
|
|
||||||
function escapeAttribute(value: string): string {
|
function escapeAttribute(value: string): string {
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ describe('media utilities', () => {
|
|||||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||||
'profile_photo_required',
|
'profile_photo_required',
|
||||||
)
|
)
|
||||||
|
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||||
|
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||||
|
'media_provider_rate_limited',
|
||||||
|
)
|
||||||
|
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||||
|
'media_provider_unauthorized',
|
||||||
|
)
|
||||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
|
|||||||
'invalid_import_url',
|
'invalid_import_url',
|
||||||
'invalid_upload',
|
'invalid_upload',
|
||||||
'invalid_upload_token',
|
'invalid_upload_token',
|
||||||
|
'media_provider_failed',
|
||||||
|
'media_provider_rate_limited',
|
||||||
|
'media_provider_unauthorized',
|
||||||
'missing_config',
|
'missing_config',
|
||||||
'import_media_not_allowed',
|
'import_media_not_allowed',
|
||||||
'import_media_too_large',
|
'import_media_too_large',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
|
import {
|
||||||
|
configurePhoneNumberFormat,
|
||||||
|
formatPhoneNumber,
|
||||||
|
normalizePhoneNumber,
|
||||||
|
} from './phone'
|
||||||
|
|
||||||
describe('phone numbers', () => {
|
describe('phone numbers', () => {
|
||||||
it('normalizes formatted ten digit values', () => {
|
it('normalizes formatted ten digit values', () => {
|
||||||
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
|
|||||||
expect(formatPhoneNumber('5551')).toBe('555 1')
|
expect(formatPhoneNumber('5551')).toBe('555 1')
|
||||||
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the server-provided number length and display groups', () => {
|
||||||
|
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
|
||||||
|
|
||||||
|
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
|
||||||
|
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
|
||||||
|
|
||||||
|
configurePhoneNumberFormat()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
|
import type { PhoneNumberFormat } from '@/types/phone'
|
||||||
|
|
||||||
export const PHONE_NUMBER_LENGTH = 10
|
export const PHONE_NUMBER_LENGTH = 10
|
||||||
|
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
|
||||||
|
groups: [3, 3, 4],
|
||||||
|
length: PHONE_NUMBER_LENGTH,
|
||||||
|
}
|
||||||
|
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||||
|
|
||||||
|
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
|
||||||
|
const length = value?.length
|
||||||
|
const groups = value?.groups
|
||||||
|
if (
|
||||||
|
typeof length !== 'number' ||
|
||||||
|
!Number.isInteger(length) ||
|
||||||
|
length < 1 ||
|
||||||
|
length > 24 ||
|
||||||
|
!Array.isArray(groups) ||
|
||||||
|
groups.length === 0 ||
|
||||||
|
groups.some((group) => !Number.isInteger(group) || group < 1)
|
||||||
|
) {
|
||||||
|
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
phoneNumberFormat = {
|
||||||
|
groups: [...groups],
|
||||||
|
length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePhoneNumber(value: string): string | null {
|
export function normalizePhoneNumber(value: string): string | null {
|
||||||
const digits = value.replace(/\D/g, '')
|
const digits = value.replace(/\D/g, '')
|
||||||
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
|
return digits.length === phoneNumberFormat.length ? digits : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPhoneNumber(value: string | number): string {
|
export function formatPhoneNumber(value: string | number): string {
|
||||||
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
|
const digits = String(value)
|
||||||
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
|
.replace(/\D/g, '')
|
||||||
return groups.filter(Boolean).join(' ')
|
.slice(0, phoneNumberFormat.length)
|
||||||
|
const formatted: string[] = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (const size of phoneNumberFormat.groups) {
|
||||||
|
const group = digits.slice(offset, offset + size)
|
||||||
|
if (!group) break
|
||||||
|
formatted.push(group)
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
if (offset < digits.length) formatted.push(digits.slice(offset))
|
||||||
|
|
||||||
|
return formatted.join(' ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
type PhoneAudioVolumeListener = (volume: number) => void
|
||||||
|
|
||||||
|
const mediaElements = new Map<HTMLMediaElement, boolean>()
|
||||||
|
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
|
||||||
|
const volumeListeners = new Set<PhoneAudioVolumeListener>()
|
||||||
|
|
||||||
|
let documentListenerReferences = 0
|
||||||
|
let outputVolume = 1
|
||||||
|
|
||||||
|
function clampVolume(volume: number): number {
|
||||||
|
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMediaVolume(element: HTMLMediaElement): void {
|
||||||
|
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||||
|
const nextVolume = clampVolume(localVolume * outputVolume)
|
||||||
|
if (Math.abs(element.volume - nextVolume) < 0.001) return
|
||||||
|
element.volume = nextVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaVolumeChange(event: Event): void {
|
||||||
|
const element = event.currentTarget as HTMLMediaElement
|
||||||
|
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||||
|
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
|
||||||
|
if (Math.abs(element.volume - expectedVolume) < 0.001) return
|
||||||
|
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||||
|
applyMediaVolume(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaPlay(event: Event): void {
|
||||||
|
if (event.target instanceof HTMLMediaElement) {
|
||||||
|
trackPhoneMediaElement(event.target, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackPhoneMediaElement<T extends HTMLMediaElement>(
|
||||||
|
element: T,
|
||||||
|
persistent: boolean,
|
||||||
|
): T {
|
||||||
|
if (mediaElements.has(element)) {
|
||||||
|
if (persistent) mediaElements.set(element, true)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
mediaElements.set(element, persistent)
|
||||||
|
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||||
|
element.addEventListener('volumechange', onMediaVolumeChange)
|
||||||
|
applyMediaVolume(element)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPhoneOutputVolume(): number {
|
||||||
|
return outputVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installPhoneAudioController(): () => void {
|
||||||
|
documentListenerReferences += 1
|
||||||
|
if (documentListenerReferences === 1) {
|
||||||
|
document.addEventListener('play', onMediaPlay, true)
|
||||||
|
document
|
||||||
|
.querySelectorAll<HTMLMediaElement>('audio, video')
|
||||||
|
.forEach((element) => trackPhoneMediaElement(element, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
|
||||||
|
if (documentListenerReferences === 0) {
|
||||||
|
document.removeEventListener('play', onMediaPlay, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
|
||||||
|
element: T,
|
||||||
|
): T {
|
||||||
|
return trackPhoneMediaElement(element, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPhoneOutputVolume(volume: number): void {
|
||||||
|
outputVolume = clampVolume(volume)
|
||||||
|
for (const [element, persistent] of mediaElements) {
|
||||||
|
if (!persistent && !element.isConnected && element.paused) {
|
||||||
|
element.removeEventListener('volumechange', onMediaVolumeChange)
|
||||||
|
mediaElements.delete(element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyMediaVolume(element)
|
||||||
|
}
|
||||||
|
for (const listener of volumeListeners) listener(outputVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePhoneOutputVolume(
|
||||||
|
listener: PhoneAudioVolumeListener,
|
||||||
|
): () => void {
|
||||||
|
volumeListeners.add(listener)
|
||||||
|
return () => volumeListeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizePhoneViewportRect,
|
||||||
|
readPhoneViewportGeometry,
|
||||||
|
type PhoneViewportRect,
|
||||||
|
} from '@/utils/phoneViewportGeometry'
|
||||||
|
|
||||||
|
type RectMeasurement = Pick<
|
||||||
|
DOMRectReadOnly,
|
||||||
|
'height' | 'left' | 'top' | 'width'
|
||||||
|
>
|
||||||
|
|
||||||
|
function measuredRect(
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): RectMeasurement {
|
||||||
|
return { height, left, top, width }
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRectClose(
|
||||||
|
actual: PhoneViewportRect,
|
||||||
|
expected: PhoneViewportRect,
|
||||||
|
): void {
|
||||||
|
for (const key of [
|
||||||
|
'bottom',
|
||||||
|
'height',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'top',
|
||||||
|
'width',
|
||||||
|
] as const) {
|
||||||
|
expect(actual[key]).toBeCloseTo(expected[key], 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGeometryFixture(options: {
|
||||||
|
canvasOffsetHeight: number
|
||||||
|
canvasOffsetWidth: number
|
||||||
|
rawCanvasRect: RectMeasurement
|
||||||
|
wrapperRect: RectMeasurement
|
||||||
|
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
|
||||||
|
const wrapper = {
|
||||||
|
getBoundingClientRect: () => options.wrapperRect,
|
||||||
|
} as unknown as HTMLElement
|
||||||
|
const canvas = {
|
||||||
|
closest: (selector: string) =>
|
||||||
|
selector === '.phone-resolution-wrapper' ? wrapper : null,
|
||||||
|
getBoundingClientRect: () => options.rawCanvasRect,
|
||||||
|
offsetHeight: options.canvasOffsetHeight,
|
||||||
|
offsetWidth: options.canvasOffsetWidth,
|
||||||
|
} as unknown as HTMLElement
|
||||||
|
|
||||||
|
return {
|
||||||
|
anchor: {
|
||||||
|
closest: (selector: string) =>
|
||||||
|
selector === '.phone-resolution-canvas' ? canvas : null,
|
||||||
|
} as unknown as Element,
|
||||||
|
element: (rect) =>
|
||||||
|
({ getBoundingClientRect: () => rect }) as unknown as Element,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('phone viewport geometry', () => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
|
||||||
|
bottom: layer.top + layer.height,
|
||||||
|
height: layer.height,
|
||||||
|
left: layer.left,
|
||||||
|
right: layer.left + layer.width,
|
||||||
|
top: layer.top,
|
||||||
|
width: layer.width,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
|
||||||
|
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||||
|
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
|
||||||
|
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
|
||||||
|
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
|
||||||
|
|
||||||
|
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
|
||||||
|
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
|
||||||
|
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
|
||||||
|
expect(corrected.left).toBeGreaterThan(wrapper.left)
|
||||||
|
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['80%', 0.6624],
|
||||||
|
['100%', 0.828],
|
||||||
|
['120%', 0.9936],
|
||||||
|
])(
|
||||||
|
'normalizes fractional positions, sizes, and deltas at %s scaling',
|
||||||
|
(_label, zoom) => {
|
||||||
|
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
|
||||||
|
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
|
||||||
|
const rawStart = measuredRect(
|
||||||
|
rawCanvas.left + 20.125,
|
||||||
|
rawCanvas.top + 50.375,
|
||||||
|
349.75,
|
||||||
|
73.125,
|
||||||
|
)
|
||||||
|
const rawEnd = measuredRect(
|
||||||
|
rawStart.left + 73.25,
|
||||||
|
rawStart.top - 41.75,
|
||||||
|
rawStart.width,
|
||||||
|
rawStart.height,
|
||||||
|
)
|
||||||
|
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
|
||||||
|
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
|
||||||
|
|
||||||
|
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
|
||||||
|
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
|
||||||
|
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
|
||||||
|
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
|
||||||
|
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
|
||||||
|
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||||
|
const fixture = createGeometryFixture({
|
||||||
|
canvasOffsetHeight: 844,
|
||||||
|
canvasOffsetWidth: 390,
|
||||||
|
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
||||||
|
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
||||||
|
})
|
||||||
|
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||||
|
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
||||||
|
|
||||||
|
expect(geometry).not.toBeNull()
|
||||||
|
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
||||||
|
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
||||||
|
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
||||||
|
const rawCanvas = measuredRect(100, 50, 0, 0)
|
||||||
|
const corrected = normalizePhoneViewportRect(
|
||||||
|
measuredRect(112.5, 58.25, 40, 20),
|
||||||
|
rawCanvas,
|
||||||
|
measuredRect(500, 300, 0, 0),
|
||||||
|
)
|
||||||
|
const fixture = createGeometryFixture({
|
||||||
|
canvasOffsetHeight: 0,
|
||||||
|
canvasOffsetWidth: 0,
|
||||||
|
rawCanvasRect: rawCanvas,
|
||||||
|
wrapperRect: measuredRect(500, 300, 0, 0),
|
||||||
|
})
|
||||||
|
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||||
|
|
||||||
|
expect(corrected).toEqual({
|
||||||
|
bottom: 328.25,
|
||||||
|
height: 20,
|
||||||
|
left: 512.5,
|
||||||
|
right: 552.5,
|
||||||
|
top: 308.25,
|
||||||
|
width: 40,
|
||||||
|
})
|
||||||
|
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
||||||
|
expect(geometry?.scaleX).toBe(1)
|
||||||
|
expect(geometry?.scaleY).toBe(1)
|
||||||
|
expect(readPhoneViewportGeometry(null)).toBeNull()
|
||||||
|
expect(
|
||||||
|
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export type PhoneViewportRect = {
|
||||||
|
bottom: number
|
||||||
|
height: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RectMeasurement = Pick<
|
||||||
|
DOMRectReadOnly,
|
||||||
|
'height' | 'left' | 'top' | 'width'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type PhoneViewportGeometry = {
|
||||||
|
readonly scaleX: number
|
||||||
|
readonly scaleY: number
|
||||||
|
rect(element: Element): PhoneViewportRect
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveRatio(numerator: number, denominator: number): number {
|
||||||
|
return Number.isFinite(numerator) &&
|
||||||
|
Number.isFinite(denominator) &&
|
||||||
|
numerator > 0 &&
|
||||||
|
denominator > 0
|
||||||
|
? numerator / denominator
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePhoneViewportRect(
|
||||||
|
rawRect: RectMeasurement,
|
||||||
|
rawCanvasRect: RectMeasurement,
|
||||||
|
wrapperRect: RectMeasurement,
|
||||||
|
): PhoneViewportRect {
|
||||||
|
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
|
||||||
|
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
|
||||||
|
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
|
||||||
|
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
|
||||||
|
const width = rawRect.width * factorX
|
||||||
|
const height = rawRect.height * factorY
|
||||||
|
|
||||||
|
return {
|
||||||
|
bottom: top + height,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPhoneViewportGeometry(
|
||||||
|
anchor: Element | null,
|
||||||
|
): PhoneViewportGeometry | null {
|
||||||
|
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
|
||||||
|
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||||
|
if (!canvas || !wrapper) return null
|
||||||
|
|
||||||
|
const rawCanvasRect = canvas.getBoundingClientRect()
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
|
||||||
|
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
|
||||||
|
rect(element: Element): PhoneViewportRect {
|
||||||
|
return normalizePhoneViewportRect(
|
||||||
|
element.getBoundingClientRect(),
|
||||||
|
rawCanvasRect,
|
||||||
|
wrapperRect,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
|
|||||||
expect(delta.x).toBeCloseTo(100)
|
expect(delta.x).toBeCloseTo(100)
|
||||||
expect(delta.y).toBeCloseTo(200)
|
expect(delta.y).toBeCloseTo(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['80% preview', 0.6624],
|
||||||
|
['80% production clamp', 2 / 3],
|
||||||
|
['100%', 0.828],
|
||||||
|
['120%', 0.9936],
|
||||||
|
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
|
||||||
|
const layoutWidth = 350
|
||||||
|
const layoutHeight = 808
|
||||||
|
const viewportLeft = 128.25
|
||||||
|
const viewportTop = 64.5
|
||||||
|
const viewportWidth = layoutWidth * zoom
|
||||||
|
const viewportHeight = layoutHeight * zoom
|
||||||
|
const pointerStart = {
|
||||||
|
x: viewportLeft + 48.5 * zoom,
|
||||||
|
y: viewportTop + 132.25 * zoom,
|
||||||
|
}
|
||||||
|
const viewportDelta = { x: 73.25, y: -41.75 }
|
||||||
|
const start = springboardViewportToLocal(
|
||||||
|
pointerStart.x,
|
||||||
|
pointerStart.y,
|
||||||
|
viewportLeft,
|
||||||
|
viewportTop,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
layoutWidth,
|
||||||
|
layoutHeight,
|
||||||
|
)
|
||||||
|
const end = springboardViewportToLocal(
|
||||||
|
pointerStart.x + viewportDelta.x,
|
||||||
|
pointerStart.y + viewportDelta.y,
|
||||||
|
viewportLeft,
|
||||||
|
viewportTop,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
layoutWidth,
|
||||||
|
layoutHeight,
|
||||||
|
)
|
||||||
|
const localDelta = springboardViewportDeltaToLocal(
|
||||||
|
viewportDelta.x,
|
||||||
|
viewportDelta.y,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
layoutWidth,
|
||||||
|
layoutHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||||
|
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||||
|
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||||
|
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||||
|
|
||||||
export type PageTurnDirection = -1 | 0 | 1
|
export type PageTurnDirection = -1 | 0 | 1
|
||||||
|
|
||||||
export type SpringboardEdgeTurn = {
|
export type SpringboardEdgeTurn = {
|
||||||
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
|
|||||||
'.springboard-page, .home-folder-panel',
|
'.springboard-page, .home-folder-panel',
|
||||||
)
|
)
|
||||||
if (!surface) return null
|
if (!surface) return null
|
||||||
const bounds = surface.getBoundingClientRect()
|
const bounds =
|
||||||
|
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||||
|
surface.getBoundingClientRect()
|
||||||
return {
|
return {
|
||||||
layoutHeight: surface.offsetHeight,
|
layoutHeight: surface.offsetHeight,
|
||||||
layoutWidth: surface.offsetWidth,
|
layoutWidth: surface.offsetWidth,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('phone tones', () => {
|
|||||||
}> = []
|
}> = []
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'Audio',
|
'Audio',
|
||||||
class {
|
class extends EventTarget {
|
||||||
currentTime = 7
|
currentTime = 7
|
||||||
loop = false
|
loop = false
|
||||||
pause = pause
|
pause = pause
|
||||||
@@ -42,6 +42,7 @@ describe('phone tones', () => {
|
|||||||
volume = 0
|
volume = 0
|
||||||
|
|
||||||
constructor(src: string) {
|
constructor(src: string) {
|
||||||
|
super()
|
||||||
this.src = src
|
this.src = src
|
||||||
players.push(this)
|
players.push(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
import type { AlarmSoundId } from '@/utils/alarms'
|
import type { AlarmSoundId } from '@/utils/alarms'
|
||||||
import type { NotificationSoundId } from '@/utils/preferences'
|
import type { NotificationSoundId } from '@/utils/preferences'
|
||||||
|
|
||||||
@@ -495,8 +496,8 @@ export function playPhoneVibration(
|
|||||||
kind: PhoneVibrationKind,
|
kind: PhoneVibrationKind,
|
||||||
loop: boolean,
|
loop: boolean,
|
||||||
): () => void {
|
): () => void {
|
||||||
const player = new Audio(
|
const player = registerPhoneMediaElement(
|
||||||
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
|
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
|
||||||
)
|
)
|
||||||
let stopped = false
|
let stopped = false
|
||||||
player.loop = loop
|
player.loop = loop
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="app"
|
v-if="app && !app.adminOnly"
|
||||||
class="app-window"
|
class="app-window"
|
||||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||||
:style="launchStyle"
|
:style="launchStyle"
|
||||||
|
|||||||
@@ -19,11 +19,31 @@ const mainCss = readFileSync(
|
|||||||
new URL('../assets/main.css', import.meta.url),
|
new URL('../assets/main.css', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
|
const tokensCss = readFileSync(
|
||||||
|
new URL('../ui/tokens.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const foundationCss = readFileSync(
|
||||||
|
new URL('../ui/foundation.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
const builtInWallpaperCss = mainCss.slice(
|
const builtInWallpaperCss = mainCss.slice(
|
||||||
mainCss.indexOf('.wallpaper--midnight'),
|
mainCss.indexOf('.wallpaper--midnight'),
|
||||||
mainCss.indexOf('.wallpaper--custom'),
|
mainCss.indexOf('.wallpaper--custom'),
|
||||||
)
|
)
|
||||||
|
|
||||||
describe('Springboard page swipe contract', () => {
|
describe('Springboard page swipe contract', () => {
|
||||||
|
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||||
|
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
|
||||||
|
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||||
|
)
|
||||||
|
expect(foundationCss).toMatch(
|
||||||
|
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the built-in wallpapers visually restrained', () => {
|
it('keeps the built-in wallpapers visually restrained', () => {
|
||||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||||
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
|
|||||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
it('renders the home drag visual through the unzoomed phone portal', () => {
|
||||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
expect(viewSource).toContain('source.cloneNode(true)')
|
||||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
|
||||||
expect(viewSource).not.toContain(':external-drag-visual')
|
|
||||||
expect(viewSource).toContain(
|
expect(viewSource).toContain(
|
||||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
'<Teleport defer to="#phone-home-drag-portal">',
|
||||||
)
|
)
|
||||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
expect(viewSource).toContain(
|
||||||
expect(appIconSource).not.toContain('externalDragVisual')
|
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
|
||||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
)
|
||||||
expect(mainCss).not.toContain('.home-drag-layer')
|
expect(viewSource).toContain('readPhoneViewportGeometry')
|
||||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
|
||||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
|
||||||
|
expect(viewSource).not.toContain('homeDragLocalPoint')
|
||||||
|
expect(viewSource).not.toContain('springboardViewportToLocal')
|
||||||
|
expect(viewSource).toContain("position.className = 'home-drag-position'")
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||||
|
).toHaveLength(4)
|
||||||
|
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||||
|
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||||
|
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||||
|
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
|
||||||
|
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||||
|
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
|
||||||
|
expect(viewSource).not.toContain('springboard--home-dragging')
|
||||||
|
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||||
|
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||||
|
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||||
|
expect(folderIconSource).toContain('app-icon-item--drag-source')
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
|
||||||
)
|
)
|
||||||
|
expect(mainCss).toContain('.home-drag-position')
|
||||||
|
expect(mainCss).toContain('.home-drag-ghost')
|
||||||
|
expect(mainCss).toContain('.app-icon-item--drag-source')
|
||||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cleans up the external drag visual on every terminal path', () => {
|
||||||
|
expect(
|
||||||
|
viewSource.match(
|
||||||
|
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
|
||||||
|
),
|
||||||
|
).toHaveLength(4)
|
||||||
|
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
|
||||||
|
expect(viewSource).toContain(
|
||||||
|
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
||||||
expect(viewSource).toContain(':data-home-page="page.page"')
|
expect(viewSource).toContain(':data-home-page="page.page"')
|
||||||
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
||||||
expect(viewSource).toContain('nearestGridDropTarget')
|
expect(viewSource).toContain('nearestGridDropTarget')
|
||||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||||
|
expect(viewSource).toContain('function homeDragViewportRect(')
|
||||||
|
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toContain(
|
||||||
|
'springboardBounds.left + (bounds.left - pageBounds.left)',
|
||||||
|
)
|
||||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
type HomeItem,
|
type HomeItem,
|
||||||
} from '@/utils/homeLayout'
|
} from '@/utils/homeLayout'
|
||||||
import type { ReorderDirection } from '@/utils/keyboard'
|
import type { ReorderDirection } from '@/utils/keyboard'
|
||||||
|
import {
|
||||||
|
readPhoneViewportGeometry,
|
||||||
|
type PhoneViewportGeometry,
|
||||||
|
type PhoneViewportRect,
|
||||||
|
} from '@/utils/phoneViewportGeometry'
|
||||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||||
import {
|
import {
|
||||||
maximumRenderedWidgetPage,
|
maximumRenderedWidgetPage,
|
||||||
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
|
|||||||
area: HomeArea
|
area: HomeArea
|
||||||
index: number
|
index: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||||
|
const homeDragVisualActive = ref(false)
|
||||||
const temporaryHomePage = ref<number | null>(null)
|
const temporaryHomePage = ref<number | null>(null)
|
||||||
const openedFolderId = ref<string | null>(null)
|
const openedFolderId = ref<string | null>(null)
|
||||||
const folderDraggingOutside = ref(false)
|
const folderDraggingOutside = ref(false)
|
||||||
@@ -125,6 +132,11 @@ let edgePageDirection = 0
|
|||||||
let edgePageLocked = false
|
let edgePageLocked = false
|
||||||
let folderHoverTimer: number | undefined
|
let folderHoverTimer: number | undefined
|
||||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||||
|
let homeDragGhost: HTMLElement | null = null
|
||||||
|
let homeDragGrip: { x: number; y: number } | null = null
|
||||||
|
let homeDragGeometry: PhoneViewportGeometry | null = null
|
||||||
|
let homeDragClipBounds: PhoneViewportRect | null = null
|
||||||
|
let homeDragPreviewBounds: PhoneViewportRect | null = null
|
||||||
|
|
||||||
const installedApps = computed(() =>
|
const installedApps = computed(() =>
|
||||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||||
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
|
|||||||
).find((candidate) => candidate.dataset.widgetId === id)
|
).find((candidate) => candidate.dataset.widgetId === id)
|
||||||
if (!widget) return
|
if (!widget) return
|
||||||
pageTransitioning.value = false
|
pageTransitioning.value = false
|
||||||
const bounds = widget.getBoundingClientRect()
|
const bounds = homeDragViewportRect(widget)
|
||||||
draggingWidgetId.value = id
|
draggingWidgetId.value = id
|
||||||
widgetDragGrip.value = {
|
widgetDragGrip.value = {
|
||||||
x: event.clientX - bounds.left,
|
x: event.clientX - bounds.left,
|
||||||
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
|
|||||||
)
|
)
|
||||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||||
if (!grid || !pageElement) return
|
if (!grid || !pageElement) return
|
||||||
const renderedGridBounds = grid.getBoundingClientRect()
|
const renderedGridBounds = homeDragViewportRect(grid)
|
||||||
const gridStyle = getComputedStyle(grid)
|
const gridStyle = getComputedStyle(grid)
|
||||||
const scaleX =
|
const scaleX =
|
||||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||||
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
|||||||
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||||
if (!springboard) return null
|
if (!springboard) return null
|
||||||
const bounds = springboard.getBoundingClientRect()
|
const bounds = homeDragViewportRect(springboard)
|
||||||
const renderedLastPage = appPages.value.length
|
const renderedLastPage = appPages.value.length
|
||||||
return resolveSpringboardHomeEdgeTurn(
|
return resolveSpringboardHomeEdgeTurn(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
|
|||||||
): void {
|
): void {
|
||||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||||
if (!springboard) return
|
if (!springboard) return
|
||||||
const bounds = springboard.getBoundingClientRect()
|
const bounds = homeDragViewportRect(springboard)
|
||||||
const turn =
|
const turn =
|
||||||
dragType === 'app'
|
dragType === 'app'
|
||||||
? resolveHomeEdgeTurn(event)
|
? resolveHomeEdgeTurn(event)
|
||||||
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
|
|||||||
if (configured) changePage(configured.page)
|
if (configured) changePage(configured.page)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHomeDrag(area: HomeArea, index: number): void {
|
function homeDragViewportRect(
|
||||||
|
element: Element,
|
||||||
|
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
|
||||||
|
): PhoneViewportRect {
|
||||||
|
if (geometry) return geometry.rect(element)
|
||||||
|
const bounds = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
bottom: bounds.bottom,
|
||||||
|
height: bounds.height,
|
||||||
|
left: bounds.left,
|
||||||
|
right: bounds.right,
|
||||||
|
top: bounds.top,
|
||||||
|
width: bounds.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportRectAt(
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): PhoneViewportRect {
|
||||||
|
return {
|
||||||
|
bottom: top + height,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||||
|
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||||
|
homeDragGhost?.remove()
|
||||||
|
homeDragGhost = null
|
||||||
|
homeDragGrip = null
|
||||||
|
homeDragGeometry = null
|
||||||
|
homeDragClipBounds = null
|
||||||
|
homeDragPreviewBounds = null
|
||||||
|
homeDragVisualActive.value = false
|
||||||
|
const layer = homeDragLayer.value
|
||||||
|
layer?.style.removeProperty('left')
|
||||||
|
layer?.style.removeProperty('top')
|
||||||
|
layer?.style.removeProperty('width')
|
||||||
|
layer?.style.removeProperty('height')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHomeDragGhost(event: {
|
||||||
|
clientX: number
|
||||||
|
clientY: number
|
||||||
|
}): void {
|
||||||
|
const ghost = homeDragGhost
|
||||||
|
const grip = homeDragGrip
|
||||||
|
const clipBounds = homeDragClipBounds
|
||||||
|
const previewBounds = homeDragPreviewBounds
|
||||||
|
if (!ghost || !grip || !clipBounds || !previewBounds) return
|
||||||
|
|
||||||
|
const left = event.clientX - grip.x
|
||||||
|
const top = event.clientY - grip.y
|
||||||
|
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
|
||||||
|
homeDragPreviewBounds = viewportRectAt(
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
previewBounds.width,
|
||||||
|
previewBounds.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHomeDragGhost(event: PointerEvent): void {
|
||||||
|
clearHomeDragGhost()
|
||||||
|
const eventTarget =
|
||||||
|
event.currentTarget instanceof Element
|
||||||
|
? event.currentTarget
|
||||||
|
: event.target instanceof Element
|
||||||
|
? event.target
|
||||||
|
: null
|
||||||
|
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||||
|
const layer = homeDragLayer.value
|
||||||
|
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
|
||||||
|
|
||||||
|
const portalBounds = portal.getBoundingClientRect()
|
||||||
|
const clipBounds = geometry.rect(springboard)
|
||||||
|
const sourceBounds = geometry.rect(source)
|
||||||
|
if (
|
||||||
|
clipBounds.width <= 0 ||
|
||||||
|
clipBounds.height <= 0 ||
|
||||||
|
sourceBounds.width <= 0 ||
|
||||||
|
sourceBounds.height <= 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||||
|
layer.style.top = `${clipBounds.top - portalBounds.top}px`
|
||||||
|
layer.style.width = `${clipBounds.width}px`
|
||||||
|
layer.style.height = `${clipBounds.height}px`
|
||||||
|
|
||||||
|
const ghost = source.cloneNode(true) as HTMLElement
|
||||||
|
ghost.classList.remove(
|
||||||
|
'app-icon-item--dragging',
|
||||||
|
'app-icon-item--editing',
|
||||||
|
'app-icon-item--drag-source',
|
||||||
|
'app-icon-item--drop-settling',
|
||||||
|
'home-folder-item--dragging',
|
||||||
|
'home-item--folder-hover',
|
||||||
|
)
|
||||||
|
ghost.classList.add('home-drag-ghost')
|
||||||
|
ghost.removeAttribute('style')
|
||||||
|
ghost
|
||||||
|
.querySelector<HTMLElement>('.app-icon-button')
|
||||||
|
?.style.removeProperty('transform')
|
||||||
|
ghost.setAttribute('aria-hidden', 'true')
|
||||||
|
ghost.querySelector('.app-icon-remove')?.remove()
|
||||||
|
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||||
|
element.removeAttribute('id')
|
||||||
|
for (const attribute of element.getAttributeNames()) {
|
||||||
|
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||||
|
'a, button, input, select, textarea, [tabindex]',
|
||||||
|
)) {
|
||||||
|
control.tabIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = document.createElement('div')
|
||||||
|
position.className = 'home-drag-position'
|
||||||
|
position.style.width = `${sourceBounds.width}px`
|
||||||
|
position.style.height = `${sourceBounds.height}px`
|
||||||
|
ghost.style.width = `${source.offsetWidth}px`
|
||||||
|
ghost.style.height = `${source.offsetHeight}px`
|
||||||
|
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
|
||||||
|
position.appendChild(ghost)
|
||||||
|
|
||||||
|
homeDragGhost = position
|
||||||
|
homeDragGrip = {
|
||||||
|
x: event.clientX - sourceBounds.left,
|
||||||
|
y: event.clientY - sourceBounds.top,
|
||||||
|
}
|
||||||
|
homeDragGeometry = geometry
|
||||||
|
homeDragClipBounds = clipBounds
|
||||||
|
homeDragPreviewBounds = sourceBounds
|
||||||
|
layer.appendChild(position)
|
||||||
|
homeDragVisualActive.value = true
|
||||||
|
updateHomeDragGhost(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHomeDrag(
|
||||||
|
area: HomeArea,
|
||||||
|
index: number,
|
||||||
|
event: PointerEvent,
|
||||||
|
): void {
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
draggingHomeApp.value = { area, index }
|
draggingHomeApp.value = { area, index }
|
||||||
|
createHomeDragGhost(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFolderHover(): void {
|
function clearFolderHover(): void {
|
||||||
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetElement = document
|
const targetElement = Array.from(
|
||||||
.elementsFromPoint(event.clientX, event.clientY)
|
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
|
||||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
)
|
||||||
.find((element) => element && !element.closest('.app-icon-item--dragging'))
|
.filter((element) => {
|
||||||
|
const area = element.dataset.homeArea as HomeArea | undefined
|
||||||
|
const index = Number(element.dataset.homeIndex)
|
||||||
|
if (
|
||||||
|
(area !== 'grid' && area !== 'dock') ||
|
||||||
|
!Number.isInteger(index) ||
|
||||||
|
(area === dragged.area && index === dragged.index)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
area === 'grid' &&
|
||||||
|
Number(
|
||||||
|
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
|
||||||
|
) !== phone.currentPage
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const bounds = homeDragViewportRect(element)
|
||||||
|
return (
|
||||||
|
event.clientX >= bounds.left &&
|
||||||
|
event.clientX <= bounds.right &&
|
||||||
|
event.clientY >= bounds.top &&
|
||||||
|
event.clientY <= bounds.bottom
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftIsDock = left.dataset.homeArea === 'dock'
|
||||||
|
const rightIsDock = right.dataset.homeArea === 'dock'
|
||||||
|
return Number(rightIsDock) - Number(leftIsDock)
|
||||||
|
})[0]
|
||||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||||
if (
|
if (
|
||||||
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
if (folderId) {
|
if (folderId) {
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
openedFolderId.value = folderId
|
openedFolderId.value = folderId
|
||||||
}
|
}
|
||||||
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
function moveHomeDrag(event: PointerEvent): void {
|
function moveHomeDrag(event: PointerEvent): void {
|
||||||
if (!draggingHomeApp.value) return
|
if (!draggingHomeApp.value) return
|
||||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||||
|
updateHomeDragGhost(event)
|
||||||
if (resolveHomeEdgeTurn(event)) {
|
if (resolveHomeEdgeTurn(event)) {
|
||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
queueEdgePageTurn(event, 'app')
|
queueEdgePageTurn(event, 'app')
|
||||||
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
|
|||||||
item: HomeItem,
|
item: HomeItem,
|
||||||
area: HomeArea,
|
area: HomeArea,
|
||||||
index: number,
|
index: number,
|
||||||
from: DOMRect,
|
from: PhoneViewportRect,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
|
|||||||
)
|
)
|
||||||
if (!appElement) return
|
if (!appElement) return
|
||||||
|
|
||||||
const target = appElement.getBoundingClientRect()
|
const target = homeDragViewportRect(
|
||||||
|
appElement,
|
||||||
|
readPhoneViewportGeometry(appElement),
|
||||||
|
)
|
||||||
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
||||||
from.left - target.left,
|
from.left - target.left,
|
||||||
from.top - target.top,
|
from.top - target.top,
|
||||||
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
|
|||||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||||
)
|
)
|
||||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||||
if (!grid || !pageElement) return null
|
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||||
const pageBounds = pageElement.getBoundingClientRect()
|
if (!grid || !pageElement || !springboard) return null
|
||||||
|
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
|
||||||
|
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||||
|
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||||
if (
|
if (
|
||||||
event.clientX < pageBounds.left ||
|
event.clientX < springboardBounds.left ||
|
||||||
event.clientX > pageBounds.right ||
|
event.clientX > springboardBounds.right ||
|
||||||
event.clientY < pageBounds.top ||
|
event.clientY < springboardBounds.top ||
|
||||||
event.clientY > pageBounds.bottom
|
event.clientY > springboardBounds.bottom
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
|
|||||||
const closestIndex = nearestSpringboardRectIndex(
|
const closestIndex = nearestSpringboardRectIndex(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
slots.map((slot) => slot.getBoundingClientRect()),
|
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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||||
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
|
|||||||
}): HTMLElement | null {
|
}): HTMLElement | null {
|
||||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||||
if (!dock) return null
|
if (!dock) return null
|
||||||
const dockBounds = dock.getBoundingClientRect()
|
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
|
||||||
|
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||||
if (
|
if (
|
||||||
event.clientX < dockBounds.left ||
|
event.clientX < dockBounds.left ||
|
||||||
event.clientX > dockBounds.right ||
|
event.clientX > dockBounds.right ||
|
||||||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
|
|||||||
const closestIndex = nearestSpringboardRectIndex(
|
const closestIndex = nearestSpringboardRectIndex(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
slots.map((slot) => slot.getBoundingClientRect()),
|
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||||
)
|
)
|
||||||
return closestIndex === null ? null : slots[closestIndex]
|
return closestIndex === null ? null : slots[closestIndex]
|
||||||
}
|
}
|
||||||
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
|||||||
clearEdgePageTurn()
|
clearEdgePageTurn()
|
||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
const dragged = draggingHomeApp.value
|
const dragged = draggingHomeApp.value
|
||||||
if (!dragged) return
|
if (!dragged) {
|
||||||
|
clearHomeDragGhost()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateHomeDragGhost(event)
|
||||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||||
const draggedElement = document.querySelector<HTMLElement>(
|
const dropGhost = homeDragGhost
|
||||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||||
)
|
|
||||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
|
||||||
const dockTarget = nearestDockDropTarget(event)
|
const dockTarget = nearestDockDropTarget(event)
|
||||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||||
let dropArea = dragged.area
|
let dropArea = dragged.area
|
||||||
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
|||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||||
if (moved && draggedItem && dropOrigin) {
|
if (moved && draggedItem && dropOrigin) {
|
||||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
void animateHomeItemDrop(
|
||||||
|
draggedItem,
|
||||||
|
dropArea,
|
||||||
|
dropIndex,
|
||||||
|
dropOrigin,
|
||||||
|
).finally(() => clearHomeDragGhost(dropGhost))
|
||||||
|
} else {
|
||||||
|
clearHomeDragGhost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
|
|||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
|
|||||||
const appElement = document.querySelector<HTMLElement>(
|
const appElement = document.querySelector<HTMLElement>(
|
||||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||||
)
|
)
|
||||||
const moveOrigin = appElement?.getBoundingClientRect()
|
const moveOrigin = appElement
|
||||||
|
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
|
||||||
|
: null
|
||||||
const targetIndex = homeKeyboardTarget(
|
const targetIndex = homeKeyboardTarget(
|
||||||
appStore.homeLayout,
|
appStore.homeLayout,
|
||||||
area,
|
area,
|
||||||
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
|
|||||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||||
)
|
)
|
||||||
?.closest<HTMLElement>('.app-icon-item')
|
?.closest<HTMLElement>('.app-icon-item')
|
||||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||||
|
const sourceBounds = sourceElement
|
||||||
|
? homeDragViewportRect(sourceElement, geometry)
|
||||||
|
: null
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||||
).filter((element) => {
|
).filter((element) => {
|
||||||
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
|
|||||||
})
|
})
|
||||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||||
if (!closest) return candidate
|
if (!closest) return candidate
|
||||||
const bounds = candidate.getBoundingClientRect()
|
const bounds = homeDragViewportRect(candidate, geometry)
|
||||||
const closestBounds = closest.getBoundingClientRect()
|
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||||
const distance = Math.hypot(
|
const distance = Math.hypot(
|
||||||
event.clientX - (bounds.left + bounds.width / 2),
|
event.clientX - (bounds.left + bounds.width / 2),
|
||||||
event.clientY - (bounds.top + bounds.height / 2),
|
event.clientY - (bounds.top + bounds.height / 2),
|
||||||
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
|
|||||||
pendingWidgetPointer = null
|
pendingWidgetPointer = null
|
||||||
lastWidgetPointer = null
|
lastWidgetPointer = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
temporaryHomePage.value = null
|
temporaryHomePage.value = null
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
draggingWidgetId.value = null
|
draggingWidgetId.value = null
|
||||||
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
|
|||||||
'springboard--dragging': dragging,
|
'springboard--dragging': dragging,
|
||||||
'springboard--editing': editMode,
|
'springboard--editing': editMode,
|
||||||
'springboard--folder-open': folderOverlayVisible,
|
'springboard--folder-open': folderOverlayVisible,
|
||||||
'springboard--home-dragging': draggingHomeApp !== null,
|
|
||||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-index="cell.sourceIndex"
|
:data-home-index="cell.sourceIndex"
|
||||||
:data-home-target-offset="cell.targetOffset"
|
:data-home-target-offset="cell.targetOffset"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||||
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@remove="removeHomeApp(cell.app.id)"
|
@remove="removeHomeApp(cell.app.id)"
|
||||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||||
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-index="cell.sourceIndex"
|
:data-home-index="cell.sourceIndex"
|
||||||
:data-home-target-offset="cell.targetOffset"
|
:data-home-target-offset="cell.targetOffset"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:folder="cell.folder"
|
:folder="cell.folder"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@open="openFolder(cell.folder.id)"
|
@open="openFolder(cell.folder.id)"
|
||||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||||
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport defer to="#phone-home-drag-portal">
|
||||||
|
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<Transition name="edit-done">
|
<Transition name="edit-done">
|
||||||
<k-glass
|
<k-glass
|
||||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||||
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-item-key="`app-${slot.app.id}`"
|
:data-home-item-key="`app-${slot.app.id}`"
|
||||||
:data-home-index="slot.index"
|
:data-home-index="slot.index"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('dock', slot.index)"
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@remove="removeHomeApp(slot.app.id)"
|
@remove="removeHomeApp(slot.app.id)"
|
||||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||||
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-item-key="slot.folder.id"
|
:data-home-item-key="slot.folder.id"
|
||||||
:data-home-index="slot.index"
|
:data-home-index="slot.index"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:folder="slot.folder"
|
:folder="slot.folder"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('dock', slot.index)"
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@open="openFolder(slot.folder.id)"
|
@open="openFolder(slot.folder.id)"
|
||||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
|
|||||||
)
|
)
|
||||||
const catalog = computed(() =>
|
const catalog = computed(() =>
|
||||||
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
||||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
|
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ const dailyCandidates = computed(() =>
|
|||||||
PHONE_APPS.filter(
|
PHONE_APPS.filter(
|
||||||
(app): app is LaunchablePhoneAppDefinition =>
|
(app): app is LaunchablePhoneAppDefinition =>
|
||||||
isLaunchablePhoneApp(app) &&
|
isLaunchablePhoneApp(app) &&
|
||||||
|
!app.adminOnly &&
|
||||||
!isExternalPhoneApp(app) &&
|
!isExternalPhoneApp(app) &&
|
||||||
app.id !== 'app-store' &&
|
app.id !== 'app-store' &&
|
||||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||||
|
|||||||
@@ -48,13 +48,11 @@ describe('AppStoreDetail contract', () => {
|
|||||||
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
|
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
|
||||||
expect(previewSource).toContain(':src="previewImage"')
|
expect(previewSource).toContain(':src="previewImage"')
|
||||||
expect(previewSource).toContain('store-detail-preview__screenshot')
|
expect(previewSource).toContain('store-detail-preview__screenshot')
|
||||||
expect(previewSource).not.toContain("visual.scene ===")
|
expect(previewSource).not.toContain('visual.scene ===')
|
||||||
expect(source).toContain('getAppStorePreviewVisual')
|
expect(source).toContain('getAppStorePreviewVisual')
|
||||||
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
|
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
|
||||||
expect(previewSource).toContain(':src="iconImage"')
|
expect(previewSource).toContain(':src="iconImage"')
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/)
|
||||||
/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/,
|
|
||||||
)
|
|
||||||
expect(previewSource).toContain('screen === 3')
|
expect(previewSource).toContain('screen === 3')
|
||||||
expect(previewSource).toContain('screen === 4')
|
expect(previewSource).toContain('screen === 4')
|
||||||
expect(previewSource).toContain('store-detail-preview__details')
|
expect(previewSource).toContain('store-detail-preview__details')
|
||||||
@@ -71,6 +69,16 @@ describe('AppStoreDetail contract', () => {
|
|||||||
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
|
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
|
||||||
expect(source).toContain('details.previousPreview')
|
expect(source).toContain('details.previousPreview')
|
||||||
expect(source).toContain('details.nextPreview')
|
expect(source).toContain('details.nextPreview')
|
||||||
expect(source).toContain('.store-detail__toolbar button:hover')
|
expect(source).toContain('.store-detail__toolbar-button:hover')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses shared liquid glass for toolbar and preview controls', () => {
|
||||||
|
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||||
|
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(4)
|
||||||
|
expect(source).toContain('class="store-detail__toolbar-button"')
|
||||||
|
expect(source).toContain('class="store-detail__preview-control"')
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.store-detail__preview-control\s*\{[^}]*width:\s*var\(--sky-touch-target\);[^}]*height:\s*var\(--sky-touch-target\);[^}]*border-radius:\s*50%;/s,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { ChevronLeft, ChevronRight, Share2, Star } from 'lucide-vue-next'
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Share2,
|
|
||||||
Star,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
|
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
|
||||||
import { usePhoneStore } from '@/stores/phone'
|
import { usePhoneStore } from '@/stores/phone'
|
||||||
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
||||||
|
import { SkyButton } from '@/ui'
|
||||||
import { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
|
import { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
|
||||||
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
|
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
|
||||||
|
|
||||||
@@ -126,20 +122,28 @@ function updateActivePreview(): void {
|
|||||||
<template>
|
<template>
|
||||||
<section class="store-detail" :style="detailStyle">
|
<section class="store-detail" :style="detailStyle">
|
||||||
<header class="store-detail__toolbar">
|
<header class="store-detail__toolbar">
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
|
class="store-detail__toolbar-button"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="phone.t('Common.back')"
|
:aria-label="phone.t('Common.back')"
|
||||||
@click="emit('back')"
|
@click="emit('back')"
|
||||||
>
|
>
|
||||||
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
|
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
|
||||||
</button>
|
</SkyButton>
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
|
class="store-detail__toolbar-button"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="phone.t('Apps.appStore.details.share')"
|
:aria-label="phone.t('Apps.appStore.details.share')"
|
||||||
@click="emit('share')"
|
@click="emit('share')"
|
||||||
>
|
>
|
||||||
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
|
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
|
||||||
</button>
|
</SkyButton>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="store-detail__hero">
|
<section class="store-detail__hero">
|
||||||
@@ -212,22 +216,30 @@ function updateActivePreview(): void {
|
|||||||
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
|
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
|
||||||
<div class="store-detail__preview-navigation">
|
<div class="store-detail__preview-navigation">
|
||||||
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
|
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
|
class="store-detail__preview-control"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
|
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
|
||||||
:disabled="activePreviewIndex === 0"
|
:disabled="activePreviewIndex === 0"
|
||||||
@click="scrollToPreview(activePreviewIndex - 1)"
|
@click="scrollToPreview(activePreviewIndex - 1)"
|
||||||
>
|
>
|
||||||
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
|
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||||
</button>
|
</SkyButton>
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
|
class="store-detail__preview-control"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
|
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
|
||||||
:disabled="activePreviewIndex === previewCount - 1"
|
:disabled="activePreviewIndex === previewCount - 1"
|
||||||
@click="scrollToPreview(activePreviewIndex + 1)"
|
@click="scrollToPreview(activePreviewIndex + 1)"
|
||||||
>
|
>
|
||||||
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
|
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||||
</button>
|
</SkyButton>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
@@ -286,7 +298,7 @@ function updateActivePreview(): void {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail__toolbar button {
|
.store-detail__toolbar-button {
|
||||||
width: var(--sky-touch-target);
|
width: var(--sky-touch-target);
|
||||||
height: var(--sky-touch-target);
|
height: var(--sky-touch-target);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -294,7 +306,6 @@ function updateActivePreview(): void {
|
|||||||
border: 1px solid var(--sky-hairline);
|
border: 1px solid var(--sky-hairline);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: var(--sky-text);
|
color: var(--sky-text);
|
||||||
background: var(--sky-surface-variant);
|
|
||||||
transition:
|
transition:
|
||||||
background-color 100ms ease,
|
background-color 100ms ease,
|
||||||
border-color 100ms ease,
|
border-color 100ms ease,
|
||||||
@@ -302,7 +313,7 @@ function updateActivePreview(): void {
|
|||||||
transform 100ms ease;
|
transform 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail__toolbar button:active {
|
.store-detail__toolbar-button:active {
|
||||||
transform: scale(0.94);
|
transform: scale(0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,27 +492,28 @@ function updateActivePreview(): void {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail__preview-navigation button {
|
.store-detail__preview-control {
|
||||||
width: 30px;
|
width: var(--sky-touch-target);
|
||||||
height: 30px;
|
height: var(--sky-touch-target);
|
||||||
|
min-width: var(--sky-touch-target);
|
||||||
|
min-height: var(--sky-touch-target);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--sky-hairline);
|
border: 1px solid var(--sky-hairline);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--sky-text);
|
color: var(--sky-text);
|
||||||
background: var(--sky-surface-variant);
|
|
||||||
transition:
|
transition:
|
||||||
background-color 100ms ease,
|
background-color 100ms ease,
|
||||||
color 100ms ease,
|
color 100ms ease,
|
||||||
transform 100ms ease;
|
transform 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail__preview-navigation button:disabled {
|
.store-detail__preview-control:disabled {
|
||||||
opacity: 0.34;
|
opacity: 0.34;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail__preview-navigation button:active:not(:disabled) {
|
.store-detail__preview-control:active:not(:disabled) {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,16 +532,14 @@ function updateActivePreview(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.store-detail__toolbar button:hover {
|
.store-detail__toolbar-button:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
background: var(--sky-surface-tint);
|
|
||||||
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail__preview-navigation button:hover:not(:disabled) {
|
.store-detail__preview-control:hover:not(:disabled) {
|
||||||
color: var(--sky-app-accent);
|
color: var(--sky-app-accent);
|
||||||
background: var(--sky-surface-tint);
|
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #f6f7f9;
|
color: #f6f7f9;
|
||||||
background: #07090c;
|
background: #07090c;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.billing-app--light {
|
.billing-app--light {
|
||||||
--billing-border: rgb(15 23 42 / 10%);
|
--billing-border: rgb(15 23 42 / 10%);
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ describe('CalculatorApp layout contract', () => {
|
|||||||
)
|
)
|
||||||
expect(source).toContain('calculator-history__nav-button--edit')
|
expect(source).toContain('calculator-history__nav-button--edit')
|
||||||
expect(source).toContain('calculator-history__nav-button--close')
|
expect(source).toContain('calculator-history__nav-button--close')
|
||||||
expect(source).toContain('background: rgb(255 255 255 / 9%);')
|
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
|
||||||
|
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
|
||||||
expect(source).toContain('-webkit-backdrop-filter: none;')
|
expect(source).toContain('-webkit-backdrop-filter: none;')
|
||||||
expect(source).toContain('backdrop-filter: none;')
|
expect(source).toContain('backdrop-filter: none;')
|
||||||
expect(source).toContain('min-width: 106px;')
|
expect(source).toContain('min-width: 106px;')
|
||||||
@@ -62,7 +63,7 @@ describe('CalculatorApp layout contract', () => {
|
|||||||
expect(source).toContain('place-items: center;')
|
expect(source).toContain('place-items: center;')
|
||||||
expect(source).toContain('padding: 0;')
|
expect(source).toContain('padding: 0;')
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
|
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
|
||||||
)
|
)
|
||||||
expect(source).not.toContain('class="calculator-history__edit"')
|
expect(source).not.toContain('class="calculator-history__edit"')
|
||||||
expect(source).not.toContain('class="calculator-history__close"')
|
expect(source).not.toContain('class="calculator-history__close"')
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ watch([() => calculator.display, expression], async () => {
|
|||||||
>
|
>
|
||||||
<template #left>
|
<template #left>
|
||||||
<SkyButton
|
<SkyButton
|
||||||
|
glass
|
||||||
class="calculator-history__nav-button calculator-history__nav-button--edit"
|
class="calculator-history__nav-button calculator-history__nav-button--edit"
|
||||||
inline
|
inline
|
||||||
rounded
|
rounded
|
||||||
@@ -397,6 +398,7 @@ watch([() => calculator.display, expression], async () => {
|
|||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
<SkyButton
|
<SkyButton
|
||||||
|
glass
|
||||||
class="calculator-history__nav-button calculator-history__nav-button--close"
|
class="calculator-history__nav-button calculator-history__nav-button--close"
|
||||||
icon-only
|
icon-only
|
||||||
rounded
|
rounded
|
||||||
@@ -721,16 +723,15 @@ watch([() => calculator.display, expression], async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calculator-history__navbar :deep(.calculator-history__nav-button) {
|
.calculator-history__navbar :deep(.calculator-history__nav-button) {
|
||||||
|
--sky-glass: rgb(44 44 46 / 62%);
|
||||||
|
--sky-hairline: rgb(255 255 255 / 14%);
|
||||||
height: 44px;
|
height: 44px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
background: rgb(255 255 255 / 9%);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calculator-history__navbar :deep(.calculator-history__nav-button:active) {
|
.calculator-history__navbar :deep(.calculator-history__nav-button:active) {
|
||||||
background: rgb(255 255 255 / 14%);
|
filter: brightness(0.94);
|
||||||
filter: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
|
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
|
||||||
@@ -757,9 +758,8 @@ watch([() => calculator.display, expression], async () => {
|
|||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.calculator-history__navbar
|
.calculator-history__navbar
|
||||||
:deep(.calculator-history__nav-button:hover:not(:disabled)) {
|
:deep(.calculator-history__nav-button:hover:not(:disabled)) {
|
||||||
background: rgb(255 255 255 / 15%);
|
|
||||||
transform: none;
|
transform: none;
|
||||||
filter: none;
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1156,13 +1156,7 @@ onMounted(async () => {
|
|||||||
padding: 47px 0 24px;
|
padding: 47px 0 24px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
Inter,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'SF Pro Display',
|
|
||||||
system-ui,
|
|
||||||
sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar--light {
|
.calendar--light {
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ const cameraAnimations = readFileSync(
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe('Camera app controls', () => {
|
describe('Camera app controls', () => {
|
||||||
|
it('uses shared liquid glass for camera interaction buttons', () => {
|
||||||
|
expect(cameraView.match(/variant="glass"/g)).toHaveLength(5)
|
||||||
|
expect(cameraView).toMatch(
|
||||||
|
/<sky-glass\s+component="button"\s+class="camera-latest"/,
|
||||||
|
)
|
||||||
|
expect(cameraView).not.toContain('variant="neutral"')
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the Sky UI moving segment for photo and video modes', () => {
|
it('uses the Sky UI moving segment for photo and video modes', () => {
|
||||||
expect(cameraView).toContain('SkySegmented')
|
expect(cameraView).toContain('SkySegmented')
|
||||||
expect(cameraView).toContain(':active-index="mode === \'photo\' ? 0 : 1"')
|
expect(cameraView).toContain(':active-index="mode === \'photo\' ? 0 : 1"')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SkyFab, SkyAppPage } from '@/ui'
|
import { SkyAppPage, SkyButton, SkyFab, SkyGlass } from '@/ui'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Images,
|
Images,
|
||||||
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
|
|||||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await nuiCall('media:requestUpload', {
|
console.info('[Sky Phone Media] Camera requested a photo upload.', {
|
||||||
|
correlationId: id,
|
||||||
|
})
|
||||||
|
const response = await nuiCall('media:requestUpload', {
|
||||||
correlationId: id,
|
correlationId: id,
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
})
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[Sky Phone Media] Photo upload request was rejected.', {
|
||||||
|
correlationId: id,
|
||||||
|
error: response.error,
|
||||||
|
})
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
correlationId: id,
|
||||||
|
error: response.error ?? 'request_failed',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
type: 'media:uploadResult',
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRecording(): void {
|
function startRecording(): void {
|
||||||
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
|
|||||||
} else if (message.type === 'media:uploadResult') {
|
} else if (message.type === 'media:uploadResult') {
|
||||||
const result = message.data as UploadResult
|
const result = message.data as UploadResult
|
||||||
if (!result?.correlationId) return
|
if (!result?.correlationId) return
|
||||||
|
console.info('[Sky Phone Media] Camera received an upload result.', {
|
||||||
|
correlationId: result.correlationId,
|
||||||
|
error: result.error,
|
||||||
|
success: result.success,
|
||||||
|
})
|
||||||
savingVideo.value = false
|
savingVideo.value = false
|
||||||
if (result.success && result.media) {
|
if (result.success && result.media) {
|
||||||
latestMedia.value = result.media
|
latestMedia.value = result.media
|
||||||
@@ -421,13 +446,11 @@ onMounted(() => {
|
|||||||
window.addEventListener('keyup', onKeyup)
|
window.addEventListener('keyup', onKeyup)
|
||||||
window.addEventListener('message', onMessage)
|
window.addEventListener('message', onMessage)
|
||||||
void nuiCall('camera:setActive', { active: true })
|
void nuiCall('camera:setActive', { active: true })
|
||||||
void nuiCall<MediaConfig>('media:config').then(
|
void nuiCall<MediaConfig>('media:config').then((response) => {
|
||||||
(response) => {
|
if (response.success && response.data?.videoBitrateKbps) {
|
||||||
if (response.success && response.data?.videoBitrateKbps) {
|
videoBitrateKbps.value = response.data.videoBitrateKbps
|
||||||
videoBitrateKbps.value = response.data.videoBitrateKbps
|
}
|
||||||
}
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
void loadLatest()
|
void loadLatest()
|
||||||
startGameView()
|
startGameView()
|
||||||
})
|
})
|
||||||
@@ -486,22 +509,24 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<header class="camera-topbar">
|
<header class="camera-topbar">
|
||||||
<div class="camera-topbar-actions">
|
<div class="camera-topbar-actions">
|
||||||
<button
|
<sky-fab
|
||||||
v-if="requestedMessageMedia"
|
v-if="requestedMessageMedia"
|
||||||
class="camera-picker-back"
|
component="button"
|
||||||
|
class="camera-control camera-picker-back"
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="glass"
|
||||||
:aria-label="phone.t('Common.back')"
|
:aria-label="phone.t('Common.back')"
|
||||||
@click="cancelMediaSelection"
|
@click="cancelMediaSelection"
|
||||||
>
|
>
|
||||||
<ArrowLeft :size="20" />
|
<template #icon><ArrowLeft :size="20" /></template>
|
||||||
</button>
|
</sky-fab>
|
||||||
<sky-fab
|
<sky-fab
|
||||||
v-else
|
v-else
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
class="camera-control"
|
class="camera-control"
|
||||||
:class="{ 'camera-control--flash-active': flashEnabled }"
|
:class="{ 'camera-control--flash-active': flashEnabled }"
|
||||||
variant="neutral"
|
variant="glass"
|
||||||
:aria-label="phone.t('Apps.camera.flash')"
|
:aria-label="phone.t('Apps.camera.flash')"
|
||||||
@click="toggleFlash"
|
@click="toggleFlash"
|
||||||
>
|
>
|
||||||
@@ -516,7 +541,7 @@ onBeforeUnmount(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="camera-control"
|
class="camera-control"
|
||||||
:class="{ 'camera-control--danger': !microphoneEnabled }"
|
:class="{ 'camera-control--danger': !microphoneEnabled }"
|
||||||
variant="neutral"
|
variant="glass"
|
||||||
:disabled="recording || savingVideo"
|
:disabled="recording || savingVideo"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
phone.t(
|
phone.t(
|
||||||
@@ -543,8 +568,10 @@ onBeforeUnmount(() => {
|
|||||||
<span v-else-if="pendingCount" class="camera-upload-pill">
|
<span v-else-if="pendingCount" class="camera-upload-pill">
|
||||||
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
|
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<SkyButton
|
||||||
v-else
|
v-else
|
||||||
|
glass
|
||||||
|
rounded
|
||||||
class="camera-focus-pill camera-lock-control"
|
class="camera-focus-pill camera-lock-control"
|
||||||
:class="{ 'camera-lock-control--active': cameraLocked }"
|
:class="{ 'camera-lock-control--active': cameraLocked }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -561,12 +588,12 @@ onBeforeUnmount(() => {
|
|||||||
<LockKeyhole v-if="cameraLocked" :size="12" />
|
<LockKeyhole v-if="cameraLocked" :size="12" />
|
||||||
<LockOpen v-else :size="12" />
|
<LockOpen v-else :size="12" />
|
||||||
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
|
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
|
||||||
</button>
|
</SkyButton>
|
||||||
<sky-fab
|
<sky-fab
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
class="camera-control"
|
class="camera-control"
|
||||||
variant="neutral"
|
variant="glass"
|
||||||
:disabled="recording || savingVideo"
|
:disabled="recording || savingVideo"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
phone.t(
|
phone.t(
|
||||||
@@ -588,9 +615,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="camera-zoom-control">
|
<div class="camera-zoom-control">
|
||||||
<div class="camera-zoom-row">
|
<div class="camera-zoom-row">
|
||||||
<button
|
<SkyButton
|
||||||
v-for="zoom in zoomLevels"
|
v-for="zoom in zoomLevels"
|
||||||
:key="zoom"
|
:key="zoom"
|
||||||
|
glass
|
||||||
|
rounded
|
||||||
class="camera-zoom-pill"
|
class="camera-zoom-pill"
|
||||||
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
|
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -599,13 +628,14 @@ onBeforeUnmount(() => {
|
|||||||
@click="setZoom(zoom)"
|
@click="setZoom(zoom)"
|
||||||
>
|
>
|
||||||
{{ zoom }}x
|
{{ zoom }}x
|
||||||
</button>
|
</SkyButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="camera-controls">
|
<footer class="camera-controls">
|
||||||
<div class="camera-capture-row">
|
<div class="camera-capture-row">
|
||||||
<button
|
<sky-glass
|
||||||
|
component="button"
|
||||||
class="camera-latest"
|
class="camera-latest"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="phone.t('Apps.camera.openGallery')"
|
:aria-label="phone.t('Apps.camera.openGallery')"
|
||||||
@@ -625,7 +655,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<Video v-else-if="latestMedia" :size="22" />
|
<Video v-else-if="latestMedia" :size="22" />
|
||||||
<Images v-else :size="22" />
|
<Images v-else :size="22" />
|
||||||
</button>
|
</sky-glass>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="camera-shutter"
|
class="camera-shutter"
|
||||||
@@ -650,7 +680,7 @@ onBeforeUnmount(() => {
|
|||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
class="camera-control camera-selfie"
|
class="camera-control camera-selfie"
|
||||||
variant="neutral"
|
variant="glass"
|
||||||
:aria-label="phone.t('Apps.camera.flip')"
|
:aria-label="phone.t('Apps.camera.flip')"
|
||||||
@click="toggleFacing"
|
@click="toggleFacing"
|
||||||
>
|
>
|
||||||
@@ -799,8 +829,9 @@ onBeforeUnmount(() => {
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
.camera-control {
|
.camera-control {
|
||||||
--sky-glass-solid: rgb(28 28 30 / 80%);
|
--sky-glass: rgb(28 28 30 / 58%);
|
||||||
color: rgb(255 255 255 / 86%);
|
--sky-hairline: rgb(255 255 255 / 16%);
|
||||||
|
color: rgb(255 255 255 / 92%) !important;
|
||||||
}
|
}
|
||||||
.camera-control--flash-active {
|
.camera-control--flash-active {
|
||||||
color: #ffd60a !important;
|
color: #ffd60a !important;
|
||||||
@@ -811,13 +842,6 @@ onBeforeUnmount(() => {
|
|||||||
.camera-picker-back {
|
.camera-picker-back {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: #1c1c1ecc;
|
|
||||||
color: #fff;
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
}
|
||||||
.camera-control svg {
|
.camera-control svg {
|
||||||
width: 21px;
|
width: 21px;
|
||||||
@@ -831,7 +855,7 @@ onBeforeUnmount(() => {
|
|||||||
.camera-page--landscape .camera-latest svg {
|
.camera-page--landscape .camera-latest svg {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
.camera-focus-pill,
|
.camera-focus-pill:not(.sky-button--glass),
|
||||||
.camera-upload-pill {
|
.camera-upload-pill {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
@@ -853,7 +877,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
.camera-lock-control {
|
.camera-lock-control {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 0;
|
padding: 7px 10px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -902,27 +926,20 @@ onBeforeUnmount(() => {
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
bottom: 196px;
|
bottom: 196px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 140px;
|
width: auto;
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(18 18 20 / 72%);
|
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
-webkit-backdrop-filter: blur(18px);
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
.camera-zoom-row {
|
.camera-zoom-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.camera-zoom-pill {
|
.camera-zoom-pill {
|
||||||
width: 30px;
|
width: 44px;
|
||||||
height: 26px;
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: transparent;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -933,9 +950,6 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow 0.2s ease;
|
box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
.camera-zoom-pill.active {
|
.camera-zoom-pill.active {
|
||||||
border-color: transparent;
|
|
||||||
background: rgb(44 44 46 / 88%);
|
|
||||||
box-shadow: 0 8px 16px rgb(0 0 0 / 30%);
|
|
||||||
color: #ffd60a;
|
color: #ffd60a;
|
||||||
}
|
}
|
||||||
.camera-controls {
|
.camera-controls {
|
||||||
@@ -956,15 +970,17 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0 24px 32px;
|
padding: 0 24px 32px;
|
||||||
}
|
}
|
||||||
.camera-latest {
|
.camera-latest {
|
||||||
|
--sky-glass: rgb(28 28 30 / 58%);
|
||||||
|
--sky-hairline: rgb(255 255 255 / 16%);
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #111b;
|
background: var(--sky-glass);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
box-shadow: var(--sky-shadow-glass);
|
||||||
}
|
}
|
||||||
.camera-selfie {
|
.camera-selfie {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
|||||||
@@ -2195,7 +2195,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #151613;
|
background: #151613;
|
||||||
color: #f8f8f4;
|
color: #f8f8f4;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.citymarkt--light {
|
.citymarkt--light {
|
||||||
--ink: #fff;
|
--ink: #fff;
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
|
|||||||
expect(source).not.toContain('class="auth-panel__heading"')
|
expect(source).not.toContain('class="auth-panel__heading"')
|
||||||
expect(source).not.toContain('class="auth-action-dock"')
|
expect(source).not.toContain('class="auth-action-dock"')
|
||||||
expect(source).not.toContain('class="password-rules"')
|
expect(source).not.toContain('class="password-rules"')
|
||||||
|
expect(source).toContain('class="auth-password-hint"')
|
||||||
|
expect(source).toContain("t('auth.ruleSpecial')")
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
|
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ onUnmounted(() => {
|
|||||||
:size="18"
|
:size="18"
|
||||||
/></button></template
|
/></button></template
|
||||||
></SkyField>
|
></SkyField>
|
||||||
|
<p v-if="authMode === 'register'" class="auth-password-hint">
|
||||||
|
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
|
||||||
|
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
|
||||||
|
</p>
|
||||||
<p v-if="formError" class="error">{{ formError }}</p>
|
<p v-if="formError" class="error">{{ formError }}</p>
|
||||||
<SkyButton block large class="auth-submit" type="submit">{{
|
<SkyButton block large class="auth-submit" type="submit">{{
|
||||||
t(
|
t(
|
||||||
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
|
|||||||
.auth-form {
|
.auth-form {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.auth-password-hint {
|
||||||
|
margin: -3px 4px 0;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
.auth-account {
|
.auth-account {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
||||||
|
|||||||
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
|
|||||||
expect(newChat).toContain('class="dc-recipient-field"')
|
expect(newChat).toContain('class="dc-recipient-field"')
|
||||||
expect(newChat).toContain('layout="inline"')
|
expect(newChat).toContain('layout="inline"')
|
||||||
expect(newChat).toContain('class="dc-new-chat-contacts"')
|
expect(newChat).toContain('class="dc-new-chat-contacts"')
|
||||||
|
expect(newChat).toContain("{{ t('newChatBody') }}")
|
||||||
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
|
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
|
||||||
expect(newChat).not.toContain('class="dc-hero"')
|
expect(newChat).not.toContain('class="dc-hero"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects the current profile identifiers before opening confirmation', () => {
|
||||||
|
expect(source).toContain('darkchat.profile?.darkId')
|
||||||
|
expect(source).toContain('darkchat.profile?.inviteCode')
|
||||||
|
expect(source).toContain("showToast(errorText('self_chat'))")
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the search visually compact without shrinking its wrapper', () => {
|
it('keeps the search visually compact without shrinking its wrapper', () => {
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
|
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
|
||||||
|
|||||||
@@ -427,6 +427,20 @@ function closeReport(): void {
|
|||||||
function requestStart(value = identifier.value): void {
|
function requestStart(value = identifier.value): void {
|
||||||
const clean = value.trim()
|
const clean = value.trim()
|
||||||
if (!clean) return
|
if (!clean) return
|
||||||
|
const ownIdentifiers = [
|
||||||
|
darkchat.profile?.darkId,
|
||||||
|
darkchat.profile?.inviteCode,
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
ownIdentifiers.some(
|
||||||
|
(ownIdentifier) =>
|
||||||
|
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
|
||||||
|
clean.toLocaleLowerCase(phone.lang),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
showToast(errorText('self_chat'))
|
||||||
|
return
|
||||||
|
}
|
||||||
pendingIdentifier.value = clean
|
pendingIdentifier.value = clean
|
||||||
safetyOpen.value = true
|
safetyOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
|
|||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dc-new-chat-intro {
|
||||||
|
margin: 2px 2px 12px;
|
||||||
|
color: var(--dc-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.dc-recipient-field,
|
.dc-recipient-field,
|
||||||
.dc-new-chat-contacts {
|
.dc-new-chat-contacts {
|
||||||
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
||||||
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</SkyNavbar>
|
</SkyNavbar>
|
||||||
<SkyScrollArea padded class="dc-new-chat-scroll">
|
<SkyScrollArea padded class="dc-new-chat-scroll">
|
||||||
|
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
|
||||||
<SkyList class="dc-recipient-field" density="compact" flush>
|
<SkyList class="dc-recipient-field" density="compact" flush>
|
||||||
<SkyField
|
<SkyField
|
||||||
:label="t('to')"
|
:label="t('to')"
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ describe('FeatherApp Sky UI contract', () => {
|
|||||||
expect(source).toContain('with-tabbar')
|
expect(source).toContain('with-tabbar')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses shared liquid glass for the floating compose action', () => {
|
||||||
|
expect(source).toMatch(
|
||||||
|
/<SkyFab[\s\S]*?class="feather-compose-fab"[\s\S]*?variant="glass"/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
|
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
|
||||||
expect(postCard).toContain(
|
expect(postCard).toContain(
|
||||||
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
|
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
|
||||||
@@ -143,7 +149,9 @@ describe('FeatherApp Sky UI contract', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps profile suggestion content styles off the follow button', () => {
|
it('keeps profile suggestion content styles off the follow button', () => {
|
||||||
expect(source).toContain('class="feather-profile-suggestion__profile"')
|
expect(source).toContain(
|
||||||
|
'class="feather-profile-suggestion__profile"',
|
||||||
|
)
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
|
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
|
||||||
)
|
)
|
||||||
@@ -193,9 +201,7 @@ describe('FeatherApp Sky UI contract', () => {
|
|||||||
expect(source).toContain('<SkyScrollRail')
|
expect(source).toContain('<SkyScrollRail')
|
||||||
expect(source).toContain('class="feather-profile-suggestions__rail"')
|
expect(source).toContain('class="feather-profile-suggestions__rail"')
|
||||||
expect(source).toContain(':label="t(\'people\')"')
|
expect(source).toContain(':label="t(\'people\')"')
|
||||||
expect(source).toContain(
|
expect(source).toContain('class="feather-profile-suggestion__profile"')
|
||||||
'class="feather-profile-suggestion__profile"',
|
|
||||||
)
|
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
|
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2174,6 +2174,7 @@ onMounted(async () => {
|
|||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
class="feather-compose-fab"
|
class="feather-compose-fab"
|
||||||
|
variant="glass"
|
||||||
:aria-label="t('newPost')"
|
:aria-label="t('newPost')"
|
||||||
@click="openComposer()"
|
@click="openComposer()"
|
||||||
>
|
>
|
||||||
@@ -2331,7 +2332,8 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<template v-if="mediaPreview.items.length > 1">
|
<template v-if="mediaPreview.items.length > 1">
|
||||||
<SkyButton
|
<SkyButton
|
||||||
clear
|
glass
|
||||||
|
icon-only
|
||||||
rounded
|
rounded
|
||||||
class="feather-media-preview__arrow feather-media-preview__arrow--left"
|
class="feather-media-preview__arrow feather-media-preview__arrow--left"
|
||||||
:aria-label="t('previousImage')"
|
:aria-label="t('previousImage')"
|
||||||
@@ -2340,7 +2342,8 @@ onMounted(async () => {
|
|||||||
<ChevronLeft :size="20" :stroke-width="2.8" />
|
<ChevronLeft :size="20" :stroke-width="2.8" />
|
||||||
</SkyButton>
|
</SkyButton>
|
||||||
<SkyButton
|
<SkyButton
|
||||||
clear
|
glass
|
||||||
|
icon-only
|
||||||
rounded
|
rounded
|
||||||
class="feather-media-preview__arrow feather-media-preview__arrow--right"
|
class="feather-media-preview__arrow feather-media-preview__arrow--right"
|
||||||
:aria-label="t('nextImage')"
|
:aria-label="t('nextImage')"
|
||||||
@@ -2389,22 +2392,15 @@ onMounted(async () => {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.feather-media-preview__arrow {
|
.feather-media-preview__arrow {
|
||||||
--sky-app-accent: rgb(10 14 20 / 88%);
|
|
||||||
--sky-button-text: #fff;
|
--sky-button-text: #fff;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 34px !important;
|
width: 44px !important;
|
||||||
min-width: 34px;
|
min-width: 44px;
|
||||||
height: 34px;
|
height: 44px;
|
||||||
min-height: 34px;
|
min-height: 44px;
|
||||||
border: 1.5px solid rgb(255 255 255 / 58%);
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
background: rgb(10 14 20 / 88%) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 5px 16px rgb(0 0 0 / 58%),
|
|
||||||
inset 0 0 0 1px rgb(255 255 255 / 8%);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
.feather-media-preview__arrow--left {
|
.feather-media-preview__arrow--left {
|
||||||
@@ -3590,7 +3586,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #12171b !important;
|
background: #12171b !important;
|
||||||
color: #f7f8f4;
|
color: #f7f8f4;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.feather-app--active.feather-app--light {
|
.feather-app--active.feather-app--light {
|
||||||
--feather-panel: #f0f1ec;
|
--feather-panel: #f0f1ec;
|
||||||
@@ -4486,7 +4482,6 @@ onMounted(async () => {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
.feather-compose-fab {
|
.feather-compose-fab {
|
||||||
--sky-app-accent: #58a6ff;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
right: 14px;
|
right: 14px;
|
||||||
@@ -4494,23 +4489,14 @@ onMounted(async () => {
|
|||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
min-width: 46px;
|
min-width: 46px;
|
||||||
border: 1px solid color-mix(in srgb, var(--feather-blue) 55%, #fff);
|
color: var(--feather-blue);
|
||||||
color: #fff;
|
|
||||||
box-shadow:
|
|
||||||
0 9px 24px rgb(29 155 240 / 38%),
|
|
||||||
0 3px 8px rgb(0 0 0 / 22%),
|
|
||||||
inset 0 1px 0 rgb(255 255 255 / 32%);
|
|
||||||
transition:
|
transition:
|
||||||
transform 150ms ease,
|
transform 150ms ease,
|
||||||
box-shadow 150ms ease,
|
box-shadow 150ms ease,
|
||||||
filter 150ms ease;
|
filter 150ms ease;
|
||||||
}
|
}
|
||||||
.feather-compose-fab:active {
|
.feather-compose-fab:active {
|
||||||
filter: brightness(0.94);
|
|
||||||
transform: scale(0.94);
|
transform: scale(0.94);
|
||||||
box-shadow:
|
|
||||||
0 4px 12px rgb(29 155 240 / 28%),
|
|
||||||
inset 0 1px 0 rgb(255 255 255 / 22%);
|
|
||||||
}
|
}
|
||||||
.feather-navigation__badge-anchor b {
|
.feather-navigation__badge-anchor b {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -5415,7 +5401,6 @@ onMounted(async () => {
|
|||||||
.feather-edit__photo-actions :deep(.sky-button) {
|
.feather-edit__photo-actions :deep(.sky-button) {
|
||||||
border-color: var(--feather-blue);
|
border-color: var(--feather-blue);
|
||||||
}
|
}
|
||||||
.feather-compose-fab,
|
|
||||||
.feather-edit__avatar {
|
.feather-edit__avatar {
|
||||||
border-color: #70c5fa;
|
border-color: #70c5fa;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2037,14 +2037,17 @@ onBeforeUnmount(() => {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
type="button"
|
type="button"
|
||||||
class="flare-match-reveal__close"
|
class="flare-match-reveal__close"
|
||||||
:aria-label="phone.t('Common.close')"
|
:aria-label="phone.t('Common.close')"
|
||||||
@click="matchReveal = null"
|
@click="matchReveal = null"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</SkyButton>
|
||||||
<Flame class="flare-match-reveal__flame" fill="currentColor" />
|
<Flame class="flare-match-reveal__flame" fill="currentColor" />
|
||||||
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
|
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -3419,14 +3422,13 @@ onBeforeUnmount(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 24px;
|
||||||
right: 18px;
|
right: 18px;
|
||||||
width: 42px;
|
width: 44px;
|
||||||
height: 42px;
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: rgb(0 0 0 / 18%);
|
|
||||||
}
|
}
|
||||||
.flare-match-reveal__flame {
|
.flare-match-reveal__flame {
|
||||||
width: 58px;
|
width: 58px;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ import {
|
|||||||
SkyToggle,
|
SkyToggle,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import {
|
||||||
|
getPhoneOutputVolume,
|
||||||
|
subscribePhoneOutputVolume,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
|
|
||||||
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
|
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
|
||||||
type AuthMode = 'login' | 'register'
|
type AuthMode = 'login' | 'register'
|
||||||
@@ -194,6 +198,8 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
|
|||||||
let flipTokYoutubeApi: YouTubeApi | null = null
|
let flipTokYoutubeApi: YouTubeApi | null = null
|
||||||
let flipTokYoutubeOwner = ''
|
let flipTokYoutubeOwner = ''
|
||||||
let flipTokYoutubeVideoId = ''
|
let flipTokYoutubeVideoId = ''
|
||||||
|
let flipTokYoutubeVolume = 0
|
||||||
|
let removePhoneOutputVolumeListener: (() => void) | undefined
|
||||||
let observer: IntersectionObserver | null = null
|
let observer: IntersectionObserver | null = null
|
||||||
let videoClickTimer: number | null = null
|
let videoClickTimer: number | null = null
|
||||||
let likePulseTimer: number | null = null
|
let likePulseTimer: number | null = null
|
||||||
@@ -786,6 +792,7 @@ async function playFlipTokYoutube(
|
|||||||
seconds = 0,
|
seconds = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
flipTokYoutubeOwner = owner
|
flipTokYoutubeOwner = owner
|
||||||
|
flipTokYoutubeVolume = volume
|
||||||
try {
|
try {
|
||||||
const api = await loadYouTubeApi()
|
const api = await loadYouTubeApi()
|
||||||
flipTokYoutubeApi = api
|
flipTokYoutubeApi = api
|
||||||
@@ -794,7 +801,7 @@ async function playFlipTokYoutube(
|
|||||||
flipTokYoutubeVideoId = videoId
|
flipTokYoutubeVideoId = videoId
|
||||||
flipTokYoutubePlayer.loadVideoById(videoId)
|
flipTokYoutubePlayer.loadVideoById(videoId)
|
||||||
}
|
}
|
||||||
flipTokYoutubePlayer.setVolume(volume)
|
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
|
||||||
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
|
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
|
||||||
flipTokYoutubePlayer.playVideo()
|
flipTokYoutubePlayer.playVideo()
|
||||||
if (owner === 'composer') customMusicLoadFailed.value = false
|
if (owner === 'composer') customMusicLoadFailed.value = false
|
||||||
@@ -835,7 +842,7 @@ async function playFlipTokYoutube(
|
|||||||
},
|
},
|
||||||
onReady: (event) => {
|
onReady: (event) => {
|
||||||
flipTokYoutubePlayer = event.target
|
flipTokYoutubePlayer = event.target
|
||||||
event.target.setVolume(volume)
|
event.target.setVolume(volume * getPhoneOutputVolume())
|
||||||
event.target.seekTo(Math.max(0, seconds), true)
|
event.target.seekTo(Math.max(0, seconds), true)
|
||||||
event.target.playVideo()
|
event.target.playVideo()
|
||||||
if (owner === 'composer') customMusicLoadFailed.value = false
|
if (owner === 'composer') customMusicLoadFailed.value = false
|
||||||
@@ -1627,7 +1634,10 @@ watch(originalVolume, (value) => {
|
|||||||
|
|
||||||
watch(musicVolume, (value) => {
|
watch(musicVolume, (value) => {
|
||||||
if (composerMusic.value) composerMusic.value.volume = value / 100
|
if (composerMusic.value) composerMusic.value.volume = value / 100
|
||||||
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
|
if (flipTokYoutubeOwner === 'composer') {
|
||||||
|
flipTokYoutubeVolume = value
|
||||||
|
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1655,6 +1665,9 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
|
||||||
|
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
|
||||||
|
})
|
||||||
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
|
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
|
||||||
'fliptok:profile-avatar',
|
'fliptok:profile-avatar',
|
||||||
)
|
)
|
||||||
@@ -1731,6 +1744,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
removePhoneOutputVolumeListener?.()
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
|
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
|
||||||
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
|
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
|
||||||
@@ -2408,7 +2422,10 @@ onBeforeUnmount(() => {
|
|||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<template v-if="selectedMediaItems.length > 1">
|
<template v-if="selectedMediaItems.length > 1">
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
type="button"
|
type="button"
|
||||||
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
|
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
|
||||||
:disabled="composerPhotoIndex === 0"
|
:disabled="composerPhotoIndex === 0"
|
||||||
@@ -2416,8 +2433,11 @@ onBeforeUnmount(() => {
|
|||||||
@click="moveComposerPhoto(-1)"
|
@click="moveComposerPhoto(-1)"
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</button>
|
</SkyButton>
|
||||||
<button
|
<SkyButton
|
||||||
|
glass
|
||||||
|
icon-only
|
||||||
|
rounded
|
||||||
type="button"
|
type="button"
|
||||||
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
|
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
|
||||||
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
|
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
|
||||||
@@ -2425,7 +2445,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="moveComposerPhoto(1)"
|
@click="moveComposerPhoto(1)"
|
||||||
>
|
>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</button>
|
</SkyButton>
|
||||||
<span class="compose-photo-preview__count">
|
<span class="compose-photo-preview__count">
|
||||||
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
|
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
|
||||||
</span>
|
</span>
|
||||||
@@ -3375,7 +3395,7 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.state,
|
.state,
|
||||||
.light-empty,
|
.light-empty,
|
||||||
@@ -5872,12 +5892,7 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgb(255 255 255 / 22%);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(14 14 16 / 58%);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 4px 14px rgb(0 0 0 / 24%);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user