mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 17:59:02 +00:00
Compare commits
13 Commits
0.1.0
..
feat/skypic
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a358ddd0 | |||
| 4b65d17acc | |||
| 691ba65559 | |||
| 5b5e9fe914 | |||
| 14d5304b10 | |||
| daa67cfb1e | |||
| 47c4d8c1d7 | |||
| 12031a9c2e | |||
| 3eae2b8d30 | |||
| 734f4651cb | |||
| 98373e8bd8 | |||
| 64a7bc78c6 | |||
| 7985ca08d9 |
@@ -87,9 +87,9 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| --- | --- |
|
||||
| **Frameworks** | ESX Legacy, QBCore, Qbox |
|
||||
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Calls** | PMA Voice, SaltyChat |
|
||||
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||
| **Housing** | ESX Property, qbx_properties |
|
||||
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
||||
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||
| **Languages** | English, German |
|
||||
@@ -140,6 +140,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
|
||||
Phone calls support:
|
||||
|
||||
- YACA
|
||||
- PMA Voice
|
||||
- SaltyChat
|
||||
|
||||
@@ -248,6 +249,11 @@ When enabled, Sky Phone prints debug and informational messages. Warnings and er
|
||||
|
||||
The short LB Phone detection notice also remains visible when debug mode is disabled.
|
||||
|
||||
On every resource start, Sky Phone compares the `version` in `fxmanifest.lua` with the tag of the
|
||||
latest published [GitHub release](https://github.com/sky-systems/sky_phone/releases/latest). The
|
||||
server console reports whether the installed version is current and shows the release link when an
|
||||
update is available. A failed GitHub request is reported but does not prevent the phone from starting.
|
||||
|
||||
## Security values
|
||||
|
||||
Sky Phone ships with stable generated defaults in `Config.Server`:
|
||||
@@ -433,10 +439,11 @@ Config.Calls.VoiceProvider = "pma"
|
||||
|
||||
Supported values:
|
||||
|
||||
- `yaca` or `yaca-voice`
|
||||
- `pma` or `pma-voice`
|
||||
- `saltychat` or `salty`
|
||||
|
||||
SaltyChat supports the provider-backed call speaker feature. PMA Voice keeps the speaker option unavailable.
|
||||
YACA supports calls, payphone calls, provider-backed speaker mode, and real microphone mute. SaltyChat supports provider-backed speaker mode. PMA Voice keeps speaker and mute controls unavailable.
|
||||
|
||||
### Radio
|
||||
|
||||
@@ -523,7 +530,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
||||
|
||||
### Housing
|
||||
|
||||
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
|
||||
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
|
||||
|
||||
### Companies
|
||||
|
||||
@@ -570,6 +577,11 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The browser mock links `demo@ifruit.com` and signs in the seeded SkyPic profile
|
||||
`@alexm` by default. To exercise SkyPic registration with an empty profile, open
|
||||
`http://localhost:5174/?testScenario=skypic-onboarding#/apps/skypic` while the
|
||||
development server is running.
|
||||
|
||||
Create a production frontend build with:
|
||||
|
||||
```powershell
|
||||
|
||||
+172
-7
@@ -45,6 +45,7 @@ import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import { useFlipTokStore } from '@/stores/fliptok'
|
||||
import { usePicstagramStore } from '@/stores/picstagram'
|
||||
import { useSkyPicStore } from '@/stores/skypic'
|
||||
import { useFeatherStore } from '@/stores/feather'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
@@ -77,6 +78,7 @@ import { nuiCall } from '@/utils/nui'
|
||||
import { formatTimer } from '@/utils/clock'
|
||||
import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -95,6 +97,8 @@ type AppMessage = {
|
||||
| FlipTokNotificationData
|
||||
| PicstagramVerificationData
|
||||
| PicstagramNotificationData
|
||||
| SkyPicNotificationData
|
||||
| SkyPicChangedData
|
||||
| FeatherNotificationData
|
||||
| BankingChangedData
|
||||
| CryptoMarketChangedData
|
||||
@@ -229,6 +233,28 @@ type PicstagramNotificationData = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
type SkyPicNotificationData = {
|
||||
actor?: string
|
||||
device?: PhoneNotificationDevicePayload
|
||||
kind?:
|
||||
| 'friend_request'
|
||||
| 'friend_accepted'
|
||||
| 'snap'
|
||||
| 'message'
|
||||
| 'story_reply'
|
||||
| 'snap_opened'
|
||||
profileId?: string
|
||||
snapId?: string
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type SkyPicChangedData = {
|
||||
device?: PhoneNotificationDevicePayload
|
||||
profileId?: string
|
||||
reason?: 'account_deleted'
|
||||
}
|
||||
|
||||
type FeatherNotificationData = {
|
||||
actor?: string
|
||||
device?: PhoneNotificationDevicePayload
|
||||
@@ -271,12 +297,14 @@ const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
|
||||
const developmentParameters = new URLSearchParams(window.location.search)
|
||||
const isBrowserPreview =
|
||||
developmentParameters.has('browserPreview') &&
|
||||
developmentParameters.get('apiBase')?.startsWith('/') === true
|
||||
(import.meta.env.DEV ||
|
||||
developmentParameters.get('apiBase')?.startsWith('/') === true)
|
||||
const isDevelopment =
|
||||
import.meta.env.DEV ||
|
||||
developmentParameters.get('apiBase')?.startsWith('/') === true
|
||||
const developmentLockScreenPreview =
|
||||
isDevelopment && developmentParameters.has('lockScreenPreview')
|
||||
let textInputFocused = false
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
@@ -295,6 +323,7 @@ const darkchat = useDarkChatStore()
|
||||
const flare = useFlareStore()
|
||||
const fliptok = useFlipTokStore()
|
||||
const picstagram = usePicstagramStore()
|
||||
const skypic = useSkyPicStore()
|
||||
const feather = useFeatherStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
@@ -317,6 +346,7 @@ const WHITE_STATUS_BAR_APP_IDS = new Set([
|
||||
'calculator',
|
||||
'camera',
|
||||
'fliptok',
|
||||
'skypic',
|
||||
'neon-drop',
|
||||
'sky-flappy',
|
||||
'snake',
|
||||
@@ -346,6 +376,7 @@ const previousHardwareAlertVolume = ref(75)
|
||||
const hardwareVolumeHudVisible = ref(false)
|
||||
const setupPreviewDismissed = ref(false)
|
||||
const setupDevelopmentSkipped = ref(false)
|
||||
const setupAppearanceSelected = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
const controlCenterOpened = ref(false)
|
||||
@@ -359,6 +390,13 @@ const setupRequired = computed(
|
||||
developmentParameters.has('setupPreview') &&
|
||||
!setupPreviewDismissed.value)),
|
||||
)
|
||||
const displayedDarkMode = computed(
|
||||
() =>
|
||||
phone.isDarkMode &&
|
||||
(!setupRequired.value ||
|
||||
setupAppearanceSelected.value ||
|
||||
phone.preferences.settings.setupStep > 4),
|
||||
)
|
||||
const hardwareAlertVolume = computed(() =>
|
||||
Math.round(
|
||||
(phone.preferences.settings.notificationVolume +
|
||||
@@ -493,6 +531,35 @@ function cancelUnlockedPhoneDataLoad(): void {
|
||||
unlockedServicesIdle = undefined
|
||||
}
|
||||
|
||||
async function refreshSkyPicState(refreshThread = false): Promise<void> {
|
||||
if (!account.email || !appAuth.isSignedIn('skypic')) {
|
||||
if (!account.email) {
|
||||
skypic.resetSession()
|
||||
} else {
|
||||
if (!skypic.bootstrapPending) skypic.resetSession()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (skypic.accountDeletePending) return
|
||||
const accountEmail = account.email
|
||||
const imei = phone.device?.imei ?? ''
|
||||
const loaded = await skypic.bootstrap()
|
||||
if (
|
||||
!loaded ||
|
||||
account.email !== accountEmail ||
|
||||
(phone.device?.imei ?? '') !== imei ||
|
||||
!appAuth.isSignedIn('skypic')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!skypic.profile) {
|
||||
appAuth.signOut('skypic')
|
||||
skypic.resetSession()
|
||||
return
|
||||
}
|
||||
if (refreshThread) await skypic.refreshActiveThread()
|
||||
}
|
||||
|
||||
function queueCompaniesChange(change: CompanyChangedPayload): void {
|
||||
if (!pendingCompaniesChange) {
|
||||
pendingCompaniesChange = { ...change }
|
||||
@@ -520,6 +587,7 @@ function queueCompaniesChange(change: CompanyChangedPayload): void {
|
||||
|
||||
async function bootstrapUnlockedPhoneData(): Promise<void> {
|
||||
const tasks: Array<() => Promise<unknown> | void> = [
|
||||
() => refreshSkyPicState(),
|
||||
() => calls.bootstrap(),
|
||||
() => messages.loadConversations(),
|
||||
() => billing.loadOverview(),
|
||||
@@ -572,6 +640,7 @@ function loadUnlockedPhoneData(): void {
|
||||
|
||||
function completePhoneSetup(): void {
|
||||
setupPreviewDismissed.value = true
|
||||
setupAppearanceSelected.value = false
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
@@ -658,6 +727,19 @@ function openDevelopmentPayphonePreview(): void {
|
||||
)
|
||||
}
|
||||
|
||||
function skyPicNotificationRoute(data: SkyPicNotificationData): string {
|
||||
const query = new URLSearchParams()
|
||||
query.set(
|
||||
'tab',
|
||||
data.kind === 'friend_request' || data.kind === 'friend_accepted'
|
||||
? 'friends'
|
||||
: 'chats',
|
||||
)
|
||||
if (data.profileId) query.set('profileId', data.profileId)
|
||||
if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId)
|
||||
return `/apps/skypic?${query.toString()}`
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
|
||||
@@ -889,6 +971,47 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
}
|
||||
notifications.show(notification)
|
||||
if (phone.isOpen) void picstagram.loadActivities()
|
||||
} else if (event.data?.type === 'skypic:new' && event.data.data) {
|
||||
const data = event.data.data as SkyPicNotificationData
|
||||
const targetsActiveDevice =
|
||||
!data.device || data.device.imei === phone.device?.imei
|
||||
const signedInOnActiveDevice = appAuth.isSignedIn('skypic')
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'skypic',
|
||||
route: skyPicNotificationRoute(data),
|
||||
subtitle: data.actor,
|
||||
text: data.text ?? phone.t('Apps.skypic.notifications.default'),
|
||||
title: data.title ?? phone.t('Apps.skypic.name'),
|
||||
}
|
||||
if (
|
||||
data.device &&
|
||||
(!phone.isOpen || data.device.imei !== phone.device?.imei)
|
||||
) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
if (!targetsActiveDevice || signedInOnActiveDevice) {
|
||||
notifications.show(notification)
|
||||
}
|
||||
if (phone.isOpen && targetsActiveDevice && signedInOnActiveDevice) {
|
||||
void refreshSkyPicState(true)
|
||||
} else if (
|
||||
targetsActiveDevice &&
|
||||
!signedInOnActiveDevice &&
|
||||
!skypic.bootstrapPending
|
||||
) {
|
||||
skypic.resetSession()
|
||||
}
|
||||
} else if (event.data?.type === 'skypic:changed' && event.data.data) {
|
||||
const data = event.data.data as SkyPicChangedData
|
||||
const targetsActiveDevice =
|
||||
!data.device || data.device.imei === phone.device?.imei
|
||||
if (phone.isOpen && targetsActiveDevice) {
|
||||
void refreshSkyPicState(true)
|
||||
}
|
||||
} else if (
|
||||
event.data?.type === 'marketplace:new-message' &&
|
||||
event.data.data
|
||||
@@ -1394,7 +1517,29 @@ function unlockCamera(): void {
|
||||
window.setTimeout(() => void router.push('/apps/camera'), 0)
|
||||
}
|
||||
|
||||
function updateTextInputFocus(active: boolean): void {
|
||||
if (textInputFocused === active) return
|
||||
textInputFocused = active
|
||||
void nuiCall('ui:input-focus', { active })
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent): void {
|
||||
const target = event.target
|
||||
updateTextInputFocus(
|
||||
target instanceof HTMLElement && isTextInputElement(target),
|
||||
)
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent): void {
|
||||
const nextTarget = event.relatedTarget
|
||||
updateTextInputFocus(
|
||||
nextTarget instanceof HTMLElement && isTextInputElement(nextTarget),
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('focusout', onFocusOut)
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('resize', updateViewportScale)
|
||||
@@ -1500,7 +1645,9 @@ watch(
|
||||
(isOpen) => {
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
if (!isOpen) {
|
||||
updateTextInputFocus(false)
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
skypic.resetSession()
|
||||
appStore.cancelPendingInstalls()
|
||||
activitySuspended.value = false
|
||||
weather.stop()
|
||||
@@ -1549,6 +1696,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [phone.device?.imei ?? '', account.email] as const,
|
||||
([imei, email], [previousImei, previousEmail]) => {
|
||||
if (imei === previousImei && email === previousEmail) return
|
||||
skypic.resetSession()
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
unlockedServicesLoaded.value = false
|
||||
if (phone.isOpen && !isLocked.value && !setupRequired.value) {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => phone.cameraLandscape,
|
||||
(landscape) => {
|
||||
@@ -1557,6 +1717,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
updateTextInputFocus(false)
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
weather.stop()
|
||||
if (clockTicker) clearInterval(clockTicker)
|
||||
@@ -1571,6 +1732,8 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('resize', updateViewportScale)
|
||||
document.removeEventListener('focusin', onFocusIn)
|
||||
document.removeEventListener('focusout', onFocusOut)
|
||||
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
|
||||
})
|
||||
</script>
|
||||
@@ -1624,7 +1787,7 @@ onBeforeUnmount(() => {
|
||||
<section
|
||||
class="phone-device"
|
||||
:class="{
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="phone.t('Common.phone')"
|
||||
@@ -1674,7 +1837,7 @@ onBeforeUnmount(() => {
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
'phone-screen--app': isAppRoute || isDevelopmentRoute,
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
>
|
||||
@@ -1711,18 +1874,19 @@ onBeforeUnmount(() => {
|
||||
</Transition>
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="phone.isDarkMode"
|
||||
:dark="displayedDarkMode"
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
:style="phoneDisplayStyle"
|
||||
:class="{
|
||||
dark: phone.isDarkMode,
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
dark: displayedDarkMode,
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
'phone-app--messages': route.params.appId === 'messages',
|
||||
'phone-app--status-light':
|
||||
WHITE_STATUS_BAR_APP_IDS.has(activeAppId),
|
||||
'phone-app--status-dark':
|
||||
DARK_STATUS_BAR_APP_IDS.has(activeAppId),
|
||||
'phone-app--setup': setupRequired,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
'phone-app--unlocking': isUnlocking,
|
||||
}"
|
||||
@@ -1742,7 +1906,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<SkyProvider
|
||||
class="phone-app-theme"
|
||||
:dark="phone.isDarkMode"
|
||||
:dark="displayedDarkMode"
|
||||
safe-areas
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
@@ -1801,6 +1965,7 @@ onBeforeUnmount(() => {
|
||||
</Transition>
|
||||
<PhoneSetupAssistant
|
||||
v-if="setupRequired"
|
||||
@appearance-selected="setupAppearanceSelected = $event"
|
||||
@complete="completePhoneSetup"
|
||||
@skip="skipPhoneSetupForDevelopment"
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,17 @@ const mainCss = readFileSync(
|
||||
)
|
||||
|
||||
describe('browser development preview contract', () => {
|
||||
it('keeps setup light until the appearance choice has been confirmed', () => {
|
||||
expect(source).toContain('const displayedDarkMode = computed(')
|
||||
expect(source).toContain('phone.preferences.settings.setupStep > 4')
|
||||
expect(source).toContain('setupAppearanceSelected.value')
|
||||
expect(source).toContain(
|
||||
'@appearance-selected="setupAppearanceSelected = $event"',
|
||||
)
|
||||
expect(source).toContain(':dark="displayedDarkMode"')
|
||||
expect(source).toContain('dark: displayedDarkMode')
|
||||
})
|
||||
|
||||
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
||||
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
||||
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
|
||||
@@ -87,6 +98,7 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('fills the dedicated browser embed without clipping the hardware controls', () => {
|
||||
expect(source).toContain("developmentParameters.has('browserPreview')")
|
||||
expect(source).toContain('import.meta.env.DEV ||')
|
||||
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
|
||||
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
|
||||
expect(mainCss).toMatch(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+31
-322
@@ -904,361 +904,63 @@ button {
|
||||
}
|
||||
.wallpaper--midnight {
|
||||
background:
|
||||
radial-gradient(circle at 76% 17%, #ffffff 0 0.7px, transparent 1.2px),
|
||||
radial-gradient(circle at 28% 8%, #9fceff 0 0.6px, transparent 1px),
|
||||
radial-gradient(circle at 82% 48%, #d8c8ff 0 0.8px, transparent 1.3px),
|
||||
radial-gradient(
|
||||
circle at 69% 19%,
|
||||
#faf7ff 0 3%,
|
||||
#a57be74d 8%,
|
||||
transparent 23%
|
||||
),
|
||||
conic-gradient(
|
||||
from 214deg at 63% 35%,
|
||||
transparent 0 29%,
|
||||
#7352a51f 36%,
|
||||
transparent 44%
|
||||
),
|
||||
radial-gradient(ellipse at 10% 84%, #1767999e 0, transparent 45%),
|
||||
linear-gradient(158deg, #050511 0%, #11132d 42%, #120a25 68%, #02040c 100%);
|
||||
background-size:
|
||||
43px 47px,
|
||||
67px 71px,
|
||||
89px 83px,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto;
|
||||
radial-gradient(circle at 78% 12%, rgb(87 103 160 / 18%), transparent 38%),
|
||||
linear-gradient(160deg, #141724 0%, #090b12 100%);
|
||||
}
|
||||
.wallpaper--aurora {
|
||||
background:
|
||||
radial-gradient(circle at 17% 14%, #dfffff 0 0.6px, transparent 1.1px),
|
||||
radial-gradient(circle at 78% 9%, #c6e7ff 0 0.7px, transparent 1.2px),
|
||||
conic-gradient(
|
||||
from 218deg at 84% 8%,
|
||||
transparent 0 18%,
|
||||
#4effbf70 27%,
|
||||
#6bf5e220 35%,
|
||||
transparent 43%
|
||||
),
|
||||
conic-gradient(
|
||||
from 224deg at 62% 26%,
|
||||
transparent 0 20%,
|
||||
#27cfd46b 28%,
|
||||
#695eff42 38%,
|
||||
transparent 47%
|
||||
),
|
||||
radial-gradient(ellipse at 12% 80%, #1860bb9c 0%, transparent 48%),
|
||||
radial-gradient(ellipse at 76% 20%, #2adfbb6e 0%, transparent 38%),
|
||||
linear-gradient(155deg, #031715 0%, #08293a 43%, #151643 68%, #040914 100%);
|
||||
background-size:
|
||||
59px 61px,
|
||||
83px 79px,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto;
|
||||
radial-gradient(circle at 74% 18%, rgb(80 188 166 / 22%), transparent 42%),
|
||||
linear-gradient(155deg, #163c3c 0%, #17263c 56%, #0b101b 100%);
|
||||
}
|
||||
.wallpaper--ember {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 80% 18%,
|
||||
#fff4ca 0 1.7%,
|
||||
#ffbd67 5%,
|
||||
#ff62335c 17%,
|
||||
transparent 31%
|
||||
),
|
||||
repeating-radial-gradient(
|
||||
ellipse at 78% 21%,
|
||||
transparent 0 16px,
|
||||
#ffb15e12 18px 20px
|
||||
),
|
||||
conic-gradient(
|
||||
from 196deg at 12% 78%,
|
||||
transparent 0 18%,
|
||||
#ff3d4c66 28%,
|
||||
#f3943a29 38%,
|
||||
transparent 48%
|
||||
),
|
||||
conic-gradient(
|
||||
from 22deg at 78% 90%,
|
||||
transparent 0 22%,
|
||||
#b52a755e 31%,
|
||||
transparent 44%
|
||||
),
|
||||
radial-gradient(ellipse at 8% 71%, #c8205c91 0, transparent 46%),
|
||||
linear-gradient(152deg, #20080a 0%, #4b171d 37%, #3c1537 66%, #090710 100%);
|
||||
radial-gradient(circle at 82% 14%, rgb(237 142 96 / 28%), transparent 40%),
|
||||
linear-gradient(155deg, #5b2925 0%, #321b25 58%, #17131b 100%);
|
||||
}
|
||||
.wallpaper--ocean {
|
||||
background:
|
||||
linear-gradient(116deg, transparent 43%, #c7fbff1c 47%, transparent 51%),
|
||||
linear-gradient(64deg, transparent 44%, #8defff17 48%, transparent 52%),
|
||||
radial-gradient(
|
||||
ellipse at 18% 12%,
|
||||
#d7ffff 0 1%,
|
||||
#70e7ff85 12%,
|
||||
transparent 34%
|
||||
),
|
||||
repeating-radial-gradient(
|
||||
ellipse at 48% 107%,
|
||||
transparent 0 24px,
|
||||
#73e8ff2e 27px 30px,
|
||||
transparent 33px 49px
|
||||
),
|
||||
radial-gradient(ellipse at 87% 70%, #056bd1ce 0, transparent 48%),
|
||||
linear-gradient(
|
||||
172deg,
|
||||
#b5f4f8 0%,
|
||||
#148eb0 22%,
|
||||
#075b91 54%,
|
||||
#03284d 78%,
|
||||
#020c20 100%
|
||||
);
|
||||
background-size:
|
||||
82px 82px,
|
||||
82px 82px,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto;
|
||||
radial-gradient(circle at 22% 10%, rgb(119 203 222 / 28%), transparent 40%),
|
||||
linear-gradient(175deg, #327e98 0%, #185273 46%, #0b2944 100%);
|
||||
}
|
||||
.wallpaper--sunrise {
|
||||
background:
|
||||
linear-gradient(18deg, #281c46 0 13%, transparent 13.3%),
|
||||
linear-gradient(-22deg, #51304a 0 18%, transparent 18.3%),
|
||||
radial-gradient(
|
||||
circle at 58% 69%,
|
||||
#fffbd7 0 5.5%,
|
||||
#ffd28d 6%,
|
||||
#ff9f666e 18%,
|
||||
transparent 35%
|
||||
),
|
||||
repeating-linear-gradient(0deg, transparent 0 16px, #ffe2ae0d 17px 18px),
|
||||
radial-gradient(ellipse at 20% 44%, #f888a360 0, transparent 42%),
|
||||
linear-gradient(
|
||||
178deg,
|
||||
#342b78 0%,
|
||||
#8a477f 35%,
|
||||
#ed706f 57%,
|
||||
#ffc277 72%,
|
||||
#672e50 100%
|
||||
);
|
||||
radial-gradient(circle at 68% 68%, rgb(255 205 153 / 30%), transparent 42%),
|
||||
linear-gradient(175deg, #6b6488 0%, #b46f78 52%, #d29673 100%);
|
||||
}
|
||||
.wallpaper--violet {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 78% 14%,
|
||||
transparent 0 9%,
|
||||
#e0b6ff35 9.5% 10.3%,
|
||||
transparent 11%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 18% 74%,
|
||||
transparent 0 16%,
|
||||
#a1a2ff29 16.5% 17.2%,
|
||||
transparent 18%
|
||||
),
|
||||
conic-gradient(
|
||||
from 202deg at 83% 25%,
|
||||
transparent 0 17%,
|
||||
#e48dff91 25%,
|
||||
#8d63ff32 35%,
|
||||
transparent 45%
|
||||
),
|
||||
linear-gradient(122deg, transparent 36%, #f0c9ff13 39%, transparent 42%),
|
||||
radial-gradient(circle at 13% 81%, #5555e5c4 0, transparent 46%),
|
||||
radial-gradient(circle at 72% 28%, #a53dcc70 0, transparent 41%),
|
||||
linear-gradient(148deg, #11072b 0%, #391164 48%, #20103f 72%, #080519 100%);
|
||||
radial-gradient(circle at 74% 18%, rgb(164 129 199 / 24%), transparent 42%),
|
||||
linear-gradient(150deg, #513d69 0%, #302b50 56%, #171828 100%);
|
||||
}
|
||||
.wallpaper--forest {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at 82% 12%,
|
||||
transparent 0 7%,
|
||||
#c8f49c2c 7.5% 8.2%,
|
||||
transparent 9%
|
||||
),
|
||||
repeating-radial-gradient(
|
||||
ellipse at -8% 108%,
|
||||
transparent 0 34px,
|
||||
#c1e6a218 36px 39px
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
transparent 42%,
|
||||
#bfe8a31c 45% 47%,
|
||||
transparent 50%
|
||||
),
|
||||
conic-gradient(
|
||||
from 55deg at 85% 24%,
|
||||
transparent 0 24%,
|
||||
#8dcf7361 31%,
|
||||
transparent 39%
|
||||
),
|
||||
radial-gradient(ellipse at 76% 16%, #b7df8675 0, transparent 36%),
|
||||
radial-gradient(ellipse at 10% 78%, #0b8b70b8 0, transparent 49%),
|
||||
linear-gradient(162deg, #061713 0%, #144833 45%, #123326 68%, #040d0b 100%);
|
||||
background-size:
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
100% 100%;
|
||||
radial-gradient(circle at 76% 14%, rgb(126 161 121 / 24%), transparent 42%),
|
||||
linear-gradient(160deg, #395c4c 0%, #243e35 54%, #13231f 100%);
|
||||
}
|
||||
.wallpaper--cobalt {
|
||||
background:
|
||||
linear-gradient(
|
||||
145deg,
|
||||
transparent 0 47%,
|
||||
#c4ddff18 48% 49%,
|
||||
transparent 50%
|
||||
),
|
||||
linear-gradient(
|
||||
35deg,
|
||||
transparent 0 47%,
|
||||
#70a5ff12 48% 49%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 76% 25%,
|
||||
transparent 0 12%,
|
||||
#62b8ff45 12.5% 13.2%,
|
||||
transparent 14%
|
||||
),
|
||||
conic-gradient(
|
||||
from 215deg at 80% 28%,
|
||||
transparent 0 22%,
|
||||
#52b5ff85 30%,
|
||||
#5457d62e 41%,
|
||||
transparent 48%
|
||||
),
|
||||
radial-gradient(circle at 14% 78%, #134ed1a3 0, transparent 43%),
|
||||
linear-gradient(152deg, #041438 0%, #0b3182 43%, #172d82 64%, #030a25 100%);
|
||||
background-size:
|
||||
58px 58px,
|
||||
58px 58px,
|
||||
auto,
|
||||
auto,
|
||||
auto,
|
||||
auto;
|
||||
radial-gradient(circle at 72% 16%, rgb(91 142 218 / 28%), transparent 40%),
|
||||
linear-gradient(155deg, #264f8b 0%, #1b3768 54%, #101e3b 100%);
|
||||
}
|
||||
.wallpaper--rose {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 16% 18%,
|
||||
#fff6f3 0 1.5%,
|
||||
#ffd9df9e 8%,
|
||||
transparent 27%
|
||||
),
|
||||
repeating-radial-gradient(
|
||||
ellipse at 108% 8%,
|
||||
transparent 0 24px,
|
||||
#ffd5e121 26px 28px
|
||||
),
|
||||
conic-gradient(
|
||||
from 214deg at 86% 72%,
|
||||
transparent 0 18%,
|
||||
#f078aa8c 27%,
|
||||
#8d3d7b38 39%,
|
||||
transparent 48%
|
||||
),
|
||||
linear-gradient(138deg, transparent 38%, #fff0f217 43%, transparent 48%),
|
||||
radial-gradient(circle at 87% 77%, #e84e92a8 0, transparent 43%),
|
||||
linear-gradient(148deg, #40132f 0%, #8e3159 39%, #b64b70 59%, #351128 100%);
|
||||
radial-gradient(circle at 24% 12%, rgb(224 154 172 / 26%), transparent 42%),
|
||||
linear-gradient(155deg, #9a596c 0%, #724354 54%, #412936 100%);
|
||||
}
|
||||
.wallpaper--sand {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 76% 16%,
|
||||
#fffce7 0 2.5%,
|
||||
#ffe5a5a8 9%,
|
||||
transparent 25%
|
||||
),
|
||||
repeating-radial-gradient(
|
||||
ellipse at -2% 110%,
|
||||
transparent 0 28px,
|
||||
#fff3d12b 31px 34px,
|
||||
transparent 37px 52px
|
||||
),
|
||||
conic-gradient(
|
||||
from 82deg at 110% 76%,
|
||||
transparent 0 24%,
|
||||
#ecc37e52 32%,
|
||||
transparent 43%
|
||||
),
|
||||
linear-gradient(115deg, transparent 41%, #fff6d21a 44%, transparent 47%),
|
||||
radial-gradient(ellipse at 15% 78%, #9f654b9e 0, transparent 48%),
|
||||
linear-gradient(163deg, #8b5c45 0%, #d4a262 43%, #b87950 68%, #4f302b 100%);
|
||||
radial-gradient(circle at 76% 14%, rgb(222 190 145 / 25%), transparent 42%),
|
||||
linear-gradient(160deg, #a17d61 0%, #80604e 56%, #513e36 100%);
|
||||
}
|
||||
.wallpaper--graphite {
|
||||
background:
|
||||
radial-gradient(circle at 77% 17%, #ffffff42 0 0.8%, transparent 12%),
|
||||
linear-gradient(
|
||||
125deg,
|
||||
transparent 39%,
|
||||
#ffffff12 40% 42%,
|
||||
transparent 43%
|
||||
),
|
||||
linear-gradient(35deg, transparent 39%, #ffffff0a 40% 42%, transparent 43%),
|
||||
repeating-linear-gradient(
|
||||
92deg,
|
||||
transparent 0 3px,
|
||||
#ffffff09 4px,
|
||||
transparent 5px 9px
|
||||
),
|
||||
conic-gradient(
|
||||
from 215deg at 79% 24%,
|
||||
transparent 0 25%,
|
||||
#b7c1d045 32%,
|
||||
transparent 43%
|
||||
),
|
||||
radial-gradient(circle at 17% 78%, #51596c73 0, transparent 44%),
|
||||
linear-gradient(157deg, #06070a 0%, #242832 43%, #171920 67%, #030405 100%);
|
||||
background-size:
|
||||
auto,
|
||||
54px 54px,
|
||||
54px 54px,
|
||||
100% 100%,
|
||||
auto,
|
||||
auto,
|
||||
auto;
|
||||
radial-gradient(circle at 76% 14%, rgb(133 140 151 / 18%), transparent 40%),
|
||||
linear-gradient(160deg, #363a42 0%, #23262c 54%, #121417 100%);
|
||||
}
|
||||
.wallpaper--prism {
|
||||
background:
|
||||
linear-gradient(
|
||||
132deg,
|
||||
transparent 0 32%,
|
||||
#ffffff36 32.5% 33.2%,
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(
|
||||
42deg,
|
||||
transparent 0 56%,
|
||||
#bffcff25 56.5% 57.2%,
|
||||
transparent 58%
|
||||
),
|
||||
conic-gradient(
|
||||
from 218deg at 70% 29%,
|
||||
#ff527ca1,
|
||||
#ffc75f91,
|
||||
#4cead2a6,
|
||||
#4f72ffae,
|
||||
#c64deaa3,
|
||||
#ff527ca1
|
||||
),
|
||||
conic-gradient(
|
||||
from 38deg at 16% 78%,
|
||||
transparent 0 19%,
|
||||
#4ae3d8a1 28%,
|
||||
#596cff48 39%,
|
||||
transparent 48%
|
||||
),
|
||||
radial-gradient(circle at 16% 76%, #35d8d399 0, transparent 38%),
|
||||
linear-gradient(152deg, #120d35 0%, #47205f 46%, #10205a 70%, #041321 100%);
|
||||
background-blend-mode: screen, screen, screen, screen, screen, normal;
|
||||
radial-gradient(circle at 72% 16%, rgb(165 151 218 / 24%), transparent 42%),
|
||||
linear-gradient(150deg, #655b86 0%, #42516f 52%, #293545 100%);
|
||||
}
|
||||
.wallpaper--custom {
|
||||
background-color: #090a0d;
|
||||
@@ -5475,6 +5177,13 @@ button {
|
||||
color: #111;
|
||||
text-shadow: 0 1px 3px #fff8;
|
||||
}
|
||||
.phone-app--setup .phone-status-bar {
|
||||
color: #1d1d1f;
|
||||
text-shadow: none;
|
||||
}
|
||||
.phone-app.dark.phone-app--setup .phone-status-bar {
|
||||
color: #f5f5f7;
|
||||
}
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useBillingStore } from '@/stores/billing'
|
||||
import { useCompaniesStore } from '@/stores/companies'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useSkyPicStore } from '@/stores/skypic'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
@@ -54,6 +55,7 @@ const billing = useBillingStore()
|
||||
const companies = useCompaniesStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const skypic = useSkyPicStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const isDragging = ref(false)
|
||||
@@ -102,6 +104,7 @@ const unreadCount = computed(() => {
|
||||
if (props.app.id === 'darkchat') return darkchat.unreadCount
|
||||
if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0
|
||||
if (props.app.id === 'companies') return companies.unreadCount
|
||||
if (props.app.id === 'skypic') return skypic.unreadCount
|
||||
return 0
|
||||
})
|
||||
const calendarWeekday = computed(() =>
|
||||
|
||||
@@ -11,13 +11,20 @@ describe('PhoneSetupAssistant contract', () => {
|
||||
it('uses first-party controls and exposes the complete setup journey', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(source).toContain('PhonePasscode')
|
||||
expect(source).toContain("step === 1")
|
||||
expect(source).toContain("step === 8")
|
||||
expect(source).toContain('step === 1')
|
||||
expect(source).toContain('step === 8')
|
||||
expect(source).toContain("['performance', 'ultimate']")
|
||||
expect(source).toContain('setup-mode-preview__card--front')
|
||||
expect(source).toContain('setup-mode-preview__card--back')
|
||||
expect(source).toContain('WALLPAPER_IDS')
|
||||
expect(source).toContain('setAllAppNotifications')
|
||||
expect(source).toContain('appStore.claimApp')
|
||||
expect(source).toContain('phone.completeSetup()')
|
||||
expect(source).toContain('await phone.completeSetup()')
|
||||
expect(source).toMatch(
|
||||
/if \(step\.value === 8\) \{[\s\S]*void finish\(\)[\s\S]*return/,
|
||||
)
|
||||
expect(source).toContain(':disabled="setupCompleteBusy"')
|
||||
expect(source).toContain("phone.t('Setup.ready.saveFailed')")
|
||||
})
|
||||
|
||||
it('persists progress and supports resuming or moving backward', () => {
|
||||
@@ -25,4 +32,30 @@ describe('PhoneSetupAssistant contract', () => {
|
||||
expect(source).toContain('phone.setSetupStep(step.value)')
|
||||
expect(source).toContain('@click="moveTo(step - 1)"')
|
||||
})
|
||||
|
||||
it('keeps the development skip control away from setup progress', () => {
|
||||
expect(source).toContain('v-if="showDevelopmentSkip && step === 0"')
|
||||
})
|
||||
|
||||
it('follows the selected system appearance throughout setup', () => {
|
||||
expect(source).toContain(
|
||||
'(appearanceSelected || step > 4) && phone.isDarkMode',
|
||||
)
|
||||
expect(source).toContain("emit('appearanceSelected', true)")
|
||||
expect(source).toContain("phone.setPreference('appearanceMode', 'light')")
|
||||
expect(source).toContain('.setup-assistant--dark.setup-assistant--step-0')
|
||||
expect(source).toContain('--setup-background: #000000')
|
||||
expect(source).toContain('background: var(--setup-background)')
|
||||
})
|
||||
|
||||
it('applies the selected graphics mode to the setup experience immediately', () => {
|
||||
expect(source).toContain("'setup-assistant--performance':")
|
||||
expect(source).toContain("'setup-assistant--ultimate':")
|
||||
expect(source).toContain(
|
||||
'.setup-assistant--performance .setup-forward-enter-active',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'.setup-assistant--ultimate .setup-mode-stack button',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Palette,
|
||||
ShieldCheck,
|
||||
Signal,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Wifi,
|
||||
} from 'lucide-vue-next'
|
||||
@@ -30,7 +31,11 @@ import {
|
||||
type WallpaperId,
|
||||
} from '@/utils/preferences'
|
||||
|
||||
const emit = defineEmits<{ complete: []; skip: [] }>()
|
||||
const emit = defineEmits<{
|
||||
appearanceSelected: [selected: boolean]
|
||||
complete: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
@@ -38,6 +43,10 @@ const appStore = useAppStoreStore()
|
||||
const step = ref(
|
||||
Math.min(PHONE_SETUP_LAST_STEP, phone.preferences.settings.setupStep),
|
||||
)
|
||||
const appearanceSelected = ref(step.value > 4)
|
||||
if (step.value === 4) {
|
||||
phone.setPreference('appearanceMode', 'light')
|
||||
}
|
||||
const direction = ref<'back' | 'forward'>('forward')
|
||||
const accountMode = ref<'login' | 'register'>('login')
|
||||
const email = ref('')
|
||||
@@ -53,6 +62,8 @@ const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
@@ -91,6 +102,13 @@ const showDevelopmentSkip = import.meta.env.DEV
|
||||
function moveTo(nextStep: number): void {
|
||||
direction.value = nextStep < step.value ? 'back' : 'forward'
|
||||
step.value = Math.min(PHONE_SETUP_LAST_STEP, Math.max(0, nextStep))
|
||||
if (step.value < 4) {
|
||||
appearanceSelected.value = false
|
||||
emit('appearanceSelected', false)
|
||||
}
|
||||
if (step.value === 4 && !appearanceSelected.value) {
|
||||
phone.setPreference('appearanceMode', 'light')
|
||||
}
|
||||
phone.setSetupStep(step.value)
|
||||
}
|
||||
|
||||
@@ -103,12 +121,16 @@ function continueSetup(): void {
|
||||
}
|
||||
if (step.value === 8) {
|
||||
for (const appId of selectedApps.value) appStore.claimApp(appId)
|
||||
void finish()
|
||||
return
|
||||
}
|
||||
moveTo(step.value + 1)
|
||||
}
|
||||
|
||||
function chooseAppearance(mode: AppearanceMode): void {
|
||||
appearanceSelected.value = true
|
||||
phone.setPreference('appearanceMode', mode)
|
||||
emit('appearanceSelected', true)
|
||||
}
|
||||
|
||||
function choosePerformance(mode: GraphicsMode): void {
|
||||
@@ -204,8 +226,16 @@ function choosePasscodeLength(length: 4 | 6): void {
|
||||
passcodeResetKey.value += 1
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
phone.completeSetup()
|
||||
async function finish(): Promise<void> {
|
||||
if (setupCompleteBusy.value) return
|
||||
setupCompleteBusy.value = true
|
||||
setupCompleteError.value = ''
|
||||
const completed = await phone.completeSetup()
|
||||
setupCompleteBusy.value = false
|
||||
if (!completed) {
|
||||
setupCompleteError.value = phone.t('Setup.ready.saveFailed')
|
||||
return
|
||||
}
|
||||
emit('complete')
|
||||
}
|
||||
|
||||
@@ -217,13 +247,22 @@ function skipSetupForDevelopment(): void {
|
||||
<template>
|
||||
<section
|
||||
class="setup-assistant"
|
||||
:class="`setup-assistant--step-${step}`"
|
||||
:class="[
|
||||
`setup-assistant--step-${step}`,
|
||||
{
|
||||
'setup-assistant--dark':
|
||||
(appearanceSelected || step > 4) && phone.isDarkMode,
|
||||
'setup-assistant--performance':
|
||||
phone.preferences.settings.graphicsMode === 'performance',
|
||||
'setup-assistant--ultimate':
|
||||
phone.preferences.settings.graphicsMode === 'ultimate',
|
||||
},
|
||||
]"
|
||||
:style="currentWallpaperStyle"
|
||||
:aria-label="phone.t('Setup.title')"
|
||||
>
|
||||
<div class="setup-assistant__aurora" aria-hidden="true"></div>
|
||||
<button
|
||||
v-if="showDevelopmentSkip"
|
||||
v-if="showDevelopmentSkip && step === 0"
|
||||
type="button"
|
||||
class="setup-assistant__development-skip"
|
||||
@click="skipSetupForDevelopment"
|
||||
@@ -252,15 +291,8 @@ function skipSetupForDevelopment(): void {
|
||||
<main :key="step" class="setup-assistant__page">
|
||||
<template v-if="step === 0">
|
||||
<div class="setup-welcome__hero" aria-hidden="true">
|
||||
<div class="setup-welcome__greetings">
|
||||
<span>{{ phone.t('Setup.welcome.hello') }}</span>
|
||||
<span>{{ phone.t('Setup.welcome.hallo') }}</span>
|
||||
<span>{{ phone.t('Setup.welcome.bonjour') }}</span>
|
||||
</div>
|
||||
<div class="setup-welcome__signature">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<div class="setup-welcome__device">
|
||||
<Smartphone :size="43" :stroke-width="1.55" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setup-welcome__copy">
|
||||
@@ -273,19 +305,6 @@ function skipSetupForDevelopment(): void {
|
||||
</p>
|
||||
</div>
|
||||
<footer class="setup-welcome__footer">
|
||||
<div class="setup-welcome__privacy">
|
||||
<span
|
||||
><ShieldCheck :size="14" />{{
|
||||
phone.t('Setup.welcome.private')
|
||||
}}</span
|
||||
>
|
||||
<i aria-hidden="true"></i>
|
||||
<span
|
||||
><Sparkles :size="14" />{{
|
||||
phone.t('Setup.welcome.personal')
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<SkyButton class="setup-assistant__primary" @click="continueSetup">
|
||||
{{ phone.t('Setup.getStarted') }}
|
||||
</SkyButton>
|
||||
@@ -622,7 +641,20 @@ function skipSetupForDevelopment(): void {
|
||||
<span
|
||||
class="setup-mode-stack__orb"
|
||||
:class="`setup-mode-stack__orb--${mode}`"
|
||||
></span>
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i class="setup-mode-preview__backdrop"></i>
|
||||
<i
|
||||
class="setup-mode-preview__card setup-mode-preview__card--back"
|
||||
></i>
|
||||
<i
|
||||
class="setup-mode-preview__card setup-mode-preview__card--front"
|
||||
></i>
|
||||
<i
|
||||
class="setup-mode-preview__line setup-mode-preview__line--wide"
|
||||
></i>
|
||||
<i class="setup-mode-preview__line"></i>
|
||||
</span>
|
||||
<span
|
||||
><strong>{{ phone.t(`Apps.settings.${mode}Mode`) }}</strong
|
||||
><small>{{ phone.t(`Setup.performance.${mode}`) }}</small></span
|
||||
@@ -794,12 +826,29 @@ function skipSetupForDevelopment(): void {
|
||||
}}</b></span
|
||||
>
|
||||
</div>
|
||||
<SkyButton class="setup-assistant__primary" @click="finish">{{
|
||||
phone.t('Setup.ready.enter')
|
||||
}}</SkyButton>
|
||||
<SkyButton
|
||||
class="setup-assistant__primary"
|
||||
:disabled="setupCompleteBusy"
|
||||
@click="finish"
|
||||
>
|
||||
<SkySpinner
|
||||
v-if="setupCompleteBusy"
|
||||
:label="phone.t('Setup.ready.saving')"
|
||||
:size="18"
|
||||
/>
|
||||
<span v-else>{{ phone.t('Setup.ready.enter') }}</span>
|
||||
</SkyButton>
|
||||
<p
|
||||
v-if="setupCompleteError"
|
||||
class="setup-assistant__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ setupCompleteError }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="setup-assistant__later"
|
||||
:disabled="setupCompleteBusy"
|
||||
@click="moveTo(0)"
|
||||
>
|
||||
{{ phone.t('Setup.ready.review') }}
|
||||
@@ -2099,6 +2148,462 @@ function skipSetupForDevelopment(): void {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Minimal system setup language, matching the restrained iOS setup hierarchy. */
|
||||
.setup-assistant,
|
||||
.setup-assistant--step-0 {
|
||||
--setup-background: #ffffff;
|
||||
--setup-text: #1d1d1f;
|
||||
--setup-secondary: #6e6e73;
|
||||
--setup-tertiary: #8e8e93;
|
||||
--setup-muted: #aeaeb2;
|
||||
--setup-surface: #f2f2f7;
|
||||
--setup-selected-surface: #eef6ff;
|
||||
--setup-control: #e9e9eb;
|
||||
--setup-control-fill: #ffffff;
|
||||
--setup-separator: #d1d1d6;
|
||||
--setup-blue: #007aff;
|
||||
--setup-green: #34c759;
|
||||
--setup-red: #ff3b30;
|
||||
--setup-orb: #dcecff;
|
||||
--setup-orb-ultimate: #e7e2ff;
|
||||
--setup-home-indicator: #1d1d1f;
|
||||
color: var(--setup-text);
|
||||
background: var(--setup-background);
|
||||
}
|
||||
.setup-assistant--dark,
|
||||
.setup-assistant--dark.setup-assistant--step-0 {
|
||||
--setup-background: #000000;
|
||||
--setup-text: #f5f5f7;
|
||||
--setup-secondary: #98989d;
|
||||
--setup-tertiary: #8e8e93;
|
||||
--setup-muted: #636366;
|
||||
--setup-surface: #1c1c1e;
|
||||
--setup-selected-surface: #102a43;
|
||||
--setup-control: #2c2c2e;
|
||||
--setup-control-fill: #636366;
|
||||
--setup-separator: #38383a;
|
||||
--setup-blue: #0a84ff;
|
||||
--setup-green: #30d158;
|
||||
--setup-red: #ff453a;
|
||||
--setup-orb: #102a43;
|
||||
--setup-orb-ultimate: #28203d;
|
||||
--setup-home-indicator: #ffffff;
|
||||
}
|
||||
.setup-assistant__development-skip {
|
||||
top: 48px;
|
||||
right: 13px;
|
||||
border: 0;
|
||||
color: var(--setup-blue);
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.setup-assistant__chrome {
|
||||
gap: 10px;
|
||||
}
|
||||
.setup-assistant__back {
|
||||
border: 0;
|
||||
color: var(--setup-blue);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.setup-assistant__progress {
|
||||
height: 3px;
|
||||
background: var(--setup-separator);
|
||||
}
|
||||
.setup-assistant__progress span {
|
||||
background: var(--setup-blue);
|
||||
}
|
||||
.setup-assistant__counter {
|
||||
color: var(--setup-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.setup-assistant__page h1 {
|
||||
margin: 12px 0 10px;
|
||||
color: var(--setup-text);
|
||||
font-size: 31px;
|
||||
font-weight: 700;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.setup-assistant__lead {
|
||||
color: var(--setup-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
.setup-assistant__eyebrow,
|
||||
.setup-welcome__eyebrow {
|
||||
display: none;
|
||||
}
|
||||
.setup-assistant__icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--setup-blue);
|
||||
background: var(--setup-surface);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.setup-assistant__icon--signal,
|
||||
.setup-assistant__icon--cloud,
|
||||
.setup-assistant__icon--security,
|
||||
.setup-assistant__icon--appearance,
|
||||
.setup-assistant__icon--performance,
|
||||
.setup-assistant__icon--wallpaper,
|
||||
.setup-assistant__icon--notifications,
|
||||
.setup-assistant__icon--apps {
|
||||
color: var(--setup-blue);
|
||||
}
|
||||
.setup-assistant__primary {
|
||||
min-height: 50px;
|
||||
border-radius: 14px !important;
|
||||
background: var(--setup-blue) !important;
|
||||
box-shadow: none !important;
|
||||
font-weight: 650;
|
||||
}
|
||||
.setup-assistant__later {
|
||||
color: var(--setup-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
.setup-assistant__notice {
|
||||
padding: 5px 4px;
|
||||
border: 0;
|
||||
color: var(--setup-blue);
|
||||
background: transparent;
|
||||
}
|
||||
.setup-assistant__notice p {
|
||||
color: var(--setup-secondary);
|
||||
}
|
||||
.setup-assistant__error {
|
||||
color: var(--setup-red);
|
||||
}
|
||||
.setup-assistant--step-0 .setup-assistant__page {
|
||||
padding: 105px 28px 0;
|
||||
}
|
||||
.setup-welcome__hero {
|
||||
width: 94px;
|
||||
height: 94px;
|
||||
border-radius: 50%;
|
||||
background: var(--setup-surface);
|
||||
}
|
||||
.setup-welcome__hero::before,
|
||||
.setup-welcome__hero::after {
|
||||
content: none;
|
||||
}
|
||||
.setup-welcome__device {
|
||||
display: grid;
|
||||
width: 94px;
|
||||
height: 94px;
|
||||
color: var(--setup-blue);
|
||||
place-items: center;
|
||||
}
|
||||
.setup-welcome__copy {
|
||||
margin-top: 31px;
|
||||
}
|
||||
.setup-welcome__copy h1 {
|
||||
margin-top: 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
.setup-welcome__footer {
|
||||
width: calc(100% + 56px);
|
||||
margin-right: -28px;
|
||||
margin-left: -28px;
|
||||
padding: 0 28px 13px;
|
||||
border: 0;
|
||||
background: var(--setup-background);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.setup-welcome__footer .setup-assistant__primary {
|
||||
margin-top: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-welcome__home-indicator {
|
||||
background: var(--setup-home-indicator);
|
||||
}
|
||||
.setup-connectivity-card {
|
||||
min-height: 142px;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
color: var(--setup-text);
|
||||
background: var(--setup-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-connectivity-card__waves {
|
||||
display: none;
|
||||
}
|
||||
.setup-connectivity-card__label,
|
||||
.setup-connectivity-card svg {
|
||||
color: var(--setup-blue);
|
||||
}
|
||||
.setup-connectivity-card > span:last-of-type {
|
||||
color: var(--setup-tertiary);
|
||||
}
|
||||
.setup-cloud-hero {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
color: var(--setup-blue);
|
||||
background: var(--setup-surface);
|
||||
}
|
||||
.setup-cloud-hero__orbit,
|
||||
.setup-cloud-hero__glow,
|
||||
.setup-cloud-hero i {
|
||||
display: none;
|
||||
}
|
||||
.setup-cloud-hero svg {
|
||||
filter: none;
|
||||
}
|
||||
.setup-cloud-identity {
|
||||
border: 0;
|
||||
background: var(--setup-surface);
|
||||
}
|
||||
.setup-cloud-identity > span {
|
||||
border: 0;
|
||||
background: var(--setup-blue);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-cloud-identity small,
|
||||
.setup-cloud-strength,
|
||||
.setup-cloud-security-note {
|
||||
color: var(--setup-tertiary);
|
||||
}
|
||||
.setup-cloud-identity strong,
|
||||
.setup-cloud-connected strong {
|
||||
color: var(--setup-text);
|
||||
}
|
||||
.setup-cloud-identity em,
|
||||
.setup-cloud-suffix {
|
||||
color: var(--setup-blue);
|
||||
}
|
||||
.setup-cloud-fields {
|
||||
border: 0;
|
||||
background: var(--setup-surface);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field + .sky-field) {
|
||||
border-top-color: var(--setup-separator);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__label) {
|
||||
color: var(--setup-tertiary);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__input) {
|
||||
color: var(--setup-text);
|
||||
caret-color: var(--setup-blue);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__input::placeholder) {
|
||||
color: var(--setup-muted);
|
||||
}
|
||||
.setup-cloud-strength span {
|
||||
background: var(--setup-separator);
|
||||
}
|
||||
.setup-cloud-strength span.active {
|
||||
background: var(--setup-blue);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-assistant__selector {
|
||||
background: var(--setup-control);
|
||||
}
|
||||
.setup-assistant__selector-indicator {
|
||||
border: 0;
|
||||
background: var(--setup-control-fill);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 14%);
|
||||
}
|
||||
.setup-assistant__selector button {
|
||||
color: var(--setup-secondary);
|
||||
}
|
||||
.setup-assistant__selector button.active {
|
||||
color: var(--setup-text);
|
||||
}
|
||||
.setup-cloud-connected {
|
||||
color: var(--setup-green);
|
||||
}
|
||||
.setup-cloud-connected span {
|
||||
color: var(--setup-secondary);
|
||||
}
|
||||
.setup-security-visual {
|
||||
border: 0;
|
||||
background: var(--setup-surface);
|
||||
}
|
||||
.setup-security-visual span {
|
||||
background: var(--setup-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-security-visual svg {
|
||||
color: var(--setup-muted);
|
||||
}
|
||||
.setup-passcode-length button,
|
||||
.setup-choice-grid button,
|
||||
.setup-mode-stack button,
|
||||
.setup-toggle-card,
|
||||
.setup-app-list button,
|
||||
.setup-ready__summary span {
|
||||
border-color: transparent;
|
||||
color: var(--setup-text);
|
||||
background: var(--setup-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-passcode-length button.selected,
|
||||
.setup-choice-grid button.selected,
|
||||
.setup-mode-stack button.selected,
|
||||
.setup-app-list button.selected {
|
||||
border-color: var(--setup-blue);
|
||||
background: var(--setup-selected-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-passcode-length small,
|
||||
.setup-mode-stack small,
|
||||
.setup-toggle-card small,
|
||||
.setup-app-list small {
|
||||
color: var(--setup-secondary);
|
||||
}
|
||||
.setup-mode-stack__orb {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border-radius: 18px;
|
||||
background: var(--setup-orb);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-mode-stack__orb--ultimate {
|
||||
background: var(--setup-orb-ultimate);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-mode-preview__backdrop,
|
||||
.setup-mode-preview__card,
|
||||
.setup-mode-preview__line {
|
||||
position: absolute;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.setup-mode-preview__backdrop {
|
||||
inset: 0;
|
||||
background: linear-gradient(145deg, #d9eaff 0%, #b9d8ff 100%);
|
||||
}
|
||||
.setup-mode-preview__card {
|
||||
width: 37px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.setup-mode-preview__card--back {
|
||||
top: 11px;
|
||||
left: 9px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.setup-mode-preview__card--front {
|
||||
right: 8px;
|
||||
bottom: 10px;
|
||||
background: #e7f1ff;
|
||||
}
|
||||
.setup-mode-preview__line {
|
||||
z-index: 2;
|
||||
right: 15px;
|
||||
bottom: 17px;
|
||||
width: 17px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: #5d80ad;
|
||||
}
|
||||
.setup-mode-preview__line--wide {
|
||||
bottom: 23px;
|
||||
width: 24px;
|
||||
}
|
||||
.setup-mode-stack__orb--ultimate .setup-mode-preview__backdrop {
|
||||
background:
|
||||
radial-gradient(circle at 20% 25%, #f4c7ff 0 12%, transparent 35%),
|
||||
linear-gradient(145deg, #776de4 0%, #342760 100%);
|
||||
}
|
||||
.setup-mode-stack__orb--ultimate .setup-mode-preview__card {
|
||||
border: 1px solid rgb(255 255 255 / 38%);
|
||||
background: rgb(255 255 255 / 25%);
|
||||
box-shadow: 0 7px 14px rgb(31 18 70 / 24%);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--back {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--front {
|
||||
background: rgb(255 255 255 / 34%);
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
.setup-mode-stack__orb--ultimate .setup-mode-preview__line {
|
||||
background: rgb(255 255 255 / 72%);
|
||||
}
|
||||
.setup-assistant--dark
|
||||
.setup-mode-stack__orb--performance
|
||||
.setup-mode-preview__backdrop {
|
||||
background: linear-gradient(145deg, #203147 0%, #142133 100%);
|
||||
}
|
||||
.setup-assistant--dark
|
||||
.setup-mode-stack__orb--performance
|
||||
.setup-mode-preview__card--back {
|
||||
background: #334965;
|
||||
}
|
||||
.setup-assistant--dark
|
||||
.setup-mode-stack__orb--performance
|
||||
.setup-mode-preview__card--front {
|
||||
background: #29405d;
|
||||
}
|
||||
.setup-assistant--dark
|
||||
.setup-mode-stack__orb--performance
|
||||
.setup-mode-preview__line {
|
||||
background: #a9c7e9;
|
||||
}
|
||||
.setup-mode-stack__check,
|
||||
.setup-app-list button > i {
|
||||
border-color: var(--setup-muted);
|
||||
}
|
||||
.selected .setup-mode-stack__check,
|
||||
.setup-app-list button.selected > i {
|
||||
border-color: var(--setup-blue);
|
||||
background: var(--setup-blue);
|
||||
}
|
||||
.setup-wallpapers button.selected {
|
||||
border-color: var(--setup-background);
|
||||
box-shadow: 0 0 0 2px var(--setup-blue);
|
||||
}
|
||||
.setup-toggle-card {
|
||||
border: 0;
|
||||
}
|
||||
.setup-toggle-card button {
|
||||
color: var(--setup-text);
|
||||
}
|
||||
.setup-toggle-card button + button {
|
||||
border-top-color: var(--setup-separator);
|
||||
}
|
||||
.setup-toggle-card i {
|
||||
background: var(--setup-separator);
|
||||
}
|
||||
.setup-ready__halo {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border: 0;
|
||||
color: var(--setup-blue);
|
||||
background: var(--setup-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
.setup-ready__summary span {
|
||||
color: var(--setup-blue);
|
||||
}
|
||||
.setup-ready__summary b {
|
||||
color: var(--setup-text);
|
||||
}
|
||||
.setup-assistant--performance .setup-forward-enter-active,
|
||||
.setup-assistant--performance .setup-forward-leave-active,
|
||||
.setup-assistant--performance .setup-back-enter-active,
|
||||
.setup-assistant--performance .setup-back-leave-active,
|
||||
.setup-assistant--performance .setup-assistant__progress span,
|
||||
.setup-assistant--performance .setup-mode-stack button,
|
||||
.setup-assistant--performance .setup-mode-stack__orb {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
.setup-assistant--ultimate .setup-mode-stack button,
|
||||
.setup-assistant--ultimate .setup-mode-stack__orb {
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.setup-assistant,
|
||||
.setup-assistant *,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import AppProfileAuth from './AppProfileAuth.vue'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./AppProfileAuth.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
async function renderAuth(
|
||||
mode: 'login' | 'register',
|
||||
movingModeHighlight = true,
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(AppProfileAuth, {
|
||||
avatarUrl: null,
|
||||
body: 'Use the linked account.',
|
||||
cameraLabel: 'Camera',
|
||||
email: 'demo@ifruit.com',
|
||||
emailLabel: 'SkyPic account',
|
||||
error: '',
|
||||
eyebrow: 'Your SkyPic account',
|
||||
galleryLabel: 'Photos',
|
||||
loginLabel: 'Continue to SkyPic',
|
||||
loginModeLabel: 'Login',
|
||||
mode,
|
||||
movingModeHighlight,
|
||||
pending: false,
|
||||
registerLabel: 'Create profile',
|
||||
registerModeLabel: 'Register',
|
||||
title: 'Welcome back',
|
||||
username: mode === 'login' ? 'alexm' : 'newprofile',
|
||||
usernameLabel: 'Handle',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AppProfileAuth', () => {
|
||||
it('uses the rounded Sky UI moving highlight for its mode switch', async () => {
|
||||
const html = await renderAuth('login')
|
||||
|
||||
expect(html).toContain('sky-segmented--strong')
|
||||
expect(html).toContain('sky-segmented--rounded')
|
||||
expect(html).toContain('sky-segmented__highlight')
|
||||
expect(html).toContain('app-profile-auth__mode--moving-highlight')
|
||||
expect(html).toContain('width:calc(50% - 4px)')
|
||||
expect(html).toContain('--sky-segmented-indicator-offset:calc(0% + 0px)')
|
||||
expect(html).toContain('aria-label="Your SkyPic account"')
|
||||
})
|
||||
|
||||
it('moves the highlight and keeps short mode labels separate from actions', async () => {
|
||||
const html = await renderAuth('register')
|
||||
|
||||
expect(html).toContain('--sky-segmented-indicator-offset:calc(100% + 4px)')
|
||||
expect(html.match(/Login/g)).toHaveLength(1)
|
||||
expect(html.match(/Register/g)).toHaveLength(1)
|
||||
expect(html).toContain('Create profile')
|
||||
expect(html).not.toContain('>Continue to SkyPic</button>')
|
||||
})
|
||||
|
||||
it('keeps the moving highlight opt-in for SkyPic', async () => {
|
||||
const html = await renderAuth('login', false)
|
||||
|
||||
expect(html).not.toContain('sky-segmented--strong')
|
||||
expect(html).not.toContain('sky-segmented__highlight')
|
||||
expect(html).toContain('app-profile-auth__mode-button--login')
|
||||
expect(html).toContain('app-profile-auth__mode-button--active')
|
||||
expect(source).toContain(':strong="movingModeHighlight"')
|
||||
expect(source).toContain(
|
||||
':not(.app-profile-auth__mode--moving-highlight)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -30,14 +30,17 @@ const props = withDefaults(
|
||||
eyebrow: string
|
||||
galleryLabel: string
|
||||
loginLabel: string
|
||||
loginModeLabel?: string
|
||||
maxUsernameLength?: number
|
||||
minUsernameLength?: number
|
||||
mode: 'login' | 'register'
|
||||
movingModeHighlight?: boolean
|
||||
password?: string
|
||||
passwordLabel?: string
|
||||
passwordPlaceholder?: string
|
||||
pending: boolean
|
||||
registerLabel: string
|
||||
registerModeLabel?: string
|
||||
requirePassword?: boolean
|
||||
submitEnabled?: boolean
|
||||
title: string
|
||||
@@ -62,10 +65,13 @@ const props = withDefaults(
|
||||
emailAsField: false,
|
||||
maxUsernameLength: 40,
|
||||
minUsernameLength: 2,
|
||||
loginModeLabel: '',
|
||||
movingModeHighlight: false,
|
||||
password: '',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: '',
|
||||
requirePassword: false,
|
||||
registerModeLabel: '',
|
||||
showConfirmPassword: false,
|
||||
submitEnabled: undefined,
|
||||
usernameAutocomplete: 'username',
|
||||
@@ -101,6 +107,10 @@ const canSubmit = computed(() => {
|
||||
)
|
||||
)
|
||||
})
|
||||
const modeLoginLabel = computed(() => props.loginModeLabel || props.loginLabel)
|
||||
const modeRegisterLabel = computed(
|
||||
() => props.registerModeLabel || props.registerLabel,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,9 +129,15 @@ const canSubmit = computed(() => {
|
||||
|
||||
<SkyGlass class="app-profile-auth__card">
|
||||
<SkySegmented
|
||||
:active-index="movingModeHighlight ? (mode === 'login' ? 0 : 1) : undefined"
|
||||
:aria-label="eyebrow"
|
||||
:item-count="movingModeHighlight ? 2 : undefined"
|
||||
raised
|
||||
:rounded="movingModeHighlight"
|
||||
:strong="movingModeHighlight"
|
||||
class="app-profile-auth__mode"
|
||||
:class="{
|
||||
'app-profile-auth__mode--moving-highlight': movingModeHighlight,
|
||||
'app-profile-auth__mode--register': mode === 'register',
|
||||
}"
|
||||
>
|
||||
@@ -133,7 +149,7 @@ const canSubmit = computed(() => {
|
||||
:active="mode === 'login'"
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
{{ modeLoginLabel }}
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
|
||||
@@ -143,7 +159,7 @@ const canSubmit = computed(() => {
|
||||
:active="mode === 'register'"
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
{{ modeRegisterLabel }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
|
||||
@@ -372,21 +388,24 @@ const canSubmit = computed(() => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
|
||||
padding: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.app-profile-auth__mode :deep(.app-profile-auth__mode-button) {
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
|
||||
:deep(.app-profile-auth__mode-button) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
.app-profile-auth__mode
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
|
||||
:deep(
|
||||
.app-profile-auth__mode-button--login.app-profile-auth__mode-button--active
|
||||
) {
|
||||
border-radius: 10px 3px 3px 10px;
|
||||
}
|
||||
.app-profile-auth__mode
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
|
||||
:deep(
|
||||
.app-profile-auth__mode-button--register.app-profile-auth__mode-button--active
|
||||
) {
|
||||
@@ -685,7 +704,8 @@ const canSubmit = computed(() => {
|
||||
padding: 14px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__mode {
|
||||
.app-profile-auth--centered
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
|
||||
@@ -170,6 +170,14 @@ describe('app registry', () => {
|
||||
expect(isPhoneAppId('music')).toBe(true)
|
||||
expect(isPhoneAppId('companies')).toBe(true)
|
||||
expect(isPhoneAppId('weazel-news')).toBe(true)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'skypic')).toMatchObject({
|
||||
category: 'social',
|
||||
dockOrder: null,
|
||||
gridOrder: 31,
|
||||
labelKey: 'Apps.skypic.name',
|
||||
route: '/apps/skypic',
|
||||
})
|
||||
expect(isPhoneAppId('skypic')).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
|
||||
).toEqual([
|
||||
@@ -188,6 +196,7 @@ describe('app registry', () => {
|
||||
).toEqual([
|
||||
'weazel-news',
|
||||
'picstagram',
|
||||
'skypic',
|
||||
'feather',
|
||||
'fliptok',
|
||||
'flare',
|
||||
|
||||
@@ -75,6 +75,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
|
||||
import flareIcon from '@/assets/img/app-icons/flare.webp'
|
||||
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
|
||||
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
|
||||
import skyPicIcon from '@/assets/img/app-icons/skypic-v2.jpg'
|
||||
import skyRideIcon from '@/assets/img/app-icons/skyride.webp'
|
||||
import musicIcon from '@/assets/img/app-icons/music.webp'
|
||||
import featherIcon from '@/assets/img/app-icons/feather.webp'
|
||||
@@ -191,6 +192,20 @@ export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
labelKey: 'Apps.picstagram.name',
|
||||
route: '/apps/picstagram',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SkyPicApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 31,
|
||||
icon: markRaw(Camera),
|
||||
iconClass: 'app-icon--skypic',
|
||||
iconImage: skyPicIcon,
|
||||
id: 'skypic',
|
||||
labelKey: 'Apps.skypic.name',
|
||||
route: '/apps/skypic',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
|
||||
@@ -85,4 +85,16 @@ describe('phone inventory contracts', () => {
|
||||
)
|
||||
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
|
||||
})
|
||||
|
||||
it('keeps non-unique phones bound to one persistent device per character', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
const migration = readResourceFile('source/server/db_migrate.lua')
|
||||
|
||||
expect(phoneServer).toContain('if not unique_phones then')
|
||||
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
||||
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
||||
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
||||
expect(migration).toContain('name = "sky_phone_character_devices"')
|
||||
expect(migration).toContain('primaryKey = "owner_identifier"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const read = (path: string) =>
|
||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
const server = read('../../sky_phone/source/server/skypic.lua')
|
||||
const migration = read('../../sky_phone/source/server/db_migrate.lua')
|
||||
const install = read('../../sky_phone/sql/install.sql')
|
||||
const media = read('../../sky_phone/source/server/media.lua')
|
||||
const config = read('../../sky_phone/config/config.lua')
|
||||
const mediaUtils = read('./utils/media.ts')
|
||||
const fallbackLocales = read('./stores/phone.ts')
|
||||
const englishLocale = read('../../sky_phone/config/locales/en.lua')
|
||||
const germanLocale = read('../../sky_phone/config/locales/de.lua')
|
||||
const mockServer = read('../testserver/index.cjs')
|
||||
|
||||
function block(source: string, startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker)
|
||||
const end = source.indexOf(endMarker, start + startMarker.length)
|
||||
expect(start, `missing ${startMarker}`).toBeGreaterThanOrEqual(0)
|
||||
expect(end, `missing ${endMarker}`).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
function migrationTable(name: string): string {
|
||||
return block(migration, `name = "sky_phone_skypic_${name}"`, 'tableOptions =')
|
||||
}
|
||||
|
||||
function installTable(name: string): string {
|
||||
return block(
|
||||
install,
|
||||
`CREATE TABLE IF NOT EXISTS \`sky_phone_skypic_${name}\``,
|
||||
') ENGINE=InnoDB',
|
||||
)
|
||||
}
|
||||
|
||||
const callbacks = [
|
||||
'bootstrap',
|
||||
'create-profile',
|
||||
'delete-account',
|
||||
'update-profile',
|
||||
'search',
|
||||
'add-friend',
|
||||
'respond-friend',
|
||||
'remove-friend',
|
||||
'block',
|
||||
'send-snap',
|
||||
'open-snap',
|
||||
'replay-snap',
|
||||
'publish-story',
|
||||
'stories',
|
||||
'view-story',
|
||||
'story-viewers',
|
||||
'remove-story',
|
||||
'spotlight-feed',
|
||||
'publish-spotlight',
|
||||
'view-spotlight',
|
||||
'like-spotlight',
|
||||
'spotlight-comments',
|
||||
'comment-spotlight',
|
||||
'delete-spotlight-comment',
|
||||
'remove-spotlight',
|
||||
'report-spotlight',
|
||||
'thread',
|
||||
'send-message',
|
||||
'mark-thread',
|
||||
'save-message',
|
||||
'delete-message',
|
||||
] as const
|
||||
|
||||
const tables: Record<string, string[]> = {
|
||||
profiles: [
|
||||
'id',
|
||||
'account_id',
|
||||
'handle',
|
||||
'display_name',
|
||||
'bio',
|
||||
'avatar_media_id',
|
||||
'avatar_seed',
|
||||
'story_privacy',
|
||||
'quick_add',
|
||||
'allow_story_replies',
|
||||
'snap_score',
|
||||
'friend_count',
|
||||
'status',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
friendships: [
|
||||
'id',
|
||||
'profile_a_id',
|
||||
'profile_b_id',
|
||||
'requested_by_id',
|
||||
'status',
|
||||
'profile_a_last_snap_on',
|
||||
'profile_b_last_snap_on',
|
||||
'streak_updated_on',
|
||||
'streak_count',
|
||||
'best_streak',
|
||||
'accepted_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
blocks: ['blocker_profile_id', 'blocked_profile_id', 'created_at'],
|
||||
messages: [
|
||||
'id',
|
||||
'friendship_id',
|
||||
'sender_profile_id',
|
||||
'recipient_profile_id',
|
||||
'message_type',
|
||||
'body',
|
||||
'caption',
|
||||
'overlay_text',
|
||||
'overlay_color',
|
||||
'media_id',
|
||||
'view_seconds',
|
||||
'allow_replay',
|
||||
'read_at',
|
||||
'opened_at',
|
||||
'replayed_at',
|
||||
'saved_at',
|
||||
'expires_at',
|
||||
'sender_deleted_at',
|
||||
'recipient_deleted_at',
|
||||
'deleted_at',
|
||||
'created_at',
|
||||
],
|
||||
stories: [
|
||||
'id',
|
||||
'profile_id',
|
||||
'media_id',
|
||||
'caption',
|
||||
'overlay_text',
|
||||
'overlay_color',
|
||||
'view_seconds',
|
||||
'privacy',
|
||||
'status',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
story_views: ['story_id', 'viewer_profile_id', 'viewed_at'],
|
||||
spotlights: [
|
||||
'id',
|
||||
'profile_id',
|
||||
'media_id',
|
||||
'caption',
|
||||
'overlay_text',
|
||||
'overlay_color',
|
||||
'kind',
|
||||
'ad_headline',
|
||||
'comments_enabled',
|
||||
'status',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
spotlight_views: ['spotlight_id', 'viewer_profile_id', 'viewed_at'],
|
||||
spotlight_likes: ['spotlight_id', 'profile_id', 'created_at'],
|
||||
spotlight_comments: [
|
||||
'id',
|
||||
'spotlight_id',
|
||||
'profile_id',
|
||||
'body',
|
||||
'status',
|
||||
'created_at',
|
||||
],
|
||||
spotlight_reports: [
|
||||
'spotlight_id',
|
||||
'reporter_profile_id',
|
||||
'reason',
|
||||
'details',
|
||||
'status',
|
||||
'created_at',
|
||||
],
|
||||
}
|
||||
|
||||
describe('SkyPic backend contracts', () => {
|
||||
it('keeps migration and clean-install schemas synchronized', () => {
|
||||
for (const [table, columns] of Object.entries(tables)) {
|
||||
const migrationSource = migrationTable(table)
|
||||
const installSource = installTable(table)
|
||||
for (const column of columns) {
|
||||
expect(migrationSource, `${table}.${column} migration`).toContain(
|
||||
`name = "${column}"`,
|
||||
)
|
||||
expect(installSource, `${table}.${column} install`).toContain(
|
||||
`\`${column}\``,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of ['messages', 'stories', 'spotlights']) {
|
||||
expect(migrationTable(table)).toContain('ON DELETE RESTRICT')
|
||||
expect(installTable(table)).toContain('ON DELETE RESTRICT')
|
||||
}
|
||||
})
|
||||
|
||||
it('backfills every normalized unique key on existing databases', () => {
|
||||
const afterMigrate = migration.slice(
|
||||
migration.indexOf('Bridge.Database.Migrate("sky_phone", schema)'),
|
||||
)
|
||||
for (const index of [
|
||||
'uniq_sky_phone_skypic_profile_account',
|
||||
'uniq_sky_phone_skypic_profile_handle',
|
||||
'uniq_sky_phone_skypic_friend_pair',
|
||||
]) {
|
||||
expect(afterMigrate).toContain(`"${index}"`)
|
||||
}
|
||||
expect(
|
||||
afterMigrate.match(/\{ unique = true \}/g)?.length ?? 0,
|
||||
).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('registers the complete canonical callback surface', () => {
|
||||
for (const callback of callbacks) {
|
||||
expect(server).toContain(
|
||||
`Bridge.Callbacks.Register("sky_phone:skypic:${callback}"`,
|
||||
)
|
||||
}
|
||||
expect(server).toContain('Bridge.Database.AfterMigration("sky_phone"')
|
||||
})
|
||||
|
||||
it('validates all submitted media through the owned-media resolver', () => {
|
||||
const editor = block(
|
||||
server,
|
||||
'local function editor_payload',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:thread"',
|
||||
)
|
||||
expect(editor).toContain(
|
||||
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)',
|
||||
)
|
||||
expect(
|
||||
server.match(/SkyPhoneMedia\.ResolveOwnedMedia\(/g)?.length,
|
||||
).toBeGreaterThanOrEqual(3)
|
||||
expect(server).toContain(
|
||||
'SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo")',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates dense photo batches before creating their cartesian snap set', () => {
|
||||
const editors = block(
|
||||
server,
|
||||
'local function snap_editor_payloads',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:thread"',
|
||||
)
|
||||
expect(config).toContain('MaximumMediaPerSend = 10')
|
||||
expect(config).toContain('MaximumSnapMessagesPerSend = 40')
|
||||
expect(editors).toContain('count > limit("MaximumMediaPerSend", 10)')
|
||||
expect(editors).toContain('if key_count ~= count then')
|
||||
expect(editors).toContain('seen[media_id]')
|
||||
expect(editors).toContain('mediaType = "photo"')
|
||||
expect(server).toContain(
|
||||
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)',
|
||||
)
|
||||
|
||||
const sendSnap = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
|
||||
)
|
||||
expect(sendSnap).toContain(
|
||||
'raw_media_count > limit("MaximumMediaPerSend", 10)',
|
||||
)
|
||||
expect(sendSnap).toContain(
|
||||
'message_count > limit("MaximumSnapMessagesPerSend", 40)',
|
||||
)
|
||||
expect(sendSnap).toContain('editor = editor')
|
||||
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(sendSnap).toContain('SELECT COUNT(*) FROM')
|
||||
expect(sendSnap).toContain(') <> ?')
|
||||
expect(sendSnap).toContain(
|
||||
'assertion_params[#assertion_params + 1] = #entries',
|
||||
)
|
||||
expect(sendSnap).toContain('message_ids[#message_ids + 1] = entry.id')
|
||||
expect(sendSnap).toContain(
|
||||
'local sent = load_snap_metadata(message_ids, profile.profile_id)',
|
||||
)
|
||||
|
||||
expect(mockServer).toContain('function skyPicMediaItems(body)')
|
||||
expect(mockServer).toContain('submitted.length > 10')
|
||||
expect(mockServer).toContain('seen.has(mediaId)')
|
||||
expect(mockServer).toContain('mediaItems.length * recipients.length > 40')
|
||||
})
|
||||
|
||||
it('maintains reciprocal UTC-day streaks and returns reconciled values', () => {
|
||||
const friendshipMigration = migrationTable('friendships')
|
||||
const friendshipInstall = installTable('friendships')
|
||||
const friends = block(
|
||||
server,
|
||||
'local function list_friends',
|
||||
'local function list_requests',
|
||||
)
|
||||
const conversations = block(
|
||||
server,
|
||||
'local function list_conversations',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:bootstrap"',
|
||||
)
|
||||
const metadata = block(
|
||||
server,
|
||||
'local function load_snap_metadata',
|
||||
'local function opened_snap_from_row',
|
||||
)
|
||||
const sendSnap = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
|
||||
)
|
||||
|
||||
for (const schema of [friendshipMigration, friendshipInstall]) {
|
||||
expect(schema).toContain('profile_a_last_snap_on')
|
||||
expect(schema).toContain('profile_b_last_snap_on')
|
||||
expect(schema).toContain('streak_updated_on')
|
||||
expect(schema).toContain('streak_count')
|
||||
expect(schema).toContain('best_streak')
|
||||
expect(schema).toContain('idx_sky_phone_skypic_streaks')
|
||||
}
|
||||
|
||||
expect(sendSnap).toContain('SET %s = UTC_DATE()')
|
||||
expect(sendSnap).toContain(
|
||||
'SELECT 1 FROM `sky_phone_skypic_messages` message WHERE message.`id` = ?',
|
||||
)
|
||||
expect(sendSnap).toContain('friendship.profile_a_id == profile.profile_id')
|
||||
expect(sendSnap).toContain('`profile_a_last_snap_on` = UTC_DATE()')
|
||||
expect(sendSnap).toContain('`profile_b_last_snap_on` = UTC_DATE()')
|
||||
expect(sendSnap).toContain(
|
||||
'`streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)',
|
||||
)
|
||||
expect(sendSnap).toContain(
|
||||
'`streak_updated_on` IS NULL OR `streak_updated_on` < UTC_DATE()',
|
||||
)
|
||||
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
|
||||
|
||||
for (const serializer of [friends, conversations, metadata]) {
|
||||
expect(serializer).toContain(
|
||||
'`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)',
|
||||
)
|
||||
expect(serializer).toContain(
|
||||
'THEN 0 ELSE friendship.`streak_count` END AS `streak_count`',
|
||||
)
|
||||
expect(serializer).toContain('friendship.`best_streak`')
|
||||
}
|
||||
expect(metadata).toContain("friendship.`status` = 'accepted'")
|
||||
expect(metadata).toContain(
|
||||
'snap.streakCount = tonumber(row.streak_count) or 0',
|
||||
)
|
||||
expect(metadata).toContain(
|
||||
'snap.bestStreak = tonumber(row.best_streak) or 0',
|
||||
)
|
||||
|
||||
expect(server).toContain('SET `streak_count` = 0')
|
||||
expect(server).toContain(
|
||||
"WHERE `status` = 'accepted' AND `streak_count` > 0",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps browser payload limits and profile creation aligned with production', () => {
|
||||
expect(config).toContain('CaptionMaxLength = 160')
|
||||
expect(server).toContain('valid_integer(data.avatarSeed, 1, 2147483647)')
|
||||
expect(mockServer).toContain('candidate <= 2_147_483_647')
|
||||
expect(mockServer).toContain(
|
||||
'return [...caption].length <= 160 ? caption : null',
|
||||
)
|
||||
expect(mockServer).toContain("error: 'invalid_avatar_seed'")
|
||||
expect(mockServer).toContain("error: 'invalid_caption'")
|
||||
expect(mockServer).toContain('value.length > 20')
|
||||
expect(mockServer).toContain("error: 'message_too_long'")
|
||||
expect(mockServer).not.toContain('body.slice(0, 1000)')
|
||||
expect(mockServer).toContain("error: 'profile_exists'")
|
||||
expect(mockServer).toContain(
|
||||
"const onboardingScenario = testScenario === 'skypic-onboarding'",
|
||||
)
|
||||
expect(mockServer).toContain(
|
||||
'onboardingScenario && skyPicOnboardingProfile',
|
||||
)
|
||||
expect(mockServer).toContain('const profile = skyPicOnboardingProfile')
|
||||
expect(mockServer).toContain('suggestions: profile')
|
||||
})
|
||||
|
||||
it('deletes only the confirmed SkyPic account and silently refreshes peers', () => {
|
||||
const deletion = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:delete-account"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:update-profile"',
|
||||
)
|
||||
expect(deletion).toContain('data.confirmed ~= true')
|
||||
expect(deletion).toContain('error = "confirmation_required"')
|
||||
expect(deletion).toContain('Bridge.Database.Transaction({')
|
||||
expect(deletion).toContain('SET peer.')
|
||||
expect(deletion).toContain('friend_count')
|
||||
expect(deletion).toContain(' > 0, peer.')
|
||||
expect(deletion).toContain('- 1, 0')
|
||||
expect(deletion).toContain('DELETE FROM')
|
||||
expect(deletion).toContain('sky_phone_skypic_profiles')
|
||||
expect(deletion).toContain('sky_phone_skypic_blocks')
|
||||
expect(deletion).toContain('UNION ALL')
|
||||
expect(deletion).toContain(
|
||||
"AND status = 'active'".replace(
|
||||
'status',
|
||||
String.fromCharCode(96) + 'status' + String.fromCharCode(96),
|
||||
),
|
||||
)
|
||||
expect(deletion).not.toContain('sky_phone_media')
|
||||
expect(deletion).toContain('"sky_phone:skypic:changed"')
|
||||
expect(deletion).not.toContain('"sky_phone:skypic:new"')
|
||||
expect(mockServer).toContain("if (endpoint === 'skypic:delete-account')")
|
||||
expect(mockServer).toContain("error: 'confirmation_required'")
|
||||
})
|
||||
|
||||
it('keeps direct snap secrets out of bootstrap and thread serializers', () => {
|
||||
const safeSnap = block(
|
||||
server,
|
||||
'local function safe_snap_from_row',
|
||||
'local function text_message_from_row',
|
||||
)
|
||||
for (const secret of [
|
||||
'url = row.url',
|
||||
'caption =',
|
||||
'textOverlay =',
|
||||
'overlayColor =',
|
||||
]) {
|
||||
expect(safeSnap).not.toContain(secret)
|
||||
}
|
||||
|
||||
const thread = block(
|
||||
server,
|
||||
'local function list_thread',
|
||||
'local function editor_payload',
|
||||
)
|
||||
expect(thread).not.toContain('message.`caption`')
|
||||
expect(thread).not.toContain('message.`overlay_text`')
|
||||
expect(thread).not.toContain('message.`overlay_color`')
|
||||
expect(thread).not.toContain('message.`media_id`')
|
||||
|
||||
const storyList = block(
|
||||
server,
|
||||
'local function list_stories',
|
||||
'local function list_conversations',
|
||||
)
|
||||
expect(storyList).not.toContain('story.`caption`')
|
||||
expect(storyList).not.toContain('story.`overlay_text`')
|
||||
expect(storyList).not.toContain('story.`overlay_color`')
|
||||
expect(storyList).not.toContain('story.`media_id`')
|
||||
expect(storyList).toContain(
|
||||
'ORDER BY (story.`profile_id` = ?) DESC, story.`created_at` DESC, story.`id` DESC',
|
||||
)
|
||||
expect(storyList).not.toContain(
|
||||
'ORDER BY (story.`profile_id` = ?) DESC, `seen`',
|
||||
)
|
||||
})
|
||||
|
||||
it('releases snap contents only after atomic one-time state changes', () => {
|
||||
const open = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:replay-snap"',
|
||||
)
|
||||
expect(open).toContain('message.`opened_at` IS NULL')
|
||||
expect(open).toContain('message.`recipient_profile_id` = ?')
|
||||
expect(open).toContain(
|
||||
'IF(message.`allow_replay` = 1, ?, message.`view_seconds`)',
|
||||
)
|
||||
expect(open.indexOf('affected_rows(result) ~= 1')).toBeLessThan(
|
||||
open.indexOf('released_snap('),
|
||||
)
|
||||
|
||||
const replay = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:replay-snap"',
|
||||
'local function own_story_metadata',
|
||||
)
|
||||
expect(replay).toContain('message.`allow_replay` = 1')
|
||||
expect(replay).toContain('message.`opened_at` IS NOT NULL')
|
||||
expect(replay).toContain('message.`replayed_at` IS NULL')
|
||||
expect(replay.indexOf('affected_rows(result) ~= 1')).toBeLessThan(
|
||||
replay.indexOf('released_snap('),
|
||||
)
|
||||
})
|
||||
|
||||
it('atomically protects every database media reference before remote delete', () => {
|
||||
const guard = block(
|
||||
media,
|
||||
'local function is_referenced_by_skypic',
|
||||
'local function delete_owned_media',
|
||||
)
|
||||
expect(guard).toContain('FROM `sky_phone_skypic_messages`')
|
||||
expect(guard).toContain('FROM `sky_phone_skypic_stories`')
|
||||
expect(guard).toContain('FROM `sky_phone_skypic_spotlights`')
|
||||
expect(guard).not.toContain('`expires_at` >')
|
||||
expect(guard).not.toContain("`status` = 'active'")
|
||||
|
||||
const deletion = block(
|
||||
media,
|
||||
'local function delete_owned_media',
|
||||
'RegisterNetEvent("sky_phone:media:delete"',
|
||||
)
|
||||
expect(deletion).toContain('return false, "media_in_use"')
|
||||
expect(deletion).toContain('AND NOT EXISTS (')
|
||||
expect(deletion).toContain('if affected_rows(result) ~= 1 then')
|
||||
expect(deletion.indexOf('DELETE FROM `sky_phone_media`')).toBeLessThan(
|
||||
deletion.indexOf('delete_remote_file(row.remote_id)'),
|
||||
)
|
||||
expect(mediaUtils).toContain("'media_in_use'")
|
||||
expect(fallbackLocales).toMatch(
|
||||
/media_in_use:\s*'This media is still used by SkyPic and cannot be deleted yet\.'/,
|
||||
)
|
||||
expect(englishLocale).toContain(
|
||||
'media_in_use = "This media is still used by SkyPic and cannot be deleted yet."',
|
||||
)
|
||||
expect(germanLocale).toContain(
|
||||
'media_in_use = "Dieses Medium wird noch von SkyPic verwendet und kann noch nicht gelöscht werden."',
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces viewer-specific deletion and bounded expiry cleanup', () => {
|
||||
expect(server).toContain('`sender_deleted_at`')
|
||||
expect(server).toContain('`recipient_deleted_at`')
|
||||
expect(server).toContain('SET `deleted_at` = CURRENT_TIMESTAMP(6)')
|
||||
expect(server).toContain('AND `sender_profile_id` = ?')
|
||||
expect(server).toContain('Wait(limit("CleanupIntervalSeconds", 45) * 1000)')
|
||||
expect(config).toContain('StoryLifetimeSeconds = 24 * 60 * 60')
|
||||
expect(config).toContain('ReplayWindowSeconds = 5 * 60')
|
||||
expect(config).toContain('TextAfterReadLifetimeSeconds = 24 * 60 * 60')
|
||||
})
|
||||
|
||||
it('gates optional story replies in the message insert itself', () => {
|
||||
const sendMessage = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:send-message"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:mark-thread"',
|
||||
)
|
||||
expect(sendMessage).toContain('local story_id =')
|
||||
expect(sendMessage).toContain('story.`profile_id` = ?')
|
||||
expect(sendMessage).toContain("story.`status` = 'active'")
|
||||
expect(sendMessage).toContain('story.`expires_at` > CURRENT_TIMESTAMP(6)')
|
||||
expect(sendMessage).toContain('author.`allow_story_replies` = 1')
|
||||
expect(sendMessage).toContain("friendship.`status` = 'accepted'")
|
||||
expect(sendMessage).toContain('NOT EXISTS (')
|
||||
expect(sendMessage).toContain('story_id and "story_reply" or "message"')
|
||||
})
|
||||
|
||||
it('returns a rich outgoing friend request from add-friend', () => {
|
||||
const addFriend = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:add-friend"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:respond-friend"',
|
||||
)
|
||||
expect(addFriend).toContain('target.friendshipId = friendship_id')
|
||||
expect(addFriend).toContain('target.friendshipStatus = "outgoing"')
|
||||
expect(addFriend).toContain('direction = "outgoing"')
|
||||
expect(addFriend).toContain('profile = target')
|
||||
})
|
||||
|
||||
it('enforces the friend limit atomically while accepting requests', () => {
|
||||
const respondFriend = block(
|
||||
server,
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:respond-friend"',
|
||||
'Bridge.Callbacks.Register("sky_phone:skypic:remove-friend"',
|
||||
)
|
||||
expect(respondFriend).toContain(
|
||||
'profile_a.`friend_count` = profile_a.`friend_count` + 1',
|
||||
)
|
||||
expect(respondFriend).toContain(
|
||||
'profile_b.`friend_count` = profile_b.`friend_count` + 1',
|
||||
)
|
||||
expect(respondFriend).toContain('profile_a.`friend_count` < ?')
|
||||
expect(respondFriend).toContain('profile_b.`friend_count` < ?')
|
||||
expect(respondFriend).toContain(
|
||||
'return { success = false, error = "friend_limit_reached" }',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps pending requests out of quick-add suggestions', () => {
|
||||
const profiles = block(
|
||||
server,
|
||||
'local function list_profiles',
|
||||
'local function list_conversations',
|
||||
)
|
||||
expect(profiles).toContain(
|
||||
'filters[#filters + 1] = "friendship.`id` IS NULL"',
|
||||
)
|
||||
expect(profiles).not.toContain("friendship.`status` = 'pending'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const read = (path: string) =>
|
||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
const app = read('./App.vue')
|
||||
const appAuth = read('./stores/app-auth.ts')
|
||||
const appIcon = read('./components/AppIcon.vue')
|
||||
const client = read('../../sky_phone/source/client/main.lua')
|
||||
const easyShare = read('../../sky_phone/source/server/easyshare.lua')
|
||||
const manifest = read('../../sky_phone/fxmanifest.lua')
|
||||
const mockServer = read('../testserver/index.cjs')
|
||||
const phoneServer = read('../../sky_phone/source/server/phone.lua')
|
||||
const reservedApps = read('../../sky_phone/source/shared/custom_apps.lua')
|
||||
const server = read('../../sky_phone/source/server/skypic.lua')
|
||||
const types = read('./types/skypic.ts')
|
||||
|
||||
const callbacks = [
|
||||
'skypic:bootstrap',
|
||||
'skypic:create-profile',
|
||||
'skypic:delete-account',
|
||||
'skypic:update-profile',
|
||||
'skypic:search',
|
||||
'skypic:add-friend',
|
||||
'skypic:respond-friend',
|
||||
'skypic:remove-friend',
|
||||
'skypic:block',
|
||||
'skypic:send-snap',
|
||||
'skypic:open-snap',
|
||||
'skypic:replay-snap',
|
||||
'skypic:publish-story',
|
||||
'skypic:stories',
|
||||
'skypic:view-story',
|
||||
'skypic:story-viewers',
|
||||
'skypic:remove-story',
|
||||
'skypic:spotlight-feed',
|
||||
'skypic:publish-spotlight',
|
||||
'skypic:view-spotlight',
|
||||
'skypic:like-spotlight',
|
||||
'skypic:spotlight-comments',
|
||||
'skypic:comment-spotlight',
|
||||
'skypic:delete-spotlight-comment',
|
||||
'skypic:remove-spotlight',
|
||||
'skypic:report-spotlight',
|
||||
'skypic:thread',
|
||||
'skypic:send-message',
|
||||
'skypic:mark-thread',
|
||||
'skypic:save-message',
|
||||
'skypic:delete-message',
|
||||
] as const
|
||||
|
||||
function typeBlock(name: string): string {
|
||||
const start = types.indexOf(`export type ${name} = {`)
|
||||
const end = types.indexOf(String.fromCharCode(10) + '}', start)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return types.slice(start, end)
|
||||
}
|
||||
|
||||
describe('SkyPic cross-runtime integration contract', () => {
|
||||
it('bridges every canonical callback through client, server, and browser mock', () => {
|
||||
for (const callback of callbacks) {
|
||||
expect(client, `missing client callback ${callback}`).toContain(
|
||||
`"${callback}"`,
|
||||
)
|
||||
expect(server, `missing server callback ${callback}`).toContain(
|
||||
`"sky_phone:${callback}"`,
|
||||
)
|
||||
expect(mockServer, `missing browser mock ${callback}`).toContain(
|
||||
`endpoint === '${callback}'`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('loads the server after media and reserves the app for built-in sharing', () => {
|
||||
const mediaIndex = manifest.indexOf("'source/server/media.lua'")
|
||||
const skyPicIndex = manifest.indexOf("'source/server/skypic.lua'")
|
||||
expect(mediaIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(skyPicIndex).toBeGreaterThan(mediaIndex)
|
||||
expect(reservedApps).toContain('skypic = true')
|
||||
expect(easyShare).toContain('skypic = true')
|
||||
})
|
||||
|
||||
it('routes localized device-aware notifications and refreshes live state', () => {
|
||||
expect(client).toContain(
|
||||
'RegisterNetEvent("sky_phone:skypic:new", function(data)',
|
||||
)
|
||||
expect(client).toContain('locale.Nui.Apps.skypic')
|
||||
expect(client).toContain(
|
||||
'notification_text:gsub("{actor}", tostring(data.actor or ""))',
|
||||
)
|
||||
expect(client).toContain(
|
||||
'SendNUIMessage({ type = "skypic:new", data = data })',
|
||||
)
|
||||
expect(client).toContain(
|
||||
'RegisterNetEvent("sky_phone:skypic:changed", function(data)',
|
||||
)
|
||||
expect(client).toContain(
|
||||
'SendNUIMessage({ type = "skypic:changed", data = data })',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'notify_profile(friendship.peer_id, profile, story_id and "story_reply" or "message", nil)',
|
||||
)
|
||||
expect(server).toContain('profileId = actor.profile_id')
|
||||
expect(phoneServer).toContain('required_app_auth')
|
||||
expect(phoneServer).toContain(
|
||||
'app_auth.accountEmail ~= device.account_email',
|
||||
)
|
||||
expect(phoneServer).toContain('has_required_app_session(device)')
|
||||
expect(server.match(/}, 'skypic'\)/g)).toHaveLength(3)
|
||||
|
||||
expect(app).toContain("event.data?.type === 'skypic:new'")
|
||||
expect(app).toContain("event.data?.type === 'skypic:changed'")
|
||||
expect(app).toContain("appId: 'skypic'")
|
||||
expect(app).toContain('route: skyPicNotificationRoute(data)')
|
||||
expect(app).toContain("query.set('profileId', data.profileId)")
|
||||
expect(app).toContain(
|
||||
"if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId)",
|
||||
)
|
||||
expect(app).toContain(
|
||||
'preferences: parsePhonePreferences(data.device.settings ?? null)',
|
||||
)
|
||||
expect(app).toContain(
|
||||
'!data.device || data.device.imei === phone.device?.imei',
|
||||
)
|
||||
expect(app).toContain('if (phone.isOpen && targetsActiveDevice)')
|
||||
expect(app).toContain('void refreshSkyPicState(true)')
|
||||
expect(app).toContain('!targetsActiveDevice || signedInOnActiveDevice')
|
||||
expect(appIcon).toContain(
|
||||
"if (props.app.id === 'skypic') return skypic.unreadCount",
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes badge state to the active unlocked device and account session', () => {
|
||||
expect(appAuth).toContain("'skypic'")
|
||||
expect(app).toContain(
|
||||
"if (!account.email || !appAuth.isSignedIn('skypic'))",
|
||||
)
|
||||
expect(app).toContain(
|
||||
"() => [phone.device?.imei ?? '', account.email] as const",
|
||||
)
|
||||
expect(app).toContain(
|
||||
'if (imei === previousImei && email === previousEmail) return',
|
||||
)
|
||||
expect(app).toContain('skypic.resetSession()')
|
||||
expect(app).toContain("appAuth.signOut('skypic')")
|
||||
expect(app).toContain('unlockedServicesLoaded.value = false')
|
||||
expect(app).toContain(
|
||||
'if (phone.isOpen && !isLocked.value && !setupRequired.value)',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let background refreshes abort auth discovery or account deletion', () => {
|
||||
const refreshBlock = app
|
||||
.split('async function refreshSkyPicState(refreshThread = false)')[1]
|
||||
?.split('function queueCompaniesChange')[0]
|
||||
expect(refreshBlock).toContain(
|
||||
'if (!skypic.bootstrapPending) skypic.resetSession()',
|
||||
)
|
||||
expect(refreshBlock).toContain('if (skypic.accountDeletePending) return')
|
||||
expect(app).toContain('!skypic.bootstrapPending')
|
||||
})
|
||||
|
||||
it('keeps snap and story secrets out of list payload types', () => {
|
||||
const snap = typeBlock('SkyPicSnap')
|
||||
const story = typeBlock('SkyPicStory')
|
||||
const openedSnap = typeBlock('SkyPicOpenedSnap')
|
||||
const viewedStory = typeBlock('SkyPicViewedStory')
|
||||
|
||||
for (const metadata of [snap, story]) {
|
||||
expect(metadata).not.toContain('url:')
|
||||
expect(metadata).not.toContain('caption:')
|
||||
expect(metadata).not.toContain('textOverlay:')
|
||||
expect(metadata).not.toContain('overlayColor:')
|
||||
}
|
||||
for (const opened of [openedSnap, viewedStory]) {
|
||||
expect(opened).toContain('url:')
|
||||
expect(opened).toContain('caption:')
|
||||
expect(opened).toContain('textOverlay:')
|
||||
expect(opened).toContain('overlayColor:')
|
||||
}
|
||||
expect(viewedStory).toContain('canReply:')
|
||||
expect(mockServer).toContain('const skyPicSnapContents = new Map(')
|
||||
expect(mockServer).toContain('const skyPicStoryContents = new Map(')
|
||||
expect(mockServer).toContain('blockedProfiles: skyPicProfiles')
|
||||
expect(mockServer).toContain(
|
||||
'skyPicIncrementOwnScore(recipients.length * mediaItems.length)',
|
||||
)
|
||||
expect(mockServer).toContain('.slice(offset, offset + 30)')
|
||||
expect(mockServer).toContain(
|
||||
"response.json({ success: false, error: 'story_unavailable' })",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,24 @@ describe('app auth store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('persists the SkyPic session independently', () => {
|
||||
const auth = useAppAuthStore()
|
||||
auth.hydrate(null, 'demo@ifruit.com')
|
||||
|
||||
auth.signIn('skypic', 'demo@ifruit.com')
|
||||
|
||||
expect(auth.isSignedIn('skypic')).toBe(true)
|
||||
expect(auth.isSignedIn('feather')).toBe(false)
|
||||
expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
|
||||
accountEmail: 'demo@ifruit.com',
|
||||
signedIn: ['skypic'],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
auth.signOut('skypic')
|
||||
expect(auth.isSignedIn('skypic')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not restore sessions belonging to another iFruit account', () => {
|
||||
const auth = useAppAuthStore()
|
||||
auth.hydrate(
|
||||
|
||||
@@ -7,6 +7,7 @@ export const APP_AUTH_IDS = [
|
||||
'local-pages',
|
||||
'feather',
|
||||
'crewlink',
|
||||
'skypic',
|
||||
] as const
|
||||
|
||||
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
|
||||
@@ -23,6 +24,7 @@ function emptySessions(): Record<AppAuthId, boolean> {
|
||||
'local-pages': false,
|
||||
feather: false,
|
||||
crewlink: false,
|
||||
skypic: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,54 @@ describe('calls store', () => {
|
||||
expect(calls.activeCall?.speakerEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the provider-authoritative mute state for a Yaca call', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { muted: true },
|
||||
})
|
||||
const calls = useCallsStore()
|
||||
calls.applyCallState({
|
||||
direction: 'outgoing',
|
||||
id: 'call-yaca-mute',
|
||||
muted: false,
|
||||
muteSupported: true,
|
||||
otherNumber: '5551110025',
|
||||
startedAt: 1,
|
||||
state: 'connected',
|
||||
})
|
||||
|
||||
const response = await calls.setMuted(true)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:set-muted', {
|
||||
enabled: true,
|
||||
id: 'call-yaca-mute',
|
||||
})
|
||||
expect(calls.activeCall?.muted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not simulate mute state for unsupported voice providers', async () => {
|
||||
const calls = useCallsStore()
|
||||
calls.applyCallState({
|
||||
direction: 'outgoing',
|
||||
id: 'call-pma-mute',
|
||||
muted: false,
|
||||
muteSupported: false,
|
||||
otherNumber: '5551110025',
|
||||
startedAt: 1,
|
||||
state: 'connected',
|
||||
})
|
||||
|
||||
const response = await calls.setMuted(true)
|
||||
|
||||
expect(response).toEqual({
|
||||
error: 'mute_unavailable',
|
||||
success: false,
|
||||
})
|
||||
expect(nuiCall).not.toHaveBeenCalled()
|
||||
expect(calls.activeCall?.muted).toBe(false)
|
||||
})
|
||||
|
||||
it('updates a contact favorite and refreshes the contact list', async () => {
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -130,6 +130,30 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
return response
|
||||
}
|
||||
|
||||
async function setMuted(
|
||||
enabled: boolean,
|
||||
): Promise<NuiResponse<{ muted: boolean }>> {
|
||||
const call = activeCall.value
|
||||
if (!call || call.state !== 'connected') {
|
||||
return { success: false, error: 'call_not_connected' }
|
||||
}
|
||||
if (!call.muteSupported) {
|
||||
return { success: false, error: 'mute_unavailable' }
|
||||
}
|
||||
|
||||
const response = await nuiCall<{ muted: boolean }>('calls:set-muted', {
|
||||
enabled,
|
||||
id: call.id,
|
||||
})
|
||||
if (response.success && response.data && activeCall.value?.id === call.id) {
|
||||
activeCall.value = {
|
||||
...activeCall.value,
|
||||
muted: response.data.muted === true,
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function blockNumber(phoneNumber: string): Promise<NuiResponse> {
|
||||
const response = await nuiCall('calls:block', { phoneNumber })
|
||||
if (response.success && activeCall.value?.otherNumber === phoneNumber) {
|
||||
@@ -179,6 +203,7 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
recents,
|
||||
saveContact,
|
||||
setContactFavorite,
|
||||
setMuted,
|
||||
setSpeaker,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -209,6 +209,242 @@ describe('phone locale fallback', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the complete SkyPic copy translated with a partial server locale', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({ locales: { Apps: { skypic: { name: 'SkyPic' } } } })
|
||||
|
||||
const skyPicKeys = [
|
||||
'name',
|
||||
'loading',
|
||||
'navigation',
|
||||
...['seconds', 'minutes', 'hours', 'days'].map((key) => 'time.' + key),
|
||||
...[
|
||||
'title',
|
||||
'eyebrow',
|
||||
'heading',
|
||||
'body',
|
||||
'displayName',
|
||||
'displayNamePlaceholder',
|
||||
'handle',
|
||||
'handlePlaceholder',
|
||||
'accountBound',
|
||||
'create',
|
||||
'creating',
|
||||
].map((key) => 'onboarding.' + key),
|
||||
...['title', 'body', 'eyebrow', 'login', 'loggingIn', 'noAccount'].map(
|
||||
(key) => 'auth.' + key,
|
||||
),
|
||||
...[
|
||||
'eyebrow',
|
||||
'title',
|
||||
'body',
|
||||
'snap',
|
||||
'story',
|
||||
'photo',
|
||||
'video',
|
||||
'gallery',
|
||||
'capturePhoto',
|
||||
'captureVideo',
|
||||
'snapHint',
|
||||
'storyHint',
|
||||
].map((key) => 'camera.' + key),
|
||||
...[
|
||||
'snapTitle',
|
||||
'storyTitle',
|
||||
'send',
|
||||
'addStory',
|
||||
'caption',
|
||||
'captionPlaceholder',
|
||||
'textOverlay',
|
||||
'textPlaceholder',
|
||||
'color',
|
||||
'duration',
|
||||
'seconds',
|
||||
'replay',
|
||||
'replayBody',
|
||||
'recipients',
|
||||
'recipientsHint',
|
||||
'recipientLimit',
|
||||
'selectedCount',
|
||||
'noFriends',
|
||||
'changeMedia',
|
||||
'sent',
|
||||
'storyPublished',
|
||||
].map((key) => 'composer.' + key),
|
||||
...['camera', 'chats', 'stories', 'friends'].map((key) => 'tabs.' + key),
|
||||
...[
|
||||
'title',
|
||||
'incoming',
|
||||
'noSnaps',
|
||||
'conversations',
|
||||
'noConversations',
|
||||
'start',
|
||||
'message',
|
||||
'threadPlaceholder',
|
||||
'messageLimit',
|
||||
'saved',
|
||||
'save',
|
||||
'unsave',
|
||||
'delete',
|
||||
'sendSnap',
|
||||
'moreActions',
|
||||
'attachPhoto',
|
||||
'takePhoto',
|
||||
'emoji',
|
||||
'attachVideo',
|
||||
'attachmentPreview',
|
||||
'removeAttachment',
|
||||
'moveAttachmentEarlier',
|
||||
'moveAttachmentLater',
|
||||
'attachmentLimit',
|
||||
'sendingAttachments',
|
||||
'photoAttachmentsSent',
|
||||
'sending',
|
||||
'failed',
|
||||
].map((key) => 'chats.' + key),
|
||||
...[
|
||||
'newVideo',
|
||||
'newPhoto',
|
||||
'replayed',
|
||||
'opened',
|
||||
'video',
|
||||
'photo',
|
||||
'replay',
|
||||
].map((key) => 'snaps.' + key),
|
||||
...[
|
||||
'title',
|
||||
'add',
|
||||
'yours',
|
||||
'friends',
|
||||
'emptyTitle',
|
||||
'emptyBody',
|
||||
'views',
|
||||
'viewers',
|
||||
'noViewers',
|
||||
'replyPlaceholder',
|
||||
'replySent',
|
||||
'replyLimit',
|
||||
'delete',
|
||||
'deleted',
|
||||
'seen',
|
||||
'unseen',
|
||||
].map((key) => 'stories.' + key),
|
||||
...[
|
||||
'title',
|
||||
'searchPlaceholder',
|
||||
'searchResults',
|
||||
'score',
|
||||
'requests',
|
||||
'sentRequests',
|
||||
'accept',
|
||||
'decline',
|
||||
'quickAdd',
|
||||
'add',
|
||||
'pending',
|
||||
'cancelRequest',
|
||||
'all',
|
||||
'empty',
|
||||
'remove',
|
||||
'block',
|
||||
'blockedProfiles',
|
||||
'unblock',
|
||||
'chat',
|
||||
'sendSnap',
|
||||
'respond',
|
||||
'friends',
|
||||
'requestSent',
|
||||
'requestCanceled',
|
||||
'removed',
|
||||
'blocked',
|
||||
'unblocked',
|
||||
].map((key) => 'friends.' + key),
|
||||
...[
|
||||
'title',
|
||||
'edit',
|
||||
'save',
|
||||
'cancel',
|
||||
'score',
|
||||
'streaks',
|
||||
'friends',
|
||||
'bio',
|
||||
'bioPlaceholder',
|
||||
'storyPrivacy',
|
||||
'privacyFriends',
|
||||
'privacyEveryone',
|
||||
'allowStoryReplies',
|
||||
'allowStoryRepliesBody',
|
||||
'showInQuickAdd',
|
||||
'showInQuickAddBody',
|
||||
'saved',
|
||||
'account',
|
||||
'logout',
|
||||
'logoutTitle',
|
||||
'logoutBody',
|
||||
'loggingOut',
|
||||
'deleteAccount',
|
||||
'deleteAccountTitle',
|
||||
'deleteAccountBody',
|
||||
'deletingAccount',
|
||||
].map((key) => 'profile.' + key),
|
||||
...['close', 'timeLeft'].map((key) => 'viewer.' + key),
|
||||
...[
|
||||
'friend_request',
|
||||
'friend_accepted',
|
||||
'snap',
|
||||
'message',
|
||||
'story_reply',
|
||||
'snap_opened',
|
||||
'default',
|
||||
].map((key) => 'notifications.' + key),
|
||||
...[
|
||||
'profile_required',
|
||||
'profile_exists',
|
||||
'invalid_handle',
|
||||
'handle_taken',
|
||||
'invalid_display_name',
|
||||
'invalid_bio',
|
||||
'invalid_avatar',
|
||||
'invalid_avatar_seed',
|
||||
'invalid_privacy',
|
||||
'invalid_request',
|
||||
'profile_not_found',
|
||||
'blocked',
|
||||
'friendship_not_found',
|
||||
'friend_request_exists',
|
||||
'friend_limit_reached',
|
||||
'request_limit_reached',
|
||||
'invalid_recipients',
|
||||
'invalid_media',
|
||||
'invalid_media_type',
|
||||
'invalid_duration',
|
||||
'invalid_caption',
|
||||
'invalid_overlay',
|
||||
'invalid_color',
|
||||
'snap_unavailable',
|
||||
'replay_unavailable',
|
||||
'message_empty',
|
||||
'message_too_long',
|
||||
'message_not_found',
|
||||
'story_limit_reached',
|
||||
'story_unavailable',
|
||||
'not_authorized',
|
||||
'confirmation_required',
|
||||
'too_many_snaps',
|
||||
'rate_limited',
|
||||
'request_timeout',
|
||||
'request_failed',
|
||||
'not_authenticated',
|
||||
'unknown_error',
|
||||
'default',
|
||||
].map((key) => 'errors.' + key),
|
||||
]
|
||||
|
||||
for (const key of skyPicKeys) {
|
||||
const path = 'Apps.skypic.' + key
|
||||
expect(phone.t(path), path).not.toBe(path)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the English Lua payload before the bundled emergency fallback', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({
|
||||
|
||||
@@ -167,4 +167,66 @@ describe('phone device persistence scope', () => {
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(phone.deviceRevisions.widgets).toBe(1)
|
||||
})
|
||||
|
||||
it('marks setup complete only after the settings save is acknowledged', async () => {
|
||||
const completion = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall.mockReturnValueOnce(completion.promise)
|
||||
const phone = usePhoneStore()
|
||||
phone.open({
|
||||
device: {
|
||||
data: {
|
||||
settings: {
|
||||
payload: {
|
||||
settings: { setupCompleted: false, setupStep: 9 },
|
||||
version: 1,
|
||||
},
|
||||
revision: 3,
|
||||
},
|
||||
},
|
||||
imei: '111',
|
||||
name: 'Phone 111',
|
||||
sim: null,
|
||||
},
|
||||
token: 'session-a',
|
||||
})
|
||||
|
||||
const completed = phone.completeSetup()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(phone.preferences.settings.setupCompleted).toBe(false)
|
||||
completion.resolve({ data: { revision: 4 }, success: true })
|
||||
|
||||
await expect(completed).resolves.toBe(true)
|
||||
expect(phone.preferences.settings.setupCompleted).toBe(true)
|
||||
expect(phone.deviceRevisions.settings).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps setup open when the completion save is rejected', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'request_failed',
|
||||
success: false,
|
||||
})
|
||||
const phone = usePhoneStore()
|
||||
phone.open({
|
||||
device: {
|
||||
data: {
|
||||
settings: {
|
||||
payload: {
|
||||
settings: { setupCompleted: false, setupStep: 9 },
|
||||
version: 1,
|
||||
},
|
||||
revision: 3,
|
||||
},
|
||||
},
|
||||
imei: '111',
|
||||
name: 'Phone 111',
|
||||
sim: null,
|
||||
},
|
||||
token: 'session-a',
|
||||
})
|
||||
|
||||
await expect(phone.completeSetup()).resolves.toBe(false)
|
||||
expect(phone.preferences.settings.setupCompleted).toBe(false)
|
||||
expect(phone.deviceRevisions.settings).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
+520
-45
@@ -43,7 +43,7 @@ export type PhoneOpenPayload = {
|
||||
token?: string
|
||||
}
|
||||
|
||||
const namespaceQueues = new Map<string, Promise<void>>()
|
||||
const namespaceQueues = new Map<string, Promise<boolean>>()
|
||||
let nextPersistenceSession = 0
|
||||
|
||||
const companiesFallbackLocales = {
|
||||
@@ -1643,6 +1643,306 @@ const defaultLocales: LocaleTree = {
|
||||
default: 'Picstagram could not complete the request.',
|
||||
},
|
||||
},
|
||||
skypic: {
|
||||
name: 'SkyPic',
|
||||
loading: 'Loading SkyPic',
|
||||
navigation: 'SkyPic navigation',
|
||||
time: {
|
||||
seconds: '{count}s ago',
|
||||
minutes: '{count}m ago',
|
||||
hours: '{count}h ago',
|
||||
days: '{count}d ago',
|
||||
},
|
||||
onboarding: {
|
||||
title: 'Welcome to SkyPic',
|
||||
eyebrow: 'Your private camera network',
|
||||
heading: 'Share the moment',
|
||||
body: 'Create a profile for private snaps, close-friend stories, Spotlight videos, and quick chats.',
|
||||
displayName: 'Display name',
|
||||
displayNamePlaceholder: 'Your name',
|
||||
handle: 'Handle',
|
||||
handlePlaceholder: 'your.handle',
|
||||
accountBound:
|
||||
'Your SkyPic profile stays linked to this Sky Cloud account.',
|
||||
create: 'Create profile',
|
||||
creating: 'Creating...',
|
||||
},
|
||||
auth: {
|
||||
eyebrow: 'Your SkyPic account',
|
||||
title: 'Welcome back',
|
||||
body: 'Continue with your iFruit account to access your SkyPic profile, snaps, and chats.',
|
||||
login: 'Continue to SkyPic',
|
||||
loggingIn: 'Signing in...',
|
||||
noAccount: 'New to SkyPic? Create an iFruit account to get started.',
|
||||
},
|
||||
camera: {
|
||||
eyebrow: 'Sky camera',
|
||||
title: 'Capture',
|
||||
body: 'Take a photo or video and share it for just a few seconds.',
|
||||
snap: 'Snap',
|
||||
story: 'Story',
|
||||
photo: 'Photo',
|
||||
video: 'Video',
|
||||
gallery: 'Open gallery',
|
||||
capturePhoto: 'Take photo',
|
||||
captureVideo: 'Record video',
|
||||
snapHint: 'Send it privately to one or more friends.',
|
||||
storyHint: 'Share it with your story for 24 hours.',
|
||||
},
|
||||
composer: {
|
||||
snapTitle: 'New snap',
|
||||
storyTitle: 'New story',
|
||||
spotlightTitle: 'New Spotlight',
|
||||
send: 'Send',
|
||||
addStory: 'Add to story',
|
||||
publishSpotlight: 'Publish',
|
||||
caption: 'Caption',
|
||||
captionPlaceholder: 'Add a caption...',
|
||||
textOverlay: 'Text overlay',
|
||||
textPlaceholder: 'Put words on the moment...',
|
||||
color: 'Text color',
|
||||
duration: 'View duration',
|
||||
seconds: '{count} seconds',
|
||||
replay: 'Allow one replay',
|
||||
replayBody: 'Recipients may open this snap one additional time.',
|
||||
recipients: 'Recipients',
|
||||
recipientsHint: 'Choose one or more friends.',
|
||||
recipientLimit: 'Choose up to {count} friends.',
|
||||
selectedCount: '{count} selected',
|
||||
noFriends: 'Add a friend before sending a snap.',
|
||||
sponsored: 'Sponsored post',
|
||||
sponsoredBody: 'Clearly label this Spotlight as advertising.',
|
||||
adHeadline: 'Ad headline',
|
||||
adHeadlinePlaceholder: 'What should people discover?',
|
||||
allowComments: 'Allow comments',
|
||||
allowCommentsBody: 'People can comment on this Spotlight.',
|
||||
changeMedia: 'Change photo or video',
|
||||
sent: 'Snap sent.',
|
||||
storyPublished: 'Story published.',
|
||||
spotlightPublished: 'Spotlight published.',
|
||||
},
|
||||
tabs: {
|
||||
camera: 'Camera',
|
||||
chats: 'Chats',
|
||||
stories: 'Stories',
|
||||
friends: 'Friends',
|
||||
spotlight: 'Spotlight',
|
||||
},
|
||||
spotlight: {
|
||||
title: 'Spotlight',
|
||||
add: 'Create Spotlight',
|
||||
empty: 'No Spotlight videos yet',
|
||||
emptyBody: 'Record the first short video for the SkyPic community.',
|
||||
videoBy: 'Spotlight video by {name}',
|
||||
sponsored: 'Sponsored',
|
||||
viewProfile: 'View creator profile',
|
||||
like: 'Like',
|
||||
comments: 'Comments',
|
||||
views: 'Views',
|
||||
noComments: 'No comments yet',
|
||||
commentPlaceholder: 'Write a comment...',
|
||||
sendComment: 'Send',
|
||||
commentsDisabled: 'Comments are disabled for this Spotlight.',
|
||||
deleteComment: 'Delete comment',
|
||||
delete: 'Delete Spotlight',
|
||||
deleted: 'Spotlight deleted.',
|
||||
report: 'Report Spotlight',
|
||||
reportBody: 'Tell us why this Spotlight should be reviewed.',
|
||||
submitReport: 'Submit report',
|
||||
reported: 'Spotlight reported.',
|
||||
navigation: 'Spotlight navigation',
|
||||
previous: 'Previous Spotlight',
|
||||
next: 'Next Spotlight',
|
||||
reportReasons: {
|
||||
spam: 'Spam or misleading',
|
||||
harassment: 'Harassment',
|
||||
dangerous: 'Dangerous content',
|
||||
illegal: 'Illegal content',
|
||||
other: 'Other',
|
||||
},
|
||||
},
|
||||
chats: {
|
||||
title: 'Chats',
|
||||
incoming: 'New snaps',
|
||||
noSnaps: 'No unopened snaps',
|
||||
conversations: 'Conversations',
|
||||
noConversations: 'Your conversations will appear here.',
|
||||
start: 'Start a conversation',
|
||||
message: 'Message',
|
||||
threadPlaceholder: 'Write a message...',
|
||||
messageLimit: 'Messages can contain up to {count} characters.',
|
||||
saved: 'Saved in chat',
|
||||
save: 'Save',
|
||||
unsave: 'Unsave',
|
||||
delete: 'Delete',
|
||||
sendSnap: 'Send a snap',
|
||||
moreActions: 'More actions',
|
||||
attachPhoto: 'Attach photos',
|
||||
takePhoto: 'Take photo',
|
||||
emoji: 'Emoji',
|
||||
attachVideo: 'Attach video',
|
||||
attachmentPreview: 'Selected attachments',
|
||||
removeAttachment: 'Remove attachment {number}',
|
||||
moveAttachmentEarlier: 'Move attachment {number} earlier',
|
||||
moveAttachmentLater: 'Move attachment {number} later',
|
||||
attachmentLimit: 'You can attach up to {count} photos.',
|
||||
sendingAttachments: 'Sending photos...',
|
||||
photoAttachmentsSent: 'Photos sent.',
|
||||
sending: 'Sending...',
|
||||
failed: 'Not delivered',
|
||||
},
|
||||
snaps: {
|
||||
newVideo: 'New video snap',
|
||||
newPhoto: 'New photo snap',
|
||||
replayed: 'Replayed',
|
||||
opened: 'Opened',
|
||||
video: 'Video snap',
|
||||
photo: 'Photo snap',
|
||||
replay: 'Replay snap',
|
||||
},
|
||||
stories: {
|
||||
title: 'Stories',
|
||||
add: 'Add story',
|
||||
yours: 'Your story',
|
||||
friends: 'Friends',
|
||||
emptyTitle: 'No stories right now',
|
||||
emptyBody: 'Stories from your friends will appear here for 24 hours.',
|
||||
views: '{count} views',
|
||||
viewers: 'Viewers',
|
||||
noViewers: 'No views yet',
|
||||
replyPlaceholder: 'Reply to this story...',
|
||||
replySent: 'Reply sent.',
|
||||
replyLimit: 'Replies can contain up to {count} characters.',
|
||||
delete: 'Delete story',
|
||||
deleted: 'Story deleted.',
|
||||
seen: 'Seen',
|
||||
unseen: 'New',
|
||||
},
|
||||
friends: {
|
||||
title: 'Friends',
|
||||
searchPlaceholder: 'Search name or @handle',
|
||||
searchResults: 'Search results',
|
||||
score: '{count} points',
|
||||
requests: 'Friend requests',
|
||||
sentRequests: 'Sent requests',
|
||||
accept: 'Accept',
|
||||
decline: 'Decline',
|
||||
quickAdd: 'Quick Add',
|
||||
add: 'Add',
|
||||
pending: 'Pending',
|
||||
cancelRequest: 'Cancel request',
|
||||
all: 'Your friends',
|
||||
empty: 'Add friends to start snapping.',
|
||||
remove: 'Remove friend',
|
||||
block: 'Block',
|
||||
blockedProfiles: 'Blocked profiles',
|
||||
unblock: 'Unblock',
|
||||
chat: 'Chat',
|
||||
sendSnap: 'Send snap',
|
||||
respond: 'Respond',
|
||||
friends: 'Friends',
|
||||
requestSent: 'Friend request sent to {name}.',
|
||||
requestCanceled: 'Friend request to {name} canceled.',
|
||||
removed: '{name} was removed from your friends.',
|
||||
blocked: '{name} was blocked.',
|
||||
unblocked: '{name} was unblocked.',
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile',
|
||||
edit: 'Edit profile',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
score: 'Snap score',
|
||||
streaks: 'Streaks',
|
||||
friends: 'Friends',
|
||||
bio: 'Bio',
|
||||
bioPlaceholder: 'Tell your friends a little about you...',
|
||||
storyPrivacy: 'Story privacy',
|
||||
privacyFriends: 'Friends only',
|
||||
privacyEveryone: 'Everyone',
|
||||
allowStoryReplies: 'Allow story replies',
|
||||
allowStoryRepliesBody: 'Friends can reply to your stories in chat.',
|
||||
showInQuickAdd: 'Show in Quick Add',
|
||||
showInQuickAddBody: 'Let other profiles discover you as a suggestion.',
|
||||
saved: 'Profile updated.',
|
||||
account: 'Account',
|
||||
logout: 'Sign out',
|
||||
logoutTitle: 'Sign out of SkyPic?',
|
||||
logoutBody:
|
||||
'You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.',
|
||||
loggingOut: 'Signing out...',
|
||||
deleteAccount: 'Delete SkyPic account',
|
||||
deleteAccountTitle: 'Delete your SkyPic account?',
|
||||
deleteAccountBody:
|
||||
'Your SkyPic profile, friends, snaps, stories, Spotlights, comments, and chats will be permanently deleted. Your iFruit account and Photos library stay available.',
|
||||
deletingAccount: 'Deleting account...',
|
||||
},
|
||||
viewer: {
|
||||
close: 'Close',
|
||||
timeLeft: '{count}s',
|
||||
},
|
||||
notifications: {
|
||||
friend_request: '{actor} sent you a friend request.',
|
||||
friend_accepted: '{actor} accepted your friend request.',
|
||||
snap: '{actor} sent you a snap.',
|
||||
message: '{actor} sent you a message.',
|
||||
story_reply: '{actor} replied to your story.',
|
||||
snap_opened: '{actor} opened your snap.',
|
||||
default: 'You have new SkyPic activity.',
|
||||
},
|
||||
errors: {
|
||||
profile_required: 'Create your SkyPic profile first.',
|
||||
profile_exists: 'This account already has a SkyPic profile.',
|
||||
invalid_handle: 'Use 3-24 letters, numbers, dots, or underscores.',
|
||||
handle_taken: 'This handle is already taken.',
|
||||
invalid_display_name: 'Enter a display name.',
|
||||
invalid_bio: 'Your bio is too long.',
|
||||
invalid_avatar: 'Choose a valid profile photo.',
|
||||
invalid_avatar_seed: 'Choose a valid avatar.',
|
||||
invalid_privacy: 'Choose a valid story privacy setting.',
|
||||
invalid_request: 'This friend request is invalid.',
|
||||
profile_not_found: 'This SkyPic profile is unavailable.',
|
||||
blocked: 'This profile is blocked.',
|
||||
friendship_not_found: 'This friendship is unavailable.',
|
||||
friend_request_exists: 'A friend request already exists.',
|
||||
friend_limit_reached: 'Your friends list is full.',
|
||||
request_limit_reached: 'Too many open friend requests.',
|
||||
invalid_recipients: 'Choose at least one current friend.',
|
||||
invalid_media: 'Choose media from this phone.',
|
||||
invalid_media_type: 'This media type is not supported.',
|
||||
invalid_duration: 'Choose a view time from 1 to 10 seconds.',
|
||||
invalid_caption: 'This caption is too long.',
|
||||
invalid_overlay: 'This text overlay is too long.',
|
||||
invalid_color: 'Choose a valid overlay color.',
|
||||
snap_unavailable: 'This snap is no longer available.',
|
||||
replay_unavailable: 'This snap cannot be replayed again.',
|
||||
message_empty: 'Write a message before sending.',
|
||||
message_too_long: 'Messages can contain at most 2000 characters.',
|
||||
message_not_found: 'This message is unavailable.',
|
||||
story_limit_reached: 'Your active story limit is reached.',
|
||||
story_unavailable: 'This story is no longer available.',
|
||||
spotlight_unavailable: 'This Spotlight is no longer available.',
|
||||
spotlight_limit_reached: 'Your active Spotlight limit is reached.',
|
||||
sponsored_limit_reached:
|
||||
'Your active sponsored Spotlight limit is reached.',
|
||||
ads_disabled: 'Sponsored Spotlights are disabled.',
|
||||
invalid_ad_headline: 'Enter an ad headline with 3 to 80 characters.',
|
||||
invalid_comment: 'Enter a valid comment.',
|
||||
comments_disabled: 'Comments are disabled.',
|
||||
comment_not_found: 'This comment is unavailable.',
|
||||
invalid_report: 'Choose a valid report reason.',
|
||||
not_authorized: 'You are not allowed to do that.',
|
||||
confirmation_required:
|
||||
'Confirm that you want to delete your SkyPic account.',
|
||||
too_many_snaps: 'Choose fewer photos or recipients.',
|
||||
rate_limited: 'Slow down for a moment and try again.',
|
||||
request_timeout: 'The SkyPic request timed out. Try again.',
|
||||
request_failed: 'SkyPic could not complete the request.',
|
||||
not_authenticated: 'Sign in to Sky Cloud first.',
|
||||
unknown_error: 'SkyPic could not complete the request.',
|
||||
default: 'SkyPic could not complete the request.',
|
||||
},
|
||||
},
|
||||
feather: {
|
||||
name: 'Feather',
|
||||
loading: 'Loading Feather',
|
||||
@@ -2397,6 +2697,7 @@ const defaultLocales: LocaleTree = {
|
||||
companies: 'Businesses, jobs and services',
|
||||
music: 'Songs, playlists and audio',
|
||||
picstagram: 'Photo sharing and social feed',
|
||||
skypic: 'Private snaps, stories and close friends',
|
||||
feather: 'Short posts and city conversations',
|
||||
fliptok: 'Short videos and trends',
|
||||
flare: 'Social posts and live moments',
|
||||
@@ -2432,46 +2733,195 @@ const defaultLocales: LocaleTree = {
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
previews: {
|
||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||
companies: { first: 'Business directory', 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' },
|
||||
citywarn: {
|
||||
first: 'Live alerts',
|
||||
second: 'Safety zones',
|
||||
third: 'Incident updates',
|
||||
},
|
||||
crypto: {
|
||||
first: 'Synthetic markets',
|
||||
second: 'Portfolio',
|
||||
third: 'Wallet transfers',
|
||||
},
|
||||
health: {
|
||||
first: 'Activity rings',
|
||||
second: 'Medical ID',
|
||||
third: 'Health records',
|
||||
},
|
||||
'weazel-news': {
|
||||
first: 'Top stories',
|
||||
second: 'Local reports',
|
||||
third: 'Breaking news',
|
||||
},
|
||||
companies: {
|
||||
first: 'Business directory',
|
||||
second: 'Job requests',
|
||||
third: 'Services',
|
||||
},
|
||||
music: {
|
||||
first: 'Now playing',
|
||||
second: 'Playlists',
|
||||
third: 'Music library',
|
||||
},
|
||||
picstagram: {
|
||||
first: 'Photo feed',
|
||||
second: 'Stories',
|
||||
third: 'Profiles',
|
||||
},
|
||||
skypic: {
|
||||
first: 'Private snaps',
|
||||
second: 'Friend stories',
|
||||
third: 'Quick chats',
|
||||
},
|
||||
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' },
|
||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||
memos: {
|
||||
first: 'Voice recordings',
|
||||
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' },
|
||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||
weather: {
|
||||
first: 'Current weather',
|
||||
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' },
|
||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||
'number-merge': { 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' },
|
||||
memory: {
|
||||
first: 'Matched pairs',
|
||||
second: 'Best time',
|
||||
third: 'Card themes',
|
||||
},
|
||||
'number-merge': {
|
||||
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: {
|
||||
recommended: 'Recommended',
|
||||
@@ -2658,11 +3108,16 @@ const defaultLocales: LocaleTree = {
|
||||
readonly_contact: 'Official company contacts cannot be changed.',
|
||||
rate_limited: 'Too many calls. Try again in a minute.',
|
||||
voice_unavailable: 'The configured phone voice service is unavailable.',
|
||||
call_not_connected: 'Connect the call before enabling speaker mode.',
|
||||
call_not_connected:
|
||||
'Connect the call before changing its audio controls.',
|
||||
speaker_unavailable:
|
||||
'Speaker mode is not available for the configured phone voice service.',
|
||||
speaker_unsupported:
|
||||
'The configured phone voice service does not support speaker mode.',
|
||||
mute_unavailable:
|
||||
'Mute is not available for the configured phone voice service.',
|
||||
mute_unsupported:
|
||||
'The configured phone voice service does not support mute.',
|
||||
inventory_full: 'There is no room for the ejected SIM card.',
|
||||
operation_in_progress:
|
||||
'Another phone operation is already in progress.',
|
||||
@@ -4553,6 +5008,8 @@ const defaultLocales: LocaleTree = {
|
||||
import_url_not_allowed: 'This link is not from the selected website.',
|
||||
import_url_unavailable: 'The linked media could not be reached.',
|
||||
import_size_unavailable: 'The website did not provide the media size.',
|
||||
media_in_use:
|
||||
'This media is still used by SkyPic and cannot be deleted yet.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
profile_photo_required:
|
||||
'This is the last photo on your Flare profile. Add another profile photo before deleting it.',
|
||||
@@ -4944,6 +5401,8 @@ const defaultLocales: LocaleTree = {
|
||||
localOnly: 'Stored on this phone',
|
||||
enter: 'Enter Sky Phone',
|
||||
review: 'Review Setup',
|
||||
saveFailed: 'Setup could not be saved. Try again.',
|
||||
saving: 'Saving setup',
|
||||
},
|
||||
},
|
||||
Common: {
|
||||
@@ -4960,8 +5419,10 @@ const defaultLocales: LocaleTree = {
|
||||
pause: 'Pause',
|
||||
phone: 'Phone',
|
||||
phoneStatus: 'Phone status',
|
||||
retry: 'Try Again',
|
||||
reset: 'Reset',
|
||||
loading: 'Loading',
|
||||
loadMore: 'Load More',
|
||||
search: 'Search',
|
||||
save: 'Save',
|
||||
send: 'Send',
|
||||
@@ -5218,13 +5679,13 @@ export const usePhoneStore = defineStore('phone', {
|
||||
JSON.stringify(device.data.settings?.payload ?? null),
|
||||
)
|
||||
},
|
||||
saveDeviceNamespace(namespace: string, payload: unknown): void {
|
||||
saveDeviceNamespace(namespace: string, payload: unknown): Promise<boolean> {
|
||||
const imei = this.device?.imei
|
||||
if (!imei) {
|
||||
console.error(
|
||||
`[Phone persistence] Could not save ${namespace} without an active device.`,
|
||||
)
|
||||
return
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const generation = this.persistenceGeneration
|
||||
const session = this.persistenceSession
|
||||
@@ -5238,7 +5699,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.deviceSessionToken === token
|
||||
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
|
||||
const queued = previous.then(async () => {
|
||||
if (!isCurrentScope()) return
|
||||
if (!isCurrentScope()) return false
|
||||
const response = await nuiCall<{ revision: number }>('device:save', {
|
||||
imei,
|
||||
namespace,
|
||||
@@ -5253,13 +5714,21 @@ export const usePhoneStore = defineStore('phone', {
|
||||
Number(response.data?.revision) >= 0
|
||||
) {
|
||||
this.deviceRevisions[namespace] = Number(response.data?.revision)
|
||||
return true
|
||||
}
|
||||
if (isCurrentScope()) {
|
||||
console.error(
|
||||
`[Phone persistence] Could not save ${namespace}: ${response.error ?? 'request_failed'}`,
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
const tracked = queued.finally(() => {
|
||||
if (namespaceQueues.get(queueKey) === tracked)
|
||||
namespaceQueues.delete(queueKey)
|
||||
})
|
||||
namespaceQueues.set(queueKey, tracked)
|
||||
return tracked
|
||||
},
|
||||
async flushDevicePersistence(): Promise<void> {
|
||||
const imei = this.device?.imei
|
||||
@@ -5324,10 +5793,16 @@ export const usePhoneStore = defineStore('phone', {
|
||||
)
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
completeSetup(): void {
|
||||
this.preferences.settings.setupCompleted = true
|
||||
this.preferences.settings.setupStep = PHONE_SETUP_LAST_STEP
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
async completeSetup(): Promise<boolean> {
|
||||
const completedPreferences = cloneJsonData(this.preferences)
|
||||
completedPreferences.settings.setupCompleted = true
|
||||
completedPreferences.settings.setupStep = PHONE_SETUP_LAST_STEP
|
||||
const saved = await this.saveDeviceNamespace(
|
||||
'settings',
|
||||
completedPreferences,
|
||||
)
|
||||
if (saved) this.preferences = completedPreferences
|
||||
return saved
|
||||
},
|
||||
resetAfterFactoryReset(): void {
|
||||
this.persistenceGeneration += 1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
const config = readResourceFile('config/config.lua')
|
||||
const testData = readResourceFile('source/server/testdata.lua')
|
||||
|
||||
describe('test data seeding contracts', () => {
|
||||
it('keeps test data disabled while retaining the development phone command', () => {
|
||||
expect(config).toContain('DevelopmentCommand = true')
|
||||
expect(config).toContain(
|
||||
'Enabled = false, -- development/test servers only',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns before registering the test-data command when disabled', () => {
|
||||
expect(
|
||||
testData.indexOf('if not Config.TestData.Enabled then'),
|
||||
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
|
||||
})
|
||||
|
||||
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
||||
expect(testData).toContain('local function move_sim_to_device')
|
||||
expect(testData).toContain(
|
||||
'SET `sim_id` = NULL WHERE `sim_id` = ? AND `imei` <> ?',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'local previous_imei = move_sim_to_device(sim.id, imei)',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'restore_sim_attachment(sim.id, imei, previous_imei)',
|
||||
)
|
||||
})
|
||||
|
||||
it('derives distinct DarkChat identifiers from each account', () => {
|
||||
expect(testData).toContain(
|
||||
'local function darkchat_identifiers(account_id)',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'local user_dark_id, user_invite_code = darkchat_identifiers(account_id)',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'local bot_dark_id, bot_invite_code = darkchat_identifiers(bot_id)',
|
||||
)
|
||||
expect(testData).not.toContain("'DARK0000000001'")
|
||||
expect(testData).not.toContain("'INV00000001'")
|
||||
})
|
||||
|
||||
it('loads the persisted Flare match before inserting its test message', () => {
|
||||
const matchLookup = testData.indexOf(
|
||||
'SELECT `id` FROM `sky_phone_flare_matches`',
|
||||
)
|
||||
const messageInsert = testData.indexOf(
|
||||
'INSERT INTO `sky_phone_flare_messages`',
|
||||
)
|
||||
|
||||
expect(matchLookup).toBeGreaterThan(-1)
|
||||
expect(messageInsert).toBeGreaterThan(matchLookup)
|
||||
expect(testData).toContain(
|
||||
'stable_uuid("sky_phone:testdata:flare:message:" .. match_id)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'flare'
|
||||
| 'fliptok'
|
||||
| 'picstagram'
|
||||
| 'skypic'
|
||||
| 'skyride'
|
||||
| 'feather'
|
||||
| 'crewlink'
|
||||
|
||||
@@ -3,6 +3,7 @@ export type HousingAccess = 'owner' | 'keyholder'
|
||||
export type HousingCapabilities = {
|
||||
cctv: boolean
|
||||
garageStatus: boolean
|
||||
keyGrant?: boolean
|
||||
keys: boolean
|
||||
lock: boolean
|
||||
waypoint: boolean
|
||||
@@ -24,7 +25,7 @@ export type HousingProperty = {
|
||||
access: HousingAccess
|
||||
capabilities: HousingCapabilities
|
||||
cctv: { enabled: boolean }
|
||||
entrance: { x: number; y: number; z: number }
|
||||
entrance?: { x: number; y: number; z: number }
|
||||
garage: { enabled: boolean; storedVehicles: number } | null
|
||||
id: string
|
||||
keys?: HousingKey[]
|
||||
|
||||
@@ -48,6 +48,8 @@ export type PhoneCall = {
|
||||
device?: { imei: string; name: string }
|
||||
direction: CallDirection
|
||||
id: string
|
||||
muted?: boolean
|
||||
muteSupported?: boolean
|
||||
otherNumber: string
|
||||
speakerEnabled?: boolean
|
||||
speakerSupported?: boolean
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
|
||||
export type SkyPicStoryPrivacy = 'everyone' | 'friends'
|
||||
export type SkyPicDirection = 'received' | 'sent'
|
||||
export type SkyPicSnapType = 'snap_photo' | 'snap_video'
|
||||
export type SkyPicFriendshipStatus =
|
||||
| 'friends'
|
||||
| 'incoming'
|
||||
| 'none'
|
||||
| 'outgoing'
|
||||
|
||||
export type SkyPicProfileSummary = {
|
||||
avatarSeed: number
|
||||
avatarUrl: string | null
|
||||
displayName: string
|
||||
friendshipId?: string | null
|
||||
friendshipStatus: SkyPicFriendshipStatus
|
||||
handle: string
|
||||
id: string
|
||||
snapScore: number
|
||||
}
|
||||
|
||||
export type SkyPicProfile = SkyPicProfileSummary & {
|
||||
allowStoryReplies: boolean
|
||||
avatarMediaId: number | null
|
||||
bio: string
|
||||
friendCount: number
|
||||
showInQuickAdd: boolean
|
||||
storyPrivacy: SkyPicStoryPrivacy
|
||||
}
|
||||
|
||||
export type SkyPicCreateProfileInput = {
|
||||
avatarMediaId?: number
|
||||
avatarSeed?: number
|
||||
displayName: string
|
||||
handle: string
|
||||
}
|
||||
|
||||
export type SkyPicUpdateProfileInput = {
|
||||
allowStoryReplies: boolean
|
||||
avatarMediaId?: number | null
|
||||
avatarSeed?: number
|
||||
bio: string
|
||||
displayName: string
|
||||
handle: string
|
||||
showInQuickAdd: boolean
|
||||
storyPrivacy: SkyPicStoryPrivacy
|
||||
}
|
||||
|
||||
export type SkyPicFriend = {
|
||||
bestStreak: number
|
||||
createdAt: string
|
||||
friendshipId: string
|
||||
profile: SkyPicProfileSummary
|
||||
streakCount: number
|
||||
}
|
||||
|
||||
export type SkyPicFriendRequest = {
|
||||
createdAt: string
|
||||
direction: 'incoming' | 'outgoing'
|
||||
friendshipId: string
|
||||
profile: SkyPicProfileSummary
|
||||
}
|
||||
|
||||
export type SkyPicConversationLastItem = {
|
||||
body?: string
|
||||
createdAt: string
|
||||
direction: SkyPicDirection
|
||||
id: string
|
||||
openedAt: string | null
|
||||
type: 'snap_photo' | 'snap_video' | 'text'
|
||||
}
|
||||
|
||||
export type SkyPicConversation = {
|
||||
bestStreak: number
|
||||
friendshipId: string
|
||||
lastItem: SkyPicConversationLastItem | null
|
||||
profile: SkyPicProfileSummary
|
||||
streakCount: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
/** Direct snap lists intentionally contain no media URL or editor contents. */
|
||||
export type SkyPicSnap = {
|
||||
allowReplay: boolean
|
||||
/** Present on successful sends so friendship streak UI can reconcile immediately. */
|
||||
bestStreak?: number
|
||||
createdAt: string
|
||||
direction: SkyPicDirection
|
||||
durationSeconds: number
|
||||
expiresAt: string
|
||||
friendshipId: string
|
||||
id: string
|
||||
openedAt: string | null
|
||||
replayedAt: string | null
|
||||
sender: SkyPicProfileSummary
|
||||
/** Present on successful sends so friendship streak UI can reconcile immediately. */
|
||||
streakCount?: number
|
||||
type: SkyPicSnapType
|
||||
}
|
||||
|
||||
/** Media and editor contents are released only by open/replay callbacks. */
|
||||
export type SkyPicOpenedSnap = {
|
||||
allowReplay: boolean
|
||||
caption: string
|
||||
durationSeconds: number
|
||||
expiresAt: string
|
||||
id: string
|
||||
mediaType: MediaType
|
||||
mimeType: string | null
|
||||
openedAt: string
|
||||
overlayColor: string
|
||||
replayedAt: string | null
|
||||
textOverlay: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Bootstrap returns story metadata only; URLs are released by view-story. */
|
||||
export type SkyPicStory = {
|
||||
author: SkyPicProfileSummary
|
||||
createdAt: string
|
||||
durationSeconds: number
|
||||
expiresAt: string
|
||||
id: string
|
||||
isOwner: boolean
|
||||
seen: boolean
|
||||
viewCount: number
|
||||
}
|
||||
|
||||
export type SkyPicViewedStory = {
|
||||
author: SkyPicProfileSummary
|
||||
canReply: boolean
|
||||
caption: string
|
||||
durationSeconds: number
|
||||
expiresAt: string
|
||||
id: string
|
||||
mediaType: MediaType
|
||||
mimeType: string | null
|
||||
overlayColor: string
|
||||
textOverlay: string
|
||||
url: string
|
||||
viewedAt: string
|
||||
}
|
||||
|
||||
export type SkyPicStoryViewer = SkyPicProfileSummary & {
|
||||
viewedAt: string
|
||||
}
|
||||
|
||||
export type SkyPicSpotlightReportReason =
|
||||
| 'dangerous'
|
||||
| 'harassment'
|
||||
| 'illegal'
|
||||
| 'other'
|
||||
| 'spam'
|
||||
|
||||
/** Spotlight is public content, so its media URL is intentionally feed-visible. */
|
||||
export type SkyPicSpotlight = {
|
||||
adHeadline: string
|
||||
author: SkyPicProfileSummary
|
||||
caption: string
|
||||
commentCount: number
|
||||
commentsEnabled: boolean
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
id: string
|
||||
isLiked: boolean
|
||||
isOwner: boolean
|
||||
isSponsored: boolean
|
||||
isViewed: boolean
|
||||
likeCount: number
|
||||
mimeType: string | null
|
||||
overlayColor: string
|
||||
textOverlay: string
|
||||
url: string
|
||||
viewCount: number
|
||||
}
|
||||
|
||||
export type SkyPicSpotlightComment = {
|
||||
author: SkyPicProfileSummary
|
||||
body: string
|
||||
createdAt: string
|
||||
id: string
|
||||
isOwner: boolean
|
||||
spotlightId: string
|
||||
}
|
||||
|
||||
export type SkyPicMessageDeliveryStatus = 'delivered' | 'failed' | 'sending'
|
||||
|
||||
export type SkyPicMessage = {
|
||||
body: string
|
||||
clientId?: string
|
||||
createdAt: string
|
||||
deliveryStatus?: SkyPicMessageDeliveryStatus
|
||||
direction: SkyPicDirection
|
||||
friendshipId: string
|
||||
id: string
|
||||
readAt: string | null
|
||||
savedAt: string | null
|
||||
type: 'text'
|
||||
}
|
||||
|
||||
export type SkyPicThread = {
|
||||
messages: SkyPicMessage[]
|
||||
snaps: SkyPicSnap[]
|
||||
}
|
||||
|
||||
export type SkyPicBootstrap = {
|
||||
blockedProfiles: SkyPicProfileSummary[]
|
||||
conversations: SkyPicConversation[]
|
||||
friends: SkyPicFriend[]
|
||||
inbox: SkyPicSnap[]
|
||||
profile: SkyPicProfile | null
|
||||
requests: SkyPicFriendRequest[]
|
||||
stories: SkyPicStory[]
|
||||
suggestions: SkyPicProfileSummary[]
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export type SkyPicDraftPurpose = 'snap' | 'spotlight' | 'story'
|
||||
|
||||
export type SkyPicMediaDraftContext = {
|
||||
purpose: SkyPicDraftPurpose
|
||||
recipientIds: string[]
|
||||
}
|
||||
|
||||
export type SkyPicThreadMediaDraftContext = {
|
||||
body: string
|
||||
friendshipId: string
|
||||
pendingMedia: PhoneMedia[]
|
||||
}
|
||||
|
||||
type SkyPicEditorInput = {
|
||||
caption: string
|
||||
durationSeconds: number
|
||||
overlayColor: string
|
||||
textOverlay: string
|
||||
}
|
||||
|
||||
type SkyPicSingleMediaInput = {
|
||||
mediaId: number
|
||||
mediaIds?: never
|
||||
mediaType: MediaType
|
||||
}
|
||||
|
||||
type SkyPicMultipleMediaInput = {
|
||||
mediaId?: never
|
||||
mediaIds: number[]
|
||||
mediaType?: never
|
||||
}
|
||||
|
||||
export type SkyPicSendSnapInput = SkyPicEditorInput &
|
||||
(SkyPicSingleMediaInput | SkyPicMultipleMediaInput) & {
|
||||
allowReplay: boolean
|
||||
recipientIds: string[]
|
||||
}
|
||||
|
||||
export type SkyPicPublishStoryInput = SkyPicEditorInput & SkyPicSingleMediaInput
|
||||
|
||||
export type SkyPicPublishSpotlightInput = SkyPicEditorInput & {
|
||||
adHeadline: string
|
||||
commentsEnabled: boolean
|
||||
isSponsored: boolean
|
||||
mediaId: number
|
||||
mediaType: 'video'
|
||||
}
|
||||
@@ -169,6 +169,9 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
|
||||
expect(html).toContain('id="directory-search"')
|
||||
expect(html).toContain('sky-glass')
|
||||
expect(html).not.toContain('sky-glass--highlight')
|
||||
expect(html).toContain('<svg class="sky-searchbar__icon"')
|
||||
expect(html).toContain('fill-rule="evenodd"')
|
||||
expect(html).not.toContain('<circle')
|
||||
expect(html).toContain('sky-searchbar__clear')
|
||||
expect(html).toContain('aria-label="Clear query"')
|
||||
expect(html).toContain('sky-searchbar__disable')
|
||||
|
||||
@@ -1465,26 +1465,13 @@ label.sky-list-item__row {
|
||||
}
|
||||
|
||||
.sky-searchbar__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
flex: none;
|
||||
border: 1.6px solid var(--sky-muted, rgba(0, 0, 0, 0.55));
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sky-searchbar__icon::after {
|
||||
width: 6px;
|
||||
height: 1.6px;
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -2px;
|
||||
content: '';
|
||||
border-radius: 2px;
|
||||
background: var(--sky-muted, rgba(0, 0, 0, 0.55));
|
||||
transform: rotate(45deg);
|
||||
transform-origin: left center;
|
||||
color: var(--sky-muted, rgba(0, 0, 0, 0.55));
|
||||
fill: currentColor;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.sky-searchbar__input {
|
||||
|
||||
@@ -141,7 +141,13 @@ function disable(event: MouseEvent): void {
|
||||
<label v-if="label" class="sky-visually-hidden" :for="resolvedInputId">
|
||||
{{ label }}
|
||||
</label>
|
||||
<span class="sky-searchbar__icon" aria-hidden="true" />
|
||||
<svg class="sky-searchbar__icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M9.5 3a6.5 6.5 0 1 0 3.98 11.64l4.44 4.44a1 1 0 0 0 1.42-1.42l-4.44-4.44A6.5 6.5 0 0 0 9.5 3Zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
:id="resolvedInputId"
|
||||
ref="input"
|
||||
|
||||
@@ -15,6 +15,7 @@ const APP_STORE_PREVIEW_STYLES = {
|
||||
companies: { accent: '#4a92ff', surface: '#0c1728' },
|
||||
music: { accent: '#fa3d71', surface: '#240b19' },
|
||||
picstagram: { accent: '#e54cff', surface: '#210b27' },
|
||||
skypic: { accent: '#24c7ff', surface: '#070f2b' },
|
||||
feather: { accent: '#3c9cff', surface: '#091c2d' },
|
||||
fliptok: { accent: '#24f0d2', surface: '#071d1b' },
|
||||
flare: { accent: '#ff567f', surface: '#260d18' },
|
||||
|
||||
@@ -158,6 +158,7 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_in_use')).toBe('media_in_use')
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,6 +106,7 @@ export function mediaErrorKey(error?: string): string {
|
||||
'import_url_not_allowed',
|
||||
'import_url_unavailable',
|
||||
'import_size_unavailable',
|
||||
'media_in_use',
|
||||
'not_found',
|
||||
'operation_in_progress',
|
||||
'owner_changed',
|
||||
|
||||
@@ -85,6 +85,10 @@ describe('preferences', () => {
|
||||
enabled: true,
|
||||
sounds: true,
|
||||
})
|
||||
expect(value.settings.notifications.skypic).toEqual({
|
||||
enabled: true,
|
||||
sounds: true,
|
||||
})
|
||||
expect(value.settings.phoneScale).toBe(110)
|
||||
expect(value.settings.screenBrightness).toBe(64)
|
||||
expect(value.settings.wallpaper).toBe('ember')
|
||||
|
||||
@@ -118,6 +118,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
'weazel-news': { enabled: true, sounds: true },
|
||||
'local-pages': { enabled: true, sounds: true },
|
||||
picstagram: { enabled: true, sounds: true },
|
||||
skypic: { enabled: true, sounds: true },
|
||||
fliptok: { enabled: true, sounds: true },
|
||||
feather: { enabled: true, sounds: true },
|
||||
crewlink: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
|
||||
function element(
|
||||
tagName: string,
|
||||
attributes: Record<string, string> = {},
|
||||
isContentEditable = false,
|
||||
) {
|
||||
return {
|
||||
getAttribute(name: string) {
|
||||
return attributes[name] ?? null
|
||||
},
|
||||
isContentEditable,
|
||||
tagName,
|
||||
}
|
||||
}
|
||||
|
||||
describe('text input focus', () => {
|
||||
it('recognizes fields that accept typed text', () => {
|
||||
expect(isTextInputElement(element('INPUT'))).toBe(true)
|
||||
expect(isTextInputElement(element('INPUT', { type: 'number' }))).toBe(true)
|
||||
expect(isTextInputElement(element('TEXTAREA'))).toBe(true)
|
||||
expect(isTextInputElement(element('DIV', {}, true))).toBe(true)
|
||||
expect(isTextInputElement(element('DIV', { role: 'textbox' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-text and read-only controls', () => {
|
||||
expect(isTextInputElement(element('INPUT', { type: 'checkbox' }))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isTextInputElement(element('INPUT', { type: 'range' }))).toBe(false)
|
||||
expect(isTextInputElement(element('INPUT', { readonly: '' }))).toBe(false)
|
||||
expect(isTextInputElement(element('BUTTON'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
const nonTextInputTypes = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'color',
|
||||
'file',
|
||||
'hidden',
|
||||
'image',
|
||||
'radio',
|
||||
'range',
|
||||
'reset',
|
||||
'submit',
|
||||
])
|
||||
|
||||
type FocusableElement = Pick<
|
||||
HTMLElement,
|
||||
'getAttribute' | 'isContentEditable' | 'tagName'
|
||||
>
|
||||
|
||||
export function isTextInputElement(element: FocusableElement): boolean {
|
||||
if (
|
||||
element.getAttribute('readonly') !== null ||
|
||||
element.getAttribute('aria-readonly') === 'true'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (element.isContentEditable || element.getAttribute('role') === 'textbox') {
|
||||
return true
|
||||
}
|
||||
if (element.tagName === 'TEXTAREA') return true
|
||||
if (element.tagName !== 'INPUT') return false
|
||||
|
||||
const inputType = element.getAttribute('type')?.toLowerCase() ?? 'text'
|
||||
return !nonTextInputTypes.has(inputType)
|
||||
}
|
||||
@@ -19,7 +19,17 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps the built-in wallpapers visually restrained', () => {
|
||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||
expect(builtInWallpaperCss.match(/linear-gradient/g)).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('replaces the home status row with the edit controls', () => {
|
||||
expect(viewSource).toContain("emit('editModeChange', editing)")
|
||||
expect(viewSource).toContain("emit('editModeChange', false)")
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('phone app theme contract', () => {
|
||||
it('provides the reactive phone theme to every routed app', () => {
|
||||
expect(appShellSource).toContain('import { SkyProvider }')
|
||||
expect(appShellSource).toContain('class="phone-app-theme"')
|
||||
expect(appShellSource).toContain(':dark="phone.isDarkMode"')
|
||||
expect(appShellSource).toContain(':dark="displayedDarkMode"')
|
||||
expect(appShellSource).toMatch(
|
||||
/<SkyProvider[\s\S]*?<RouterView[\s\S]*?<component/,
|
||||
)
|
||||
|
||||
@@ -44,6 +44,15 @@ describe('House app sheets', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('can expose key revocation without advertising key grants', () => {
|
||||
expect(source).toContain(
|
||||
'v-if="selectedProperty.capabilities.keyGrant !== false"',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'<section v-if="selectedProperty.capabilities.keys" class="house-keys">',
|
||||
)
|
||||
})
|
||||
|
||||
it('centers the resident icon above the Give a Key title', () => {
|
||||
expect(source).toMatch(
|
||||
/\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s,
|
||||
|
||||
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
|
||||
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
|
||||
>
|
||||
<sky-button
|
||||
v-if="selectedProperty.capabilities.keyGrant !== false"
|
||||
rounded
|
||||
small
|
||||
inline
|
||||
|
||||
@@ -100,7 +100,7 @@ const inCallKeypad = ref('')
|
||||
const blockDialogOpened = ref(false)
|
||||
const blockTargetNumber = ref('')
|
||||
const callSpeakerPending = ref(false)
|
||||
const callMuted = ref(false)
|
||||
const callMutePending = ref(false)
|
||||
const callElapsedSeconds = ref(0)
|
||||
let callClock: number | null = null
|
||||
const tabs = [
|
||||
@@ -464,6 +464,26 @@ async function toggleCallSpeaker(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCallMute(): Promise<void> {
|
||||
const call = calls.activeCall
|
||||
if (
|
||||
!call ||
|
||||
call.state !== 'connected' ||
|
||||
!call.muteSupported ||
|
||||
callMutePending.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
callMutePending.value = true
|
||||
const response = await calls.setMuted(!call.muted)
|
||||
callMutePending.value = false
|
||||
if (!response.success) {
|
||||
error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`)
|
||||
}
|
||||
}
|
||||
|
||||
function updateCallElapsed(): void {
|
||||
const call = calls.activeCall
|
||||
if (!call || call.state !== 'connected') {
|
||||
@@ -853,8 +873,20 @@ onBeforeUnmount(() => {
|
||||
<sky-button
|
||||
rounded
|
||||
class="phone-call-action"
|
||||
:class="{ 'is-active': callMuted }"
|
||||
@click="callMuted = !callMuted"
|
||||
:class="{
|
||||
'is-active': calls.activeCall.muted,
|
||||
'is-disabled':
|
||||
calls.activeCall.state !== 'connected' ||
|
||||
!calls.activeCall.muteSupported,
|
||||
}"
|
||||
:disabled="
|
||||
calls.activeCall.state !== 'connected' ||
|
||||
!calls.activeCall.muteSupported ||
|
||||
callMutePending
|
||||
"
|
||||
:aria-busy="callMutePending || undefined"
|
||||
:aria-pressed="calls.activeCall.muted === true"
|
||||
@click="toggleCallMute"
|
||||
>
|
||||
<MicOff />
|
||||
<span>{{ phone.t('Apps.phone.mute') }}</span>
|
||||
|
||||
@@ -13,6 +13,10 @@ describe('SettingsApp Sky UI contract', () => {
|
||||
expect(source).not.toMatch(/<\/?k-[a-z]/)
|
||||
expect(source).toContain('<SkyAppPage')
|
||||
expect(source).toContain('<SkyNavbar')
|
||||
expect(source).toContain('class="settings-navbar"')
|
||||
expect(source).toContain(
|
||||
'.settings-navbar.sky-navbar--large.sky-navbar--no-navigation',
|
||||
)
|
||||
expect(source).toContain(
|
||||
":variant=\"activeView === 'root' ? 'large' : 'compact'\"",
|
||||
)
|
||||
@@ -64,4 +68,10 @@ describe('SettingsApp Sky UI contract', () => {
|
||||
expect(source).toContain("phone.t('Apps.settings.keepCloudData')")
|
||||
expect(source).toContain('phone.resetAfterFactoryReset()')
|
||||
})
|
||||
|
||||
it('provides a non-destructive development preview for the reset progress screen', () => {
|
||||
expect(source).toContain('import.meta.env.DEV')
|
||||
expect(source).toContain("has('factoryResetPreview')")
|
||||
expect(source).toContain('factoryResetProgress.value = 46')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -740,6 +740,14 @@ async function confirmSimEject(): Promise<void> {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).has('factoryResetPreview')
|
||||
) {
|
||||
factoryResetting.value = true
|
||||
factoryResetProgress.value = 46
|
||||
}
|
||||
|
||||
if (route.query.wallpaper === '1') {
|
||||
activeView.value = 'wallpaper'
|
||||
wallpaperTarget.value =
|
||||
@@ -771,6 +779,7 @@ onBeforeUnmount(() => {
|
||||
:label="phone.t('Apps.settings.name')"
|
||||
>
|
||||
<SkyNavbar
|
||||
class="settings-navbar"
|
||||
:title="
|
||||
activeView === 'root' ? phone.t('Apps.settings.name') : activeTitle
|
||||
"
|
||||
@@ -1699,17 +1708,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="settings-reset-content">
|
||||
<div class="settings-reset-mark" aria-hidden="true">
|
||||
<span
|
||||
class="settings-reset-mark__layer settings-reset-mark__layer--back"
|
||||
></span>
|
||||
<span
|
||||
class="settings-reset-mark__layer settings-reset-mark__layer--middle"
|
||||
></span>
|
||||
<div class="settings-reset-mark__face">
|
||||
<Smartphone :size="35" :stroke-width="1.45" />
|
||||
<span class="settings-reset-mark__erase">
|
||||
<i></i><i></i><i></i>
|
||||
</span>
|
||||
<Smartphone :size="38" :stroke-width="1.45" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1872,6 +1872,16 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.settings-navbar.sky-navbar--large) {
|
||||
min-height: calc(
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
|
||||
);
|
||||
}
|
||||
|
||||
:deep(.settings-navbar.sky-navbar--large.sky-navbar--no-navigation) {
|
||||
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
|
||||
}
|
||||
|
||||
.settings-search {
|
||||
margin-bottom: var(--sky-space-4);
|
||||
}
|
||||
@@ -2510,6 +2520,102 @@ onBeforeUnmount(() => {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* Factory reset stays intentionally quiet: white, direct and system-like. */
|
||||
.settings-reset-hero__icon {
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #ff3b30;
|
||||
background: #f2f2f7;
|
||||
box-shadow: none;
|
||||
}
|
||||
.settings-reset-overlay {
|
||||
padding: 58px 30px 34px;
|
||||
color: #1d1d1f;
|
||||
background: #ffffff;
|
||||
}
|
||||
.settings-reset-overlay::before,
|
||||
.settings-reset-overlay::after {
|
||||
content: none;
|
||||
}
|
||||
.settings-reset-content {
|
||||
max-width: 300px;
|
||||
}
|
||||
.settings-reset-mark {
|
||||
display: grid;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #f2f2f7;
|
||||
}
|
||||
.settings-reset-mark__face {
|
||||
position: static;
|
||||
display: grid;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #ff3b30;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
place-items: center;
|
||||
}
|
||||
.settings-reset-heading {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.settings-reset-heading h2 {
|
||||
color: #1d1d1f;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.settings-reset-heading p {
|
||||
color: #6e6e73;
|
||||
}
|
||||
.settings-reset-progress-copy {
|
||||
margin-top: 40px;
|
||||
}
|
||||
.settings-reset-progress-copy strong {
|
||||
color: #1d1d1f;
|
||||
font-size: 15px;
|
||||
}
|
||||
.settings-reset-progress-copy span {
|
||||
color: #8e8e93;
|
||||
}
|
||||
.settings-reset-progress {
|
||||
height: 5px;
|
||||
background: #e5e5ea;
|
||||
}
|
||||
.settings-reset-progress > span {
|
||||
background: #007aff;
|
||||
box-shadow: none;
|
||||
}
|
||||
.settings-reset-detail {
|
||||
color: #8e8e93;
|
||||
}
|
||||
.settings-reset-assurance {
|
||||
margin-top: 34px;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #f2f2f7;
|
||||
box-shadow: none;
|
||||
}
|
||||
.settings-reset-assurance__icon {
|
||||
border-radius: 50%;
|
||||
color: #007aff;
|
||||
background: #e4f1ff;
|
||||
}
|
||||
.settings-reset-assurance strong {
|
||||
color: #1d1d1f;
|
||||
}
|
||||
.settings-reset-assurance small {
|
||||
color: #6e6e73;
|
||||
}
|
||||
.settings-reset-warning {
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.settings-dialog-button--danger:not(:disabled) {
|
||||
background: var(--sky-danger);
|
||||
color: #ffffff;
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const viewSource = readFileSync(
|
||||
new URL('./SkyPicApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const storeSource = readFileSync(
|
||||
new URL('../../stores/skypic.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const typeSource = readFileSync(
|
||||
new URL('../../types/skypic.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('SkyPic frontend contract', () => {
|
||||
it('uses Sky UI and exposes the camera-first five-tab shell', () => {
|
||||
expect(viewSource).toContain("from '@/ui'")
|
||||
expect(viewSource).not.toContain("from 'konsta/vue'")
|
||||
expect(viewSource).toContain("const activeTab = ref<Tab>('camera')")
|
||||
expect(viewSource).toContain(
|
||||
"type Tab = 'camera' | 'chats' | 'friends' | 'spotlight' | 'stories'",
|
||||
)
|
||||
expect(viewSource).toContain("activeTab === 'camera'")
|
||||
expect(viewSource).toContain("activeTab === 'chats'")
|
||||
expect(viewSource).toContain("activeTab === 'stories'")
|
||||
expect(viewSource).toContain("activeTab === 'friends'")
|
||||
expect(viewSource).toContain("activeTab === 'spotlight'")
|
||||
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('keeps every bottom tab and app surface theme-monochrome', () => {
|
||||
expect(viewSource).toContain('phone.isDarkMode')
|
||||
expect(viewSource).toContain("'skypic-app--player-dark': phone.isDarkMode")
|
||||
expect(viewSource).toContain(
|
||||
"'skypic-app--player-light': !phone.isDarkMode",
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'class="sp-tab sp-tab--camera sp-tab--monochrome"',
|
||||
)
|
||||
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
|
||||
4,
|
||||
)
|
||||
expect(viewSource).not.toContain('color: var(--sp-camera-blue) !important')
|
||||
expect(viewSource).toContain('--sp-tab-monochrome: #000000')
|
||||
expect(viewSource).toContain('--sp-tab-monochrome: #ffffff')
|
||||
expect(viewSource).toContain('color: var(--sp-tab-monochrome) !important')
|
||||
const cameraCss = viewSource
|
||||
.split('.sp-camera-screen {')[1]
|
||||
?.split('.sp-camera-header')[0]
|
||||
expect(cameraCss).toBeTruthy()
|
||||
expect(cameraCss).toContain('background: #000')
|
||||
expect(cameraCss).not.toContain('255, 91, 189')
|
||||
expect(viewSource).toContain('rgba(18, 18, 18, 0.92)')
|
||||
expect(viewSource).not.toContain('var(--sp-cyan)')
|
||||
})
|
||||
|
||||
it('uses the reference-led Sky UI hierarchy with Spotlight and no camera filters', () => {
|
||||
expect(viewSource.match(/class="sp-camera-header__actions"/g)).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(viewSource.match(/class="sp-camera-header__glass"/g)).toHaveLength(3)
|
||||
expect(viewSource).toContain('class="sp-camera-viewfinder"')
|
||||
expect(viewSource).toContain('class="sp-camera-dock"')
|
||||
const cameraMarkup = viewSource
|
||||
.split('v-if="activeTab === \'camera\'"')[1]
|
||||
?.split('<template v-else-if="activeTab === \'chats\'">')[0]
|
||||
expect(cameraMarkup).toContain('<SkyGlass')
|
||||
expect(cameraMarkup).toContain('class="sp-shutter"')
|
||||
expect(viewSource).toContain('class="sp-chat-list"')
|
||||
expect(viewSource).toContain('class="sp-chat-row')
|
||||
expect(viewSource).toContain('class="sp-story-rail"')
|
||||
expect(viewSource).toContain('class="sp-story-grid"')
|
||||
expect(viewSource).toContain('class="sp-discovery-grid"')
|
||||
expect(viewSource).toContain('width: calc(100% - (var(--sky-space-3) * 2))')
|
||||
expect(viewSource).toContain('class="sp-bottom-nav"')
|
||||
expect(viewSource).not.toContain('sp-camera-filter')
|
||||
expect(viewSource).toContain('class="sp-spotlight-player"')
|
||||
expect(
|
||||
viewSource.match(/class="sp-spotlight-header__actions/g),
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
viewSource.match(/class="sp-spotlight-header__glass"/g),
|
||||
).toHaveLength(3)
|
||||
expect(viewSource).toContain(
|
||||
'height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height))',
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'padding: var(--sky-safe-area-top) var(--sky-page-gutter)',
|
||||
)
|
||||
expect(viewSource).toContain('min-height: var(--sky-navbar-height)')
|
||||
expect(viewSource).toContain("t('spotlight.sponsored')")
|
||||
expect(viewSource).toContain('store.publishSpotlight')
|
||||
expect(viewSource).not.toContain('href=')
|
||||
})
|
||||
|
||||
it('builds story discovery only from safe metadata until view-story releases media', () => {
|
||||
const storyFeedBlock = viewSource
|
||||
.split('<section class="sp-story-feed">')[1]
|
||||
?.split('</section>')[0]
|
||||
|
||||
expect(viewSource).toContain('const friendStoryRail = computed(')
|
||||
expect(storyFeedBlock).toBeTruthy()
|
||||
expect(storyFeedBlock).toContain('v-for="story in store.stories"')
|
||||
expect(storyFeedBlock).toContain('story.author.avatarUrl')
|
||||
expect(storyFeedBlock).toContain('story.durationSeconds')
|
||||
expect(storyFeedBlock).not.toContain('story.url')
|
||||
expect(storyFeedBlock).not.toContain('store.viewedStory.url')
|
||||
})
|
||||
|
||||
it('renders every visible streak as an accessible Flame icon', () => {
|
||||
expect(viewSource.match(/<Flame/g)?.length).toBeGreaterThanOrEqual(4)
|
||||
expect(viewSource).toContain("t('profile.streaks') +")
|
||||
expect(viewSource).toContain('class="sp-navbar-streak"')
|
||||
expect(viewSource).toContain('class="sp-profile-streak-stat"')
|
||||
expect(viewSource).not.toContain('🔥')
|
||||
})
|
||||
|
||||
it('keeps existing Snap and Story viewers full-bleed with ordered overlays', () => {
|
||||
expect(viewSource).toContain(
|
||||
'class="sp-media-viewer sp-media-viewer--snap"',
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'class="sp-media-viewer sp-media-viewer--story"',
|
||||
)
|
||||
expect(viewSource).toContain('object-fit: cover')
|
||||
expect(viewSource).toContain('.sp-media-viewer::before')
|
||||
expect(viewSource).toContain('.sp-media-viewer::after')
|
||||
})
|
||||
|
||||
it('uses the shared Camera and Gallery media handoff with a bounded draft', () => {
|
||||
expect(viewSource).toContain('mediaPicker.begin(')
|
||||
expect(viewSource).toContain("'skypic-draft'")
|
||||
expect(viewSource).toContain('`/apps/skypic?compose=${purpose}`')
|
||||
expect(viewSource).toContain(
|
||||
"mediaPicker.consumeMany<SkyPicMediaDraftContext>('skypic-draft')",
|
||||
)
|
||||
expect(viewSource).toContain('path: `/apps/${source}`')
|
||||
expect(viewSource).toContain('query: { mediaAttachment: mediaType }')
|
||||
expect(viewSource).toContain("type MediaSource = 'camera' | 'photos'")
|
||||
})
|
||||
|
||||
it('supports a bounded multi-photo thread handoff with preview controls', () => {
|
||||
expect(viewSource).toContain('const MAX_THREAD_ATTACHMENTS = 10')
|
||||
expect(viewSource).toContain("'skypic-thread-media'")
|
||||
expect(viewSource).toContain(
|
||||
'mediaPicker.consumeMany<SkyPicThreadMediaDraftContext>',
|
||||
)
|
||||
expect(viewSource).toContain('pendingThreadMedia')
|
||||
expect(viewSource).toContain('mediaIds: queuedMedia.map')
|
||||
expect(viewSource).toContain('removeThreadMedia(media.id)')
|
||||
expect(viewSource).toContain('moveThreadMedia(index, -1)')
|
||||
expect(viewSource).toContain('moveThreadMedia(index, 1)')
|
||||
expect(viewSource).toContain("openThreadMedia('photos', 'photo')")
|
||||
expect(viewSource).toContain("openThreadMedia('camera', 'photo')")
|
||||
expect(viewSource).toContain("openThreadMedia('photos', 'video')")
|
||||
expect(viewSource).toContain('openThreadEmojiPicker')
|
||||
expect(typeSource).toContain('SkyPicThreadMediaDraftContext')
|
||||
expect(typeSource).toContain('mediaIds: number[]')
|
||||
})
|
||||
|
||||
it('aligns chat ownership and keeps the story reply action inside the viewer', () => {
|
||||
expect(viewSource).toContain(
|
||||
`:type="entry.value.direction === 'sent' ? 'sent' : 'received'"`,
|
||||
)
|
||||
const threadLayoutCss = viewSource
|
||||
.split('.sp-thread :deep(.sky-messages) {')[1]
|
||||
?.split('.sp-thread :deep(.sky-message__footer)')[0]
|
||||
expect(threadLayoutCss).toContain('display: flex')
|
||||
expect(threadLayoutCss).toContain('flex-direction: column')
|
||||
const sentSnapCss = viewSource
|
||||
.split('.sp-thread-snap--sent {')[1]
|
||||
?.split('.sp-thread-snap > span')[0]
|
||||
expect(sentSnapCss).toContain('align-self: flex-end')
|
||||
const storyReplyCss = viewSource
|
||||
.split('.sp-story-reply {')[1]
|
||||
?.split('.sp-media-viewer__owner-actions button')[0]
|
||||
expect(storyReplyCss).toContain('width: auto')
|
||||
expect(storyReplyCss).toContain('right: var(--sky-page-gutter)')
|
||||
expect(storyReplyCss).toContain('left: var(--sky-page-gutter)')
|
||||
})
|
||||
|
||||
it('uses app-local authentication and exposes safe account controls', () => {
|
||||
expect(viewSource).toContain('useAppAuthStore()')
|
||||
expect(viewSource).toContain("appAuth.isSignedIn('skypic')")
|
||||
expect(viewSource).toContain("appAuth.signIn('skypic', account.email)")
|
||||
expect(viewSource).toContain("appAuth.signOut('skypic')")
|
||||
expect(viewSource).toContain('<AppProfileAuth')
|
||||
expect(viewSource).toContain('moving-mode-highlight')
|
||||
expect(viewSource).toContain(':login-mode-label=')
|
||||
expect(viewSource).toContain(':register-mode-label=')
|
||||
expect(viewSource).toContain('<AccountLogoutDialog')
|
||||
expect(viewSource).toContain('app-id="skypic"')
|
||||
expect(viewSource).toContain('deleteSkyPicAccount')
|
||||
expect(viewSource).toContain('store.resetSession()')
|
||||
expect(storeSource).toContain("'skypic:delete-account'")
|
||||
expect(storeSource).toContain('confirmed: true')
|
||||
})
|
||||
|
||||
it('uses the production SkyPic handle rule for login and registration', () => {
|
||||
expect(viewSource).toContain('isValidSkyPicHandle(authHandle.value)')
|
||||
expect(viewSource).toContain(
|
||||
'const handle = normalizeSkyPicHandle(onboarding.handle)',
|
||||
)
|
||||
expect(storeSource).toContain('/^[a-z0-9._]+$/')
|
||||
})
|
||||
|
||||
it('reacts to confirmed remote account deletion with a clean register state', () => {
|
||||
expect(viewSource).toContain('() => store.profileAbsentRevision')
|
||||
const resetBlock = viewSource
|
||||
.split('function resetAccountUiState(accountExists: boolean): void {')[1]
|
||||
?.split('function handleLoggedOut')[0]
|
||||
expect(resetBlock).toContain(
|
||||
"authMode.value = accountExists ? 'login' : 'register'",
|
||||
)
|
||||
expect(resetBlock).toContain('deleteAccountDialogOpen.value = false')
|
||||
expect(resetBlock).toContain('profileEditing.value = false')
|
||||
expect(resetBlock).toContain('pendingThreadMedia.value = []')
|
||||
expect(resetBlock).toContain('storyViewerSheetOpen.value = false')
|
||||
expect(resetBlock).toContain("storyViewerSheetStoryId.value = ''")
|
||||
expect(resetBlock).toContain("storyReply.value = ''")
|
||||
expect(resetBlock).toContain('storyNavigationRequest += 1')
|
||||
expect(resetBlock).toContain('clearStoryTimer()')
|
||||
expect(resetBlock).toContain('store.clearViewedStory()')
|
||||
expect(resetBlock).toContain("activeTab.value = 'camera'")
|
||||
})
|
||||
|
||||
it('clears private SkyPic state while the local app session is signed out', () => {
|
||||
const logoutBlock = viewSource
|
||||
.split('function handleLoggedOut(): void {')[1]
|
||||
?.split('async function deleteSkyPicAccount')[0]
|
||||
const bootstrapBlock = viewSource
|
||||
.split('async function bootstrapApp(): Promise<void> {')[1]
|
||||
?.split('onMounted(')[0]
|
||||
|
||||
expect(logoutBlock).toContain('store.resetSession()')
|
||||
expect(logoutBlock).not.toContain('bootstrapApp()')
|
||||
expect(bootstrapBlock).toContain("!appAuth.isSignedIn('skypic')")
|
||||
expect(bootstrapBlock).toContain('store.resetSession()')
|
||||
expect(viewSource).toContain(
|
||||
'hasSkyPicAccount.value = Boolean(store.profile)',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders local registration guidance without bootstrapping an absent Sky account', () => {
|
||||
const bootstrapBlock = viewSource
|
||||
.split('async function bootstrapApp(): Promise<void> {')[1]
|
||||
?.split('onMounted(')[0]
|
||||
const noAccountGuard = bootstrapBlock?.indexOf('if (!account.email)') ?? -1
|
||||
const serverBootstrap = bootstrapBlock?.indexOf(
|
||||
'const loaded = await store.bootstrap()',
|
||||
)
|
||||
expect(noAccountGuard).toBeGreaterThanOrEqual(0)
|
||||
expect(noAccountGuard).toBeLessThan(serverBootstrap ?? -1)
|
||||
expect(bootstrapBlock).toContain('resetAccountUiState(false)')
|
||||
expect(bootstrapBlock).toContain('bootstrapped.value = true')
|
||||
})
|
||||
|
||||
it('keeps profile editing parse-safe and composer inputs canonical', () => {
|
||||
expect(viewSource).toContain('function toggleProfileEditor(): void')
|
||||
expect(viewSource.match(/@click="toggleProfileEditor"/g)).toHaveLength(3)
|
||||
expect(viewSource).toContain('const MAX_CAPTION_LENGTH = 160')
|
||||
expect(viewSource).toContain(':maxlength="MAX_CAPTION_LENGTH"')
|
||||
expect(viewSource).toContain('.slice(0, MAX_CAPTION_LENGTH)')
|
||||
expect(viewSource).not.toContain('maxlength="240"')
|
||||
expect(viewSource).toContain('const MAX_TEXT_OVERLAY_LENGTH = 160')
|
||||
expect(viewSource).toContain(':maxlength="MAX_TEXT_OVERLAY_LENGTH"')
|
||||
expect(storeSource).toContain('MAX_TEXT_OVERLAY_CHARACTERS = 160')
|
||||
expect(viewSource).toContain(
|
||||
'avatarSeed: Math.floor(Math.random() * 360) + 1',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps direct and story secrets behind explicit release callbacks', () => {
|
||||
const snapBlock = typeSource.match(
|
||||
/export type SkyPicSnap = \{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
const openedSnapBlock = typeSource.match(
|
||||
/export type SkyPicOpenedSnap = \{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
const storyBlock = typeSource.match(
|
||||
/export type SkyPicStory = \{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
const viewedStoryBlock = typeSource.match(
|
||||
/export type SkyPicViewedStory = \{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
|
||||
expect(snapBlock).toBeTruthy()
|
||||
expect(snapBlock).not.toMatch(/\burl\b|caption|textOverlay|overlayColor/)
|
||||
expect(openedSnapBlock).toMatch(/\burl: string\b/)
|
||||
expect(openedSnapBlock).toMatch(/caption: string/)
|
||||
expect(openedSnapBlock).toMatch(/textOverlay: string/)
|
||||
expect(openedSnapBlock).toMatch(/overlayColor: string/)
|
||||
expect(storyBlock).not.toMatch(/\burl\b|caption|textOverlay|overlayColor/)
|
||||
expect(viewedStoryBlock).toMatch(/\burl: string\b/)
|
||||
expect(storeSource).toContain("'skypic:open-snap'")
|
||||
expect(storeSource).toContain("'skypic:replay-snap'")
|
||||
expect(storeSource).toContain("'skypic:view-story'")
|
||||
expect(viewSource).toContain('store.clearOpenedSnap()')
|
||||
expect(viewSource).toContain('store.clearViewedStory()')
|
||||
})
|
||||
|
||||
it('implements all agreed callback names exactly', () => {
|
||||
const callbacks = [
|
||||
'skypic:bootstrap',
|
||||
'skypic:create-profile',
|
||||
'skypic:delete-account',
|
||||
'skypic:update-profile',
|
||||
'skypic:search',
|
||||
'skypic:add-friend',
|
||||
'skypic:respond-friend',
|
||||
'skypic:remove-friend',
|
||||
'skypic:block',
|
||||
'skypic:send-snap',
|
||||
'skypic:open-snap',
|
||||
'skypic:replay-snap',
|
||||
'skypic:publish-story',
|
||||
'skypic:stories',
|
||||
'skypic:view-story',
|
||||
'skypic:story-viewers',
|
||||
'skypic:remove-story',
|
||||
'skypic:thread',
|
||||
'skypic:send-message',
|
||||
'skypic:mark-thread',
|
||||
'skypic:save-message',
|
||||
'skypic:delete-message',
|
||||
]
|
||||
callbacks.forEach((callback) =>
|
||||
expect(storeSource).toContain("'" + callback + "'"),
|
||||
)
|
||||
})
|
||||
|
||||
it('reacts to query-only notification and media-return deep links', () => {
|
||||
expect(viewSource).toContain('() => route.query')
|
||||
expect(viewSource).toContain('{ deep: true }')
|
||||
expect(viewSource).toContain('query.compose')
|
||||
expect(viewSource).toContain('query.profileId')
|
||||
expect(viewSource).toContain('query.friendship')
|
||||
expect(viewSource).toContain('query.snap')
|
||||
expect(viewSource).toContain('query.story')
|
||||
expect(viewSource).toContain('conversationForProfile(profileId)')
|
||||
expect(viewSource).toContain(
|
||||
'if (!requestedFriendship && store.activeFriendshipId)',
|
||||
)
|
||||
expect(viewSource).toContain("chatBody.value = ''")
|
||||
})
|
||||
|
||||
it('only opens incoming direct snaps', () => {
|
||||
const canOpenBlock = viewSource.match(
|
||||
/function snapCanOpen\(snap: SkyPicSnap\): boolean \{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
|
||||
expect(canOpenBlock).toContain("snap.direction === 'received'")
|
||||
expect(canOpenBlock).toContain('snap.allowReplay')
|
||||
})
|
||||
|
||||
it('implements server-authorized story replies', () => {
|
||||
expect(typeSource).toContain('canReply: boolean')
|
||||
expect(viewSource).toContain('store.viewedStory.canReply')
|
||||
expect(viewSource).toContain('stories.replyPlaceholder')
|
||||
expect(viewSource).toContain(
|
||||
'store.sendMessage(friendshipId, body, story.id)',
|
||||
)
|
||||
expect(viewSource).toContain('@focus="pauseStoryCountdown"')
|
||||
expect(viewSource).toContain('@blur="resumeStoryCountdown"')
|
||||
expect(storeSource).toContain('...(storyId ? { storyId } : {})')
|
||||
})
|
||||
|
||||
it('renders outgoing requests with a cancellable relationship action', () => {
|
||||
expect(viewSource).toContain('store.outgoingRequests')
|
||||
expect(viewSource).toContain('cancelFriendRequest(request)')
|
||||
expect(viewSource).toContain("'friends.cancelRequest'")
|
||||
expect(storeSource).toContain("'skypic:remove-friend'")
|
||||
})
|
||||
|
||||
it('has timer cleanup, conditional scroll owners and accessible controls', () => {
|
||||
expect(viewSource).toContain('beginCountdown(')
|
||||
expect(viewSource).toContain('clearSnapTimer()')
|
||||
expect(viewSource).toContain('clearStoryTimer()')
|
||||
expect(viewSource).toContain('onBeforeUnmount(')
|
||||
expect(viewSource).toContain('<SkyScrollArea')
|
||||
expect(viewSource).toContain('with-tabbar')
|
||||
expect(viewSource).toContain('var(--sky-touch-target)')
|
||||
expect(viewSource).toContain(':focus-visible')
|
||||
expect(viewSource).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
expect(viewSource).toContain('aria-modal="true"')
|
||||
})
|
||||
|
||||
it('starts viewer timers only after media readiness and closes sheets safely', () => {
|
||||
expect(viewSource).toContain(
|
||||
"prepareViewerMedia('snap', snap.durationSeconds)",
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
"prepareViewerMedia('story', story.durationSeconds)",
|
||||
)
|
||||
expect(viewSource).toContain("handleViewerMediaReady('snap')")
|
||||
expect(viewSource).toContain("handleViewerMediaReady('story')")
|
||||
expect(viewSource).toContain("handleViewerVideoCanPlay('snap', $event)")
|
||||
expect(viewSource).toContain("handleViewerVideoCanPlay('story', $event)")
|
||||
expect(viewSource).toContain('await video.play()')
|
||||
expect(viewSource).toContain('mediaLoading.value = true')
|
||||
expect(viewSource).toContain('mediaError.value = true')
|
||||
expect(viewSource).toContain('pauseStoryCountdown()')
|
||||
expect(viewSource).toContain('storyViewerSheetOpen.value = false')
|
||||
})
|
||||
|
||||
it('guards stale tab, story and viewer-sheet requests', () => {
|
||||
expect(viewSource).toContain(
|
||||
'navigationRequest !== threadNavigationRequest',
|
||||
)
|
||||
expect(viewSource).toContain('activeTab.value !== next')
|
||||
expect(viewSource).toContain(
|
||||
'if (!appMounted || store.storyViewing) return',
|
||||
)
|
||||
expect(viewSource).toContain('const requestId = ++storyNavigationRequest')
|
||||
expect(viewSource).toContain('const requestId = ++storyViewerRequest')
|
||||
expect(viewSource).toContain('store.viewedStory?.id === story.id')
|
||||
expect(storeSource).toContain("error: 'story_view_in_progress'")
|
||||
expect(storeSource).toContain('requestId !== storyRequest')
|
||||
expect(storeSource).toContain('requestId !== storyViewersRequest')
|
||||
expect(viewSource).toContain('if (!appMounted) return')
|
||||
expect(viewSource).toContain('threadNavigationRequest += 1')
|
||||
expect(viewSource).toContain('store.closeThread()')
|
||||
})
|
||||
|
||||
it('bounds discovery search and clears failed results', () => {
|
||||
expect(viewSource).toContain('const MAX_SEARCH_CHARACTERS = 64')
|
||||
expect(viewSource).toContain("slice(0, MAX_SEARCH_CHARACTERS).join('')")
|
||||
expect(viewSource).toContain('@update:model-value="updateSearchQuery"')
|
||||
expect(viewSource).toContain('notify(errorText(store.error ?? undefined))')
|
||||
expect(storeSource).toContain('searchResults.value = []')
|
||||
})
|
||||
|
||||
it('deduplicates concurrent account bootstraps', () => {
|
||||
expect(storeSource).toContain(
|
||||
'bootstrapInFlight?.session === requestSession',
|
||||
)
|
||||
expect(storeSource).toContain('return bootstrapInFlight.promise')
|
||||
expect(storeSource).toContain(
|
||||
'bootstrapInFlight = { promise, session: requestSession, token }',
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,10 @@ const clientMain = readFileSync(
|
||||
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneApp = readFileSync(
|
||||
new URL('./views/apps/PhoneApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const clientRadio = readFileSync(
|
||||
new URL('../../sky_phone/source/bridge/client/radio.lua', import.meta.url),
|
||||
'utf8',
|
||||
@@ -76,13 +80,13 @@ describe('voice provider contracts', () => {
|
||||
expect(config).toMatch(/Config\.Speaker\s*=\s*\{\s*Enabled\s*=\s*true,/)
|
||||
expect(sharedBridge).toContain('function Bridge.Speaker.IsEnabled()')
|
||||
expect(clientCalls).toContain(
|
||||
'Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"',
|
||||
'Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")',
|
||||
)
|
||||
expect(clientRadio).toContain(
|
||||
'Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'Bridge.Speaker.IsEnabled() and resolve_call_provider() == "saltychat"',
|
||||
'Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'Bridge.Speaker.IsEnabled() and resolve_radio_provider() == "saltychat"',
|
||||
@@ -90,6 +94,37 @@ describe('voice provider contracts', () => {
|
||||
expect(serverCalls).toContain('if not Bridge.Speaker.IsEnabled() then')
|
||||
})
|
||||
|
||||
it('integrates Yaca calls, speaker mode and provider-backed mute end to end', () => {
|
||||
expect(config).toContain('yaca (alias: yaca-voice)')
|
||||
expect(clientCalls).toContain('yaca = "yaca-voice"')
|
||||
expect(clientCalls).toContain(
|
||||
'Yaca and SaltyChat call membership is owned by the server bridge.',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'exports["yaca-voice"]:callPlayer(caller_source, target_source, true)',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'exports["yaca-voice"]:callPlayer(caller_source, target_source, false)',
|
||||
)
|
||||
expect(serverVoice).toContain('exports["yaca-voice"]:enablePhoneSpeaker(')
|
||||
expect(serverVoice).toContain('exports["yaca-voice"]:muteOnPhone(')
|
||||
expect(serverCalls).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:calls:set-muted"',
|
||||
)
|
||||
expect(clientMain).toContain('"calls:set-muted"')
|
||||
expect(phoneApp).toContain('@click="toggleCallMute"')
|
||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||
})
|
||||
|
||||
it('passes Yaca radio volume arguments in the documented order', () => {
|
||||
expect(clientRadio).toContain(
|
||||
'changeRadioChannelVolumeRaw(volume / 100, 1)',
|
||||
)
|
||||
expect(clientRadio).toContain(
|
||||
'changeRadioChannelVolumeRaw(volume / 100, 2)',
|
||||
)
|
||||
})
|
||||
|
||||
it('provides safe shared defaults for the optional server radio speaker adapter', () => {
|
||||
expect(sharedBridge).toContain('function Bridge.Radio.SupportsSpeaker()')
|
||||
expect(sharedBridge).toMatch(
|
||||
|
||||
+1790
-13
File diff suppressed because it is too large
Load Diff
+1018
-3
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -89,9 +89,9 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| --- | --- |
|
||||
| **Frameworks** | ESX Legacy, QBCore, Qbox |
|
||||
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Calls** | PMA Voice, SaltyChat |
|
||||
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||
| **Housing** | ESX Property, qbx_properties |
|
||||
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
||||
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||
| **Languages** | English, German |
|
||||
@@ -140,6 +140,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
|
||||
Phone calls support:
|
||||
|
||||
- YACA
|
||||
- PMA Voice
|
||||
- SaltyChat
|
||||
|
||||
@@ -433,10 +434,11 @@ Config.Calls.VoiceProvider = "pma"
|
||||
|
||||
Supported values:
|
||||
|
||||
- `yaca` or `yaca-voice`
|
||||
- `pma` or `pma-voice`
|
||||
- `saltychat` or `salty`
|
||||
|
||||
SaltyChat supports the provider-backed call speaker feature. PMA Voice keeps the speaker option unavailable.
|
||||
YACA supports calls, payphone calls, provider-backed speaker mode, and real microphone mute. SaltyChat supports provider-backed speaker mode. PMA Voice keeps speaker and mute controls unavailable.
|
||||
|
||||
### Radio
|
||||
|
||||
@@ -523,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
||||
|
||||
### Housing
|
||||
|
||||
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
|
||||
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
|
||||
|
||||
### Companies
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Config.Phone = {
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = true,
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
@@ -82,7 +82,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -368,8 +368,8 @@ Config.Garage = {
|
||||
}
|
||||
|
||||
Config.Housing = {
|
||||
System = "auto", -- auto, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties" },
|
||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
||||
MaximumProperties = 50,
|
||||
OverviewRequestsPerMinute = 30,
|
||||
ActionsPerMinute = 12,
|
||||
@@ -489,6 +489,59 @@ Config.Picstagram = {
|
||||
AdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.SkyPic = {
|
||||
PageSize = 30,
|
||||
ThreadPageSize = 200,
|
||||
InboxPageSize = 100,
|
||||
SuggestionLimit = 20,
|
||||
SearchLimit = 30,
|
||||
HandleMinLength = 3,
|
||||
HandleMaxLength = 24,
|
||||
DisplayNameMaxLength = 40,
|
||||
BioMaxLength = 160,
|
||||
MessageMaxLength = 2000,
|
||||
CaptionMaxLength = 160,
|
||||
OverlayTextMaxLength = 160,
|
||||
MinimumViewSeconds = 1,
|
||||
MaximumViewSeconds = 10,
|
||||
MaximumSnapRecipients = 20,
|
||||
MaximumMediaPerSend = 10,
|
||||
MaximumSnapMessagesPerSend = 40,
|
||||
MaximumFriends = 500,
|
||||
MaximumPendingRequests = 100,
|
||||
MaximumActiveStories = 50,
|
||||
MaximumActiveSpotlights = 20,
|
||||
MaximumActiveSponsoredSpotlights = 3,
|
||||
UnopenedSnapLifetimeSeconds = 30 * 24 * 60 * 60,
|
||||
ReplayWindowSeconds = 5 * 60,
|
||||
TextAfterReadLifetimeSeconds = 24 * 60 * 60,
|
||||
StoryLifetimeSeconds = 24 * 60 * 60,
|
||||
SpotlightLifetimeSeconds = 30 * 24 * 60 * 60,
|
||||
SponsoredSpotlightLifetimeSeconds = 7 * 24 * 60 * 60,
|
||||
SpotlightPageSize = 12,
|
||||
SpotlightCommentPageSize = 50,
|
||||
SpotlightCommentMaxLength = 500,
|
||||
SpotlightAdHeadlineMaxLength = 80,
|
||||
AllowSponsoredSpotlights = true,
|
||||
SpotlightReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
|
||||
CleanupIntervalSeconds = 45,
|
||||
ProfileActionsPerMinute = 10,
|
||||
ReadActionsPerMinute = 120,
|
||||
SearchActionsPerMinute = 30,
|
||||
FriendActionsPerMinute = 30,
|
||||
MessagesPerMinute = 30,
|
||||
SnapsPerMinute = 20,
|
||||
SnapRecipientsPerMinute = 120,
|
||||
OpensPerMinute = 120,
|
||||
StoriesPerMinute = 6,
|
||||
StoryViewsPerMinute = 120,
|
||||
SpotlightsPerMinute = 4,
|
||||
SpotlightViewsPerMinute = 120,
|
||||
SpotlightReactionsPerMinute = 120,
|
||||
SpotlightCommentsPerMinute = 20,
|
||||
SpotlightReportsPerMinute = 10,
|
||||
}
|
||||
|
||||
Config.Feather = {
|
||||
PageSize = 20,
|
||||
TextMaxLength = 360,
|
||||
|
||||
@@ -123,12 +123,13 @@ Locales["de"] = {
|
||||
ready = {
|
||||
eyebrow = "Einrichtung abgeschlossen", title = "Willkommen, {name}", body = "Dein Sky Phone ist eingerichtet und bereit. Du kannst alles später in den Einstellungen anpassen.",
|
||||
localOnly = "Auf diesem Handy gespeichert", enter = "Sky Phone öffnen", review = "Einrichtung prüfen",
|
||||
saving = "Einrichtung wird gespeichert", saveFailed = "Die Einrichtung konnte nicht gespeichert werden. Versuche es erneut.",
|
||||
},
|
||||
},
|
||||
Common = {
|
||||
add = "Hinzufügen", back = "Zurück", cancel = "Abbrechen", clear = "Leeren", close = "Schließen", continue = "Weiter", delete = "Löschen", done = "Fertig", edit = "Bearbeiten", home = "Home", loading = "Wird geladen", pause = "Pause", use = "Verwenden",
|
||||
phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen",
|
||||
save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen",
|
||||
phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen", retry = "Erneut versuchen",
|
||||
loadMore = "Mehr laden", save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen",
|
||||
signOut = "Abmelden", signingOut = "Wird abgemeldet...", signOutTitle = "Von {app} abmelden?",
|
||||
signOutBody = "Du wirst nur von {app} abgemeldet. Deine anderen iFruit-Apps bleiben angemeldet.", signOutFailed = "Abmelden fehlgeschlagen. Versuche es erneut.",
|
||||
appAuth = {
|
||||
@@ -529,6 +530,175 @@ Locales["de"] = {
|
||||
default = "Picstagram konnte die Anfrage nicht abschließen."
|
||||
},
|
||||
},
|
||||
skypic = {
|
||||
name = "SkyPic",
|
||||
loading = "SkyPic wird geladen",
|
||||
navigation = "SkyPic-Navigation",
|
||||
time = {
|
||||
seconds = "vor {count} Sek.", minutes = "vor {count} Min.",
|
||||
hours = "vor {count} Std.", days = "vor {count} Tagen",
|
||||
},
|
||||
onboarding = {
|
||||
title = "Willkommen bei SkyPic", eyebrow = "Dein privates Kameranetzwerk",
|
||||
heading = "Teile den Moment",
|
||||
body = "Erstelle ein Profil für private Snaps, Stories mit Freunden, Spotlight-Videos und schnelle Chats.",
|
||||
displayName = "Anzeigename", displayNamePlaceholder = "Dein Name",
|
||||
handle = "Handle", handlePlaceholder = "dein.handle",
|
||||
accountBound = "Dein SkyPic-Profil bleibt mit diesem Sky-Cloud-Konto verbunden.",
|
||||
create = "Profil erstellen", creating = "Wird erstellt...",
|
||||
},
|
||||
auth = {
|
||||
eyebrow = "Dein SkyPic-Account", title = "Willkommen zurück",
|
||||
body = "Fahre mit deinem iFruit-Account fort, um auf dein SkyPic-Profil, deine Snaps und Chats zuzugreifen.",
|
||||
login = "Weiter zu SkyPic", loggingIn = "Wird angemeldet...",
|
||||
noAccount = "Neu bei SkyPic? Erstelle einen iFruit-Account, um loszulegen.",
|
||||
},
|
||||
camera = {
|
||||
eyebrow = "Sky-Kamera", title = "Aufnehmen",
|
||||
body = "Nimm ein Foto oder Video auf und teile es für nur wenige Sekunden.",
|
||||
snap = "Snap", story = "Story", photo = "Foto", video = "Video",
|
||||
gallery = "Galerie öffnen", capturePhoto = "Foto aufnehmen", captureVideo = "Video aufnehmen",
|
||||
snapHint = "Sende es privat an einen oder mehrere Freunde.",
|
||||
storyHint = "Teile es 24 Stunden lang in deiner Story.",
|
||||
},
|
||||
composer = {
|
||||
snapTitle = "Neuer Snap", storyTitle = "Neue Story", spotlightTitle = "Neuer Spotlight",
|
||||
send = "Senden", addStory = "Zur Story hinzufügen", publishSpotlight = "Veröffentlichen",
|
||||
caption = "Bildunterschrift", captionPlaceholder = "Bildunterschrift hinzufügen...",
|
||||
textOverlay = "Text im Bild", textPlaceholder = "Schreib etwas auf den Moment...",
|
||||
color = "Textfarbe", duration = "Anzeigedauer", seconds = "{count} Sekunden",
|
||||
replay = "Eine Wiederholung erlauben",
|
||||
replayBody = "Empfänger dürfen diesen Snap ein weiteres Mal öffnen.",
|
||||
recipients = "Empfänger", recipientsHint = "Wähle einen oder mehrere Freunde.", recipientLimit = "Wähle bis zu {count} Freunde aus.",
|
||||
selectedCount = "{count} ausgewählt", noFriends = "Füge vor dem Senden einen Freund hinzu.",
|
||||
sponsored = "Gesponserter Beitrag", sponsoredBody = "Kennzeichnet diesen Spotlight eindeutig als Werbung.",
|
||||
adHeadline = "Werbeheadline", adHeadlinePlaceholder = "Was sollen andere entdecken?",
|
||||
allowComments = "Kommentare erlauben", allowCommentsBody = "Andere können diesen Spotlight kommentieren.",
|
||||
changeMedia = "Foto oder Video ändern", sent = "Snap gesendet.", storyPublished = "Story veröffentlicht.",
|
||||
spotlightPublished = "Spotlight veröffentlicht.",
|
||||
},
|
||||
tabs = { camera = "Kamera", chats = "Chats", stories = "Stories", friends = "Freunde", spotlight = "Spotlight" },
|
||||
spotlight = {
|
||||
title = "Spotlight", add = "Spotlight erstellen", empty = "Noch keine Spotlight-Videos",
|
||||
emptyBody = "Nimm das erste Kurzvideo für die SkyPic-Community auf.",
|
||||
videoBy = "Spotlight-Video von {name}", sponsored = "Gesponsert", viewProfile = "Creator-Profil ansehen",
|
||||
like = "Gefällt mir", comments = "Kommentare", views = "Aufrufe", noComments = "Noch keine Kommentare",
|
||||
commentPlaceholder = "Kommentar schreiben...", sendComment = "Senden", commentsDisabled = "Kommentare sind für diesen Spotlight deaktiviert.",
|
||||
deleteComment = "Kommentar löschen", delete = "Spotlight löschen", deleted = "Spotlight gelöscht.",
|
||||
report = "Spotlight melden", reportBody = "Warum soll dieser Spotlight überprüft werden?",
|
||||
submitReport = "Meldung senden", reported = "Spotlight gemeldet.",
|
||||
navigation = "Spotlight-Navigation", previous = "Vorheriger Spotlight", next = "Nächster Spotlight",
|
||||
reportReasons = {
|
||||
spam = "Spam oder irreführend", harassment = "Belästigung", dangerous = "Gefährlicher Inhalt",
|
||||
illegal = "Illegaler Inhalt", other = "Sonstiges",
|
||||
},
|
||||
},
|
||||
chats = {
|
||||
title = "Chats", incoming = "Neue Snaps", noSnaps = "Keine ungeöffneten Snaps",
|
||||
conversations = "Unterhaltungen", noConversations = "Deine Unterhaltungen erscheinen hier.",
|
||||
start = "Unterhaltung beginnen", message = "Nachricht", threadPlaceholder = "Nachricht schreiben...", messageLimit = "Nachrichten dürfen bis zu {count} Zeichen enthalten.",
|
||||
saved = "Im Chat gespeichert", save = "Speichern", unsave = "Nicht mehr speichern", delete = "Löschen",
|
||||
sendSnap = "Snap senden", moreActions = "Weitere Aktionen",
|
||||
attachPhoto = "Fotos anhängen", takePhoto = "Foto aufnehmen", emoji = "Emoji", attachVideo = "Video anhängen",
|
||||
attachmentPreview = "Ausgewählte Anhänge", removeAttachment = "Anhang {number} entfernen",
|
||||
moveAttachmentEarlier = "Anhang {number} nach vorne verschieben", moveAttachmentLater = "Anhang {number} nach hinten verschieben",
|
||||
attachmentLimit = "Du kannst bis zu {count} Fotos anhängen.",
|
||||
sendingAttachments = "Fotos werden gesendet...", photoAttachmentsSent = "Fotos gesendet.",
|
||||
sending = "Wird gesendet...", failed = "Nicht zugestellt",
|
||||
},
|
||||
snaps = {
|
||||
newVideo = "Neuer Video-Snap", newPhoto = "Neuer Foto-Snap", replayed = "Wiederholt",
|
||||
opened = "Geöffnet", video = "Video-Snap", photo = "Foto-Snap", replay = "Snap wiederholen",
|
||||
},
|
||||
stories = {
|
||||
title = "Stories", add = "Story hinzufügen", yours = "Deine Story", friends = "Freunde",
|
||||
emptyTitle = "Gerade keine Stories",
|
||||
emptyBody = "Stories deiner Freunde erscheinen hier für 24 Stunden.",
|
||||
views = "{count} Aufrufe", viewers = "Zuschauer", noViewers = "Noch keine Aufrufe",
|
||||
replyPlaceholder = "Auf diese Story antworten...", replySent = "Antwort gesendet.", replyLimit = "Antworten dürfen bis zu {count} Zeichen enthalten.",
|
||||
delete = "Story löschen", deleted = "Story gelöscht.", seen = "Gesehen", unseen = "Neu",
|
||||
},
|
||||
friends = {
|
||||
title = "Freunde", searchPlaceholder = "Name oder @Handle suchen",
|
||||
searchResults = "Suchergebnisse", score = "{count} Punkte",
|
||||
requests = "Freundschaftsanfragen", sentRequests = "Gesendete Anfragen", accept = "Annehmen", decline = "Ablehnen",
|
||||
quickAdd = "Schnell hinzufügen", add = "Hinzufügen", pending = "Ausstehend", cancelRequest = "Anfrage zurückziehen", all = "Deine Freunde",
|
||||
empty = "Füge Freunde hinzu, um Snaps zu senden.", remove = "Freund entfernen", block = "Blockieren", blockedProfiles = "Blockierte Profile", unblock = "Blockierung aufheben",
|
||||
chat = "Chat", sendSnap = "Snap senden", respond = "Antworten", friends = "Freunde",
|
||||
requestSent = "Freundschaftsanfrage an {name} gesendet.", requestCanceled = "Freundschaftsanfrage an {name} zurückgezogen.",
|
||||
removed = "{name} wurde aus deinen Freunden entfernt.", blocked = "{name} wurde blockiert.", unblocked = "{name} wurde entsperrt.",
|
||||
},
|
||||
profile = {
|
||||
title = "Profil", edit = "Profil bearbeiten", save = "Speichern", cancel = "Abbrechen",
|
||||
score = "Snap-Score", streaks = "Serien", friends = "Freunde", bio = "Bio",
|
||||
bioPlaceholder = "Erzähl deinen Freunden ein wenig über dich...",
|
||||
storyPrivacy = "Story-Sichtbarkeit", privacyFriends = "Nur Freunde", privacyEveryone = "Alle",
|
||||
allowStoryReplies = "Story-Antworten erlauben",
|
||||
allowStoryRepliesBody = "Freunde können im Chat auf deine Stories antworten.",
|
||||
showInQuickAdd = "In Schnell hinzufügen anzeigen",
|
||||
showInQuickAddBody = "Andere Profile dürfen dich als Vorschlag entdecken.",
|
||||
saved = "Profil aktualisiert.",
|
||||
account = "Account", logout = "Abmelden", logoutTitle = "Von SkyPic abmelden?",
|
||||
logoutBody = "Du wirst nur von SkyPic abgemeldet. Dein Profil, deine Snaps und Chats bleiben verfügbar.",
|
||||
loggingOut = "Wird abgemeldet...", deleteAccount = "SkyPic-Account löschen",
|
||||
deleteAccountTitle = "Deinen SkyPic-Account löschen?",
|
||||
deleteAccountBody = "Dein SkyPic-Profil, deine Freunde, Snaps, Stories, Spotlights, Kommentare und Chats werden dauerhaft gelöscht. Dein iFruit-Account und deine Fotomediathek bleiben verfügbar.",
|
||||
deletingAccount = "Account wird gelöscht...",
|
||||
},
|
||||
viewer = { close = "Schließen", timeLeft = "{count}s" },
|
||||
notifications = {
|
||||
friend_request = "{actor} hat dir eine Freundschaftsanfrage gesendet.",
|
||||
friend_accepted = "{actor} hat deine Freundschaftsanfrage angenommen.",
|
||||
snap = "{actor} hat dir einen Snap gesendet.", message = "{actor} hat dir eine Nachricht gesendet.",
|
||||
story_reply = "{actor} hat auf deine Story geantwortet.", snap_opened = "{actor} hat deinen Snap geöffnet.",
|
||||
default = "Du hast neue SkyPic-Aktivität.",
|
||||
},
|
||||
errors = {
|
||||
profile_required = "Erstelle zuerst dein SkyPic-Profil.",
|
||||
profile_exists = "Dieses Konto hat bereits ein SkyPic-Profil.",
|
||||
invalid_handle = "Verwende 3-24 Buchstaben, Zahlen, Punkte oder Unterstriche.",
|
||||
handle_taken = "Dieser Handle ist bereits vergeben.",
|
||||
invalid_display_name = "Gib einen Anzeigenamen ein.", invalid_bio = "Deine Bio ist zu lang.",
|
||||
invalid_avatar = "Wähle ein gültiges Profilfoto.", invalid_avatar_seed = "Wähle einen gültigen Avatar.",
|
||||
invalid_privacy = "Wähle eine gültige Story-Sichtbarkeit.",
|
||||
invalid_request = "Diese Freundschaftsanfrage ist ungültig.",
|
||||
profile_not_found = "Dieses SkyPic-Profil ist nicht verfügbar.", blocked = "Dieses Profil ist blockiert.",
|
||||
friendship_not_found = "Diese Freundschaft ist nicht verfügbar.",
|
||||
friend_request_exists = "Eine Freundschaftsanfrage besteht bereits.",
|
||||
friend_limit_reached = "Deine Freundesliste ist voll.",
|
||||
request_limit_reached = "Zu viele offene Freundschaftsanfragen.",
|
||||
invalid_recipients = "Wähle mindestens einen aktuellen Freund.",
|
||||
invalid_media = "Wähle Medien von diesem Telefon.",
|
||||
invalid_media_type = "Dieser Medientyp wird nicht unterstützt.",
|
||||
invalid_duration = "Wähle eine Anzeigedauer von 1 bis 10 Sekunden.",
|
||||
invalid_caption = "Diese Bildunterschrift ist zu lang.",
|
||||
invalid_overlay = "Dieser eingeblendete Text ist zu lang.",
|
||||
invalid_color = "Wähle eine gültige Textfarbe.",
|
||||
snap_unavailable = "Dieser Snap ist nicht mehr verfügbar.",
|
||||
replay_unavailable = "Dieser Snap kann nicht noch einmal wiederholt werden.",
|
||||
message_empty = "Schreibe vor dem Senden eine Nachricht.",
|
||||
message_too_long = "Nachrichten dürfen höchstens 2000 Zeichen enthalten.",
|
||||
message_not_found = "Diese Nachricht ist nicht verfügbar.",
|
||||
story_limit_reached = "Du hast das Limit aktiver Stories erreicht.",
|
||||
story_unavailable = "Diese Story ist nicht mehr verfügbar.",
|
||||
spotlight_unavailable = "Dieser Spotlight ist nicht mehr verfügbar.",
|
||||
spotlight_limit_reached = "Du hast das Limit aktiver Spotlights erreicht.",
|
||||
sponsored_limit_reached = "Du hast das Limit aktiver gesponserter Spotlights erreicht.",
|
||||
ads_disabled = "Gesponserte Spotlights sind deaktiviert.",
|
||||
invalid_ad_headline = "Gib eine Werbeheadline mit 3 bis 80 Zeichen ein.",
|
||||
invalid_comment = "Gib einen gültigen Kommentar ein.", comments_disabled = "Kommentare sind deaktiviert.",
|
||||
comment_not_found = "Dieser Kommentar ist nicht verfügbar.", invalid_report = "Wähle einen gültigen Meldegrund.",
|
||||
not_authorized = "Du darfst das nicht tun.",
|
||||
confirmation_required = "Bestätige, dass du deinen SkyPic-Account löschen möchtest.",
|
||||
too_many_snaps = "Wähle weniger Fotos oder Empfänger.",
|
||||
rate_limited = "Warte einen Moment und versuche es erneut.",
|
||||
request_timeout = "Die SkyPic-Anfrage hat zu lange gedauert. Versuche es erneut.",
|
||||
request_failed = "SkyPic konnte die Anfrage nicht abschließen.",
|
||||
not_authenticated = "Melde dich zuerst bei Sky Cloud an.",
|
||||
unknown_error = "SkyPic konnte die Anfrage nicht abschließen.",
|
||||
default = "SkyPic konnte die Anfrage nicht abschließen.",
|
||||
},
|
||||
},
|
||||
feather = {
|
||||
name = "Feather", loading = "Feather wird geladen...", home = "Home", explore = "Erkunden", activity = "Mitteilungen", activityNav = "Mitteilungen", profile = "Profil", back = "Zurück",
|
||||
settings = "Einstellungen", settingsEyebrow = "Feather-Einstellungen", compactMode = "Kompakte Timeline", compactModeBody = "Mehr Beiträge auf dem Bildschirm anzeigen.", showSuggestions = "Profilvorschläge", showSuggestionsBody = "Zeige Profile an, denen du vielleicht folgen möchtest.",
|
||||
@@ -1008,8 +1178,9 @@ Locales["de"] = {
|
||||
message_unavailable = "Das Gespräch konnte nicht eröffnet werden.", contact_remove_failed = "Der Kontakt konnte nicht entfernt werden.", contact_favorite_failed = "Der Favorit konnte nicht aktualisiert werden.",
|
||||
blocked = "Diese Nummer ist blockiert.", recipient_not_found = "Diese Nummer ist nicht bekannt.",
|
||||
rate_limited = "Zu viele Anrufe. Versuch es in einer Minute erneut.", voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
call_not_connected = "Schließ den Anruf an, bevor du den Lautsprechermodus ermöglichst.", speaker_unavailable = "Der Lautsprechermodus ist für den konfigurierten Handy-Sprachdienst nicht verfügbar.",
|
||||
call_not_connected = "Nimm den Anruf an, bevor du die Audiosteuerung änderst.", speaker_unavailable = "Der Lautsprechermodus ist für den konfigurierten Handy-Sprachdienst nicht verfügbar.",
|
||||
speaker_unsupported = "Der konfigurierte Handy-Sprachdienst unterstützt den Lautsprechermodus nicht.",
|
||||
mute_unavailable = "Die Stummschaltung ist für den konfigurierten Handy-Sprachdienst nicht verfügbar.", mute_unsupported = "Der konfigurierte Handy-Sprachdienst unterstützt keine Stummschaltung.",
|
||||
inventory_full = "Es gibt keinen Platz für die ausgeworfene SIM-Karte.", request_failed = "Die Telefonanfrage ist fehlgeschlagen.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.", sim_request_expired = "Die SIM-Auswahl ist abgelaufen. Verwende die SIM-Karte erneut.",
|
||||
sim_not_owned = "Diese SIM Karte ist nicht mehr in deinem Inventar.", phone_not_owned = "Das Handy ist nicht mehr in deinem Inventar.",
|
||||
@@ -1878,6 +2049,7 @@ Locales["de"] = {
|
||||
import_source_unavailable = "Diese Website ist vorübergehend nicht verfügbar.",
|
||||
invalid_import_url = "Gib einen gültigen HTTPS-Medienlink ein.", import_url_not_allowed = "Dieser Link stammt nicht von der gewählten Website.",
|
||||
import_url_unavailable = "Die vernetzten Medien konnten nicht erreicht werden.", import_size_unavailable = "Die Website lieferte nicht die Mediengröße.",
|
||||
media_in_use = "Dieses Medium wird noch von SkyPic verwendet und kann noch nicht gelöscht werden.",
|
||||
not_found = "Das Medienobjekt existiert nicht mehr.", profile_photo_required = "Dies ist das letzte Foto in deinem Flare-Profil. Füge vor dem Löschen ein weiteres Profilfoto hinzu.", owner_changed = "Das aktive Telefonkonto hat sich geändert.",
|
||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||
rate_limited = "Zu viele Medienaktionen. Versuch es gleich erneut.", request_failed = "Die Anfrage Fotos ist fehlgeschlagen.",
|
||||
@@ -1918,7 +2090,7 @@ Locales["de"] = {
|
||||
health = "Gesundheit, Aktivität und medizinische Informationen",
|
||||
["weazel-news"] = "Lokale Nachrichten und Stadtgeschichten",
|
||||
companies = "Unternehmen, Arbeitsplätze und Dienstleistungen", music = "Lieder, Wiedergabelisten und Audio",
|
||||
picstagram = "Foto-Sharing und sozialer Feed", feather = "Kurze Beiträge und Stadtgespräche",
|
||||
picstagram = "Foto-Sharing und sozialer Feed", skypic = "Private Snaps, Stories und enge Freunde", feather = "Kurze Beiträge und Stadtgespräche",
|
||||
fliptok = "Videos und Trends", flare = "Soziale Beiträge und Live-Momente",
|
||||
calendar = "Veranstaltungen, Termine und Erinnerungen", radio = "Live-Radio und Team-Kommunikation",
|
||||
["local-pages"] = "Lokale Unternehmen und Community-Seiten", crewlink = "Crews, Mitglieder und Koordination",
|
||||
@@ -1944,6 +2116,7 @@ Locales["de"] = {
|
||||
companies = { first = "Unternehmensverzeichnis", second = "Jobanfragen", third = "Dienstleistungen" },
|
||||
music = { first = "Aktuelle Wiedergabe", second = "Playlists", third = "Musikmediathek" },
|
||||
picstagram = { first = "Foto-Feed", second = "Stories", third = "Profile" },
|
||||
skypic = { first = "Private Snaps", second = "Freundes-Stories", third = "Schnelle Chats" },
|
||||
feather = { first = "Kurzbeiträge", second = "Abonniert-Feed", third = "Unterhaltungen" },
|
||||
fliptok = { first = "Video-Feed", second = "Creator-Werkzeuge", third = "Trends" },
|
||||
flare = { first = "Personen entdecken", second = "Matches", third = "Live-Momente" },
|
||||
|
||||
@@ -123,12 +123,13 @@ Locales["en"] = {
|
||||
ready = {
|
||||
eyebrow = "Setup Complete", title = "Welcome, {name}", body = "Your Sky Phone is configured and ready. Your choices can always be refined in Settings.",
|
||||
localOnly = "Stored on this phone", enter = "Enter Sky Phone", review = "Review Setup",
|
||||
saving = "Saving setup", saveFailed = "Setup could not be saved. Try again.",
|
||||
},
|
||||
},
|
||||
Common = {
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", continue = "Continue", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
|
||||
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset", retry = "Try Again",
|
||||
loadMore = "Load More", save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?",
|
||||
signOutBody = "You will only be signed out of {app}. Your other iFruit apps stay signed in.", signOutFailed = "Could not sign out. Please try again.",
|
||||
appAuth = {
|
||||
@@ -529,6 +530,175 @@ Locales["en"] = {
|
||||
default = "Picstagram could not complete the request."
|
||||
},
|
||||
},
|
||||
skypic = {
|
||||
name = "SkyPic",
|
||||
loading = "Loading SkyPic",
|
||||
navigation = "SkyPic navigation",
|
||||
time = {
|
||||
seconds = "{count}s ago", minutes = "{count}m ago",
|
||||
hours = "{count}h ago", days = "{count}d ago",
|
||||
},
|
||||
onboarding = {
|
||||
title = "Welcome to SkyPic", eyebrow = "Your private camera network",
|
||||
heading = "Share the moment",
|
||||
body = "Create a profile for private snaps, close-friend stories, Spotlight videos, and quick chats.",
|
||||
displayName = "Display name", displayNamePlaceholder = "Your name",
|
||||
handle = "Handle", handlePlaceholder = "your.handle",
|
||||
accountBound = "Your SkyPic profile stays linked to this Sky Cloud account.",
|
||||
create = "Create profile", creating = "Creating...",
|
||||
},
|
||||
auth = {
|
||||
eyebrow = "Your SkyPic account", title = "Welcome back",
|
||||
body = "Continue with your iFruit account to access your SkyPic profile, snaps, and chats.",
|
||||
login = "Continue to SkyPic", loggingIn = "Signing in...",
|
||||
noAccount = "New to SkyPic? Create an iFruit account to get started.",
|
||||
},
|
||||
camera = {
|
||||
eyebrow = "Sky camera", title = "Capture",
|
||||
body = "Take a photo or video and share it for just a few seconds.",
|
||||
snap = "Snap", story = "Story", photo = "Photo", video = "Video",
|
||||
gallery = "Open gallery", capturePhoto = "Take photo", captureVideo = "Record video",
|
||||
snapHint = "Send it privately to one or more friends.",
|
||||
storyHint = "Share it with your story for 24 hours.",
|
||||
},
|
||||
composer = {
|
||||
snapTitle = "New snap", storyTitle = "New story", spotlightTitle = "New Spotlight",
|
||||
send = "Send", addStory = "Add to story", publishSpotlight = "Publish",
|
||||
caption = "Caption", captionPlaceholder = "Add a caption...",
|
||||
textOverlay = "Text overlay", textPlaceholder = "Put words on the moment...",
|
||||
color = "Text color", duration = "View duration", seconds = "{count} seconds",
|
||||
replay = "Allow one replay",
|
||||
replayBody = "Recipients may open this snap one additional time.",
|
||||
recipients = "Recipients", recipientsHint = "Choose one or more friends.", recipientLimit = "Choose up to {count} friends.",
|
||||
selectedCount = "{count} selected", noFriends = "Add a friend before sending a snap.",
|
||||
sponsored = "Sponsored post", sponsoredBody = "Clearly label this Spotlight as advertising.",
|
||||
adHeadline = "Ad headline", adHeadlinePlaceholder = "What should people discover?",
|
||||
allowComments = "Allow comments", allowCommentsBody = "People can comment on this Spotlight.",
|
||||
changeMedia = "Change photo or video", sent = "Snap sent.", storyPublished = "Story published.",
|
||||
spotlightPublished = "Spotlight published.",
|
||||
},
|
||||
tabs = { camera = "Camera", chats = "Chats", stories = "Stories", friends = "Friends", spotlight = "Spotlight" },
|
||||
spotlight = {
|
||||
title = "Spotlight", add = "Create Spotlight", empty = "No Spotlight videos yet",
|
||||
emptyBody = "Record the first short video for the SkyPic community.",
|
||||
videoBy = "Spotlight video by {name}", sponsored = "Sponsored", viewProfile = "View creator profile",
|
||||
like = "Like", comments = "Comments", views = "Views", noComments = "No comments yet",
|
||||
commentPlaceholder = "Write a comment...", sendComment = "Send", commentsDisabled = "Comments are disabled for this Spotlight.",
|
||||
deleteComment = "Delete comment", delete = "Delete Spotlight", deleted = "Spotlight deleted.",
|
||||
report = "Report Spotlight", reportBody = "Tell us why this Spotlight should be reviewed.",
|
||||
submitReport = "Submit report", reported = "Spotlight reported.",
|
||||
navigation = "Spotlight navigation", previous = "Previous Spotlight", next = "Next Spotlight",
|
||||
reportReasons = {
|
||||
spam = "Spam or misleading", harassment = "Harassment", dangerous = "Dangerous content",
|
||||
illegal = "Illegal content", other = "Other",
|
||||
},
|
||||
},
|
||||
chats = {
|
||||
title = "Chats", incoming = "New snaps", noSnaps = "No unopened snaps",
|
||||
conversations = "Conversations", noConversations = "Your conversations will appear here.",
|
||||
start = "Start a conversation", message = "Message", threadPlaceholder = "Write a message...", messageLimit = "Messages can contain up to {count} characters.",
|
||||
saved = "Saved in chat", save = "Save", unsave = "Unsave", delete = "Delete",
|
||||
sendSnap = "Send a snap", moreActions = "More actions",
|
||||
attachPhoto = "Attach photos", takePhoto = "Take photo", emoji = "Emoji", attachVideo = "Attach video",
|
||||
attachmentPreview = "Selected attachments", removeAttachment = "Remove attachment {number}",
|
||||
moveAttachmentEarlier = "Move attachment {number} earlier", moveAttachmentLater = "Move attachment {number} later",
|
||||
attachmentLimit = "You can attach up to {count} photos.",
|
||||
sendingAttachments = "Sending photos...", photoAttachmentsSent = "Photos sent.",
|
||||
sending = "Sending...", failed = "Not delivered",
|
||||
},
|
||||
snaps = {
|
||||
newVideo = "New video snap", newPhoto = "New photo snap", replayed = "Replayed",
|
||||
opened = "Opened", video = "Video snap", photo = "Photo snap", replay = "Replay snap",
|
||||
},
|
||||
stories = {
|
||||
title = "Stories", add = "Add story", yours = "Your story", friends = "Friends",
|
||||
emptyTitle = "No stories right now",
|
||||
emptyBody = "Stories from your friends will appear here for 24 hours.",
|
||||
views = "{count} views", viewers = "Viewers", noViewers = "No views yet",
|
||||
replyPlaceholder = "Reply to this story...", replySent = "Reply sent.", replyLimit = "Replies can contain up to {count} characters.",
|
||||
delete = "Delete story", deleted = "Story deleted.", seen = "Seen", unseen = "New",
|
||||
},
|
||||
friends = {
|
||||
title = "Friends", searchPlaceholder = "Search name or @handle",
|
||||
searchResults = "Search results", score = "{count} points",
|
||||
requests = "Friend requests", sentRequests = "Sent requests", accept = "Accept", decline = "Decline",
|
||||
quickAdd = "Quick Add", add = "Add", pending = "Pending", cancelRequest = "Cancel request", all = "Your friends",
|
||||
empty = "Add friends to start snapping.", remove = "Remove friend", block = "Block", blockedProfiles = "Blocked profiles", unblock = "Unblock",
|
||||
chat = "Chat", sendSnap = "Send snap", respond = "Respond", friends = "Friends",
|
||||
requestSent = "Friend request sent to {name}.", requestCanceled = "Friend request to {name} canceled.",
|
||||
removed = "{name} was removed from your friends.", blocked = "{name} was blocked.", unblocked = "{name} was unblocked.",
|
||||
},
|
||||
profile = {
|
||||
title = "Profile", edit = "Edit profile", save = "Save", cancel = "Cancel",
|
||||
score = "Snap score", streaks = "Streaks", friends = "Friends", bio = "Bio",
|
||||
bioPlaceholder = "Tell your friends a little about you...",
|
||||
storyPrivacy = "Story privacy", privacyFriends = "Friends only", privacyEveryone = "Everyone",
|
||||
allowStoryReplies = "Allow story replies",
|
||||
allowStoryRepliesBody = "Friends can reply to your stories in chat.",
|
||||
showInQuickAdd = "Show in Quick Add",
|
||||
showInQuickAddBody = "Let other profiles discover you as a suggestion.",
|
||||
saved = "Profile updated.",
|
||||
account = "Account", logout = "Sign out", logoutTitle = "Sign out of SkyPic?",
|
||||
logoutBody = "You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.",
|
||||
loggingOut = "Signing out...", deleteAccount = "Delete SkyPic account",
|
||||
deleteAccountTitle = "Delete your SkyPic account?",
|
||||
deleteAccountBody = "Your SkyPic profile, friends, snaps, stories, Spotlights, comments, and chats will be permanently deleted. Your iFruit account and Photos library stay available.",
|
||||
deletingAccount = "Deleting account...",
|
||||
},
|
||||
viewer = { close = "Close", timeLeft = "{count}s" },
|
||||
notifications = {
|
||||
friend_request = "{actor} sent you a friend request.",
|
||||
friend_accepted = "{actor} accepted your friend request.",
|
||||
snap = "{actor} sent you a snap.", message = "{actor} sent you a message.",
|
||||
story_reply = "{actor} replied to your story.", snap_opened = "{actor} opened your snap.",
|
||||
default = "You have new SkyPic activity.",
|
||||
},
|
||||
errors = {
|
||||
profile_required = "Create your SkyPic profile first.",
|
||||
profile_exists = "This account already has a SkyPic profile.",
|
||||
invalid_handle = "Use 3-24 letters, numbers, dots, or underscores.",
|
||||
handle_taken = "This handle is already taken.",
|
||||
invalid_display_name = "Enter a display name.", invalid_bio = "Your bio is too long.",
|
||||
invalid_avatar = "Choose a valid profile photo.", invalid_avatar_seed = "Choose a valid avatar.",
|
||||
invalid_privacy = "Choose a valid story privacy setting.",
|
||||
invalid_request = "This friend request is invalid.",
|
||||
profile_not_found = "This SkyPic profile is unavailable.", blocked = "This profile is blocked.",
|
||||
friendship_not_found = "This friendship is unavailable.",
|
||||
friend_request_exists = "A friend request already exists.",
|
||||
friend_limit_reached = "Your friends list is full.",
|
||||
request_limit_reached = "Too many open friend requests.",
|
||||
invalid_recipients = "Choose at least one current friend.",
|
||||
invalid_media = "Choose media from this phone.",
|
||||
invalid_media_type = "This media type is not supported.",
|
||||
invalid_duration = "Choose a view time from 1 to 10 seconds.",
|
||||
invalid_caption = "This caption is too long.",
|
||||
invalid_overlay = "This text overlay is too long.",
|
||||
invalid_color = "Choose a valid overlay color.",
|
||||
snap_unavailable = "This snap is no longer available.",
|
||||
replay_unavailable = "This snap cannot be replayed again.",
|
||||
message_empty = "Write a message before sending.",
|
||||
message_too_long = "Messages can contain at most 2000 characters.",
|
||||
message_not_found = "This message is unavailable.",
|
||||
story_limit_reached = "Your active story limit is reached.",
|
||||
story_unavailable = "This story is no longer available.",
|
||||
spotlight_unavailable = "This Spotlight is no longer available.",
|
||||
spotlight_limit_reached = "Your active Spotlight limit is reached.",
|
||||
sponsored_limit_reached = "Your active sponsored Spotlight limit is reached.",
|
||||
ads_disabled = "Sponsored Spotlights are disabled.",
|
||||
invalid_ad_headline = "Enter an ad headline with 3 to 80 characters.",
|
||||
invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.",
|
||||
comment_not_found = "This comment is unavailable.", invalid_report = "Choose a valid report reason.",
|
||||
not_authorized = "You are not allowed to do that.",
|
||||
confirmation_required = "Confirm that you want to delete your SkyPic account.",
|
||||
too_many_snaps = "Choose fewer photos or recipients.",
|
||||
rate_limited = "Slow down for a moment and try again.",
|
||||
request_timeout = "The SkyPic request timed out. Try again.",
|
||||
request_failed = "SkyPic could not complete the request.",
|
||||
not_authenticated = "Sign in to Sky Cloud first.",
|
||||
unknown_error = "SkyPic could not complete the request.",
|
||||
default = "SkyPic could not complete the request.",
|
||||
},
|
||||
},
|
||||
feather = {
|
||||
name = "Feather", loading = "Loading Feather", home = "Home", explore = "Explore", activity = "Notifications", activityNav = "Alerts", profile = "Profile", back = "Back",
|
||||
settings = "Settings", settingsEyebrow = "Feather preferences", compactMode = "Compact timeline", compactModeBody = "Show more conversation on the screen.", showSuggestions = "Profile suggestions", showSuggestionsBody = "Show people to follow on your profile.",
|
||||
@@ -1008,8 +1178,9 @@ Locales["en"] = {
|
||||
message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.",
|
||||
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
|
||||
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
call_not_connected = "Connect the call before enabling speaker mode.", speaker_unavailable = "Speaker mode is not available for the configured phone voice service.",
|
||||
call_not_connected = "Connect the call before changing its audio controls.", speaker_unavailable = "Speaker mode is not available for the configured phone voice service.",
|
||||
speaker_unsupported = "The configured phone voice service does not support speaker mode.",
|
||||
mute_unavailable = "Mute is not available for the configured phone voice service.", mute_unsupported = "The configured phone voice service does not support mute.",
|
||||
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
|
||||
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
|
||||
sim_not_owned = "That SIM card is no longer in your inventory.", phone_not_owned = "That phone is no longer in your inventory.",
|
||||
@@ -1878,6 +2049,7 @@ Locales["en"] = {
|
||||
import_source_unavailable = "This website is temporarily unavailable.",
|
||||
invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.",
|
||||
import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.",
|
||||
media_in_use = "This media is still used by SkyPic and cannot be deleted yet.",
|
||||
not_found = "The media item no longer exists.", profile_photo_required = "This is the last photo on your Flare profile. Add another profile photo before deleting it.", owner_changed = "The active phone account changed.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Photos request failed.",
|
||||
@@ -1918,7 +2090,7 @@ Locales["en"] = {
|
||||
health = "Health, activity and medical information",
|
||||
["weazel-news"] = "Local news and city stories",
|
||||
companies = "Businesses, jobs and services", music = "Songs, playlists and audio",
|
||||
picstagram = "Photo sharing and social feed", feather = "Short posts and city conversations",
|
||||
picstagram = "Photo sharing and social feed", skypic = "Private snaps, stories and close friends", feather = "Short posts and city conversations",
|
||||
fliptok = "Short videos and trends", flare = "Social posts and live moments",
|
||||
calendar = "Events, schedules and reminders", radio = "Live radio and team communication",
|
||||
["local-pages"] = "Local businesses and community pages", crewlink = "Crews, members and coordination",
|
||||
@@ -1944,6 +2116,7 @@ Locales["en"] = {
|
||||
companies = { first = "Business directory", second = "Job requests", third = "Services" },
|
||||
music = { first = "Now playing", second = "Playlists", third = "Music library" },
|
||||
picstagram = { first = "Photo feed", second = "Stories", third = "Profiles" },
|
||||
skypic = { first = "Private snaps", second = "Friend stories", third = "Quick chats" },
|
||||
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" },
|
||||
|
||||
@@ -55,6 +55,7 @@ server_scripts {
|
||||
'config/media.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'source/server/update_check.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
'source/bridge/server/migrations.lua',
|
||||
'source/bridge/server/callbacks.lua',
|
||||
@@ -82,6 +83,7 @@ server_scripts {
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/skypic.lua',
|
||||
'source/server/weazel_news.lua',
|
||||
'source/server/citywarn.lua',
|
||||
'source/server/messages.lua',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
local provider_resources = {
|
||||
yaca = "yaca-voice",
|
||||
pma = "pma-voice",
|
||||
saltychat = "saltychat",
|
||||
}
|
||||
local provider_aliases = {
|
||||
["yaca-voice"] = "yaca",
|
||||
["pma-voice"] = "pma",
|
||||
salty = "saltychat",
|
||||
}
|
||||
@@ -22,7 +24,12 @@ function Bridge.Calls.GetProvider()
|
||||
end
|
||||
|
||||
function Bridge.Calls.SupportsSpeaker()
|
||||
return Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"
|
||||
local selected = resolve_provider()
|
||||
return Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")
|
||||
end
|
||||
|
||||
function Bridge.Calls.SupportsMute()
|
||||
return resolve_provider() == "yaca"
|
||||
end
|
||||
|
||||
function Bridge.Calls.Join(channel)
|
||||
@@ -37,8 +44,8 @@ function Bridge.Calls.Join(channel)
|
||||
return true
|
||||
end
|
||||
|
||||
if selected == "saltychat" then
|
||||
-- SaltyChat call membership is owned by the server bridge.
|
||||
if selected == "yaca" or selected == "saltychat" then
|
||||
-- Yaca and SaltyChat call membership is owned by the server bridge.
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -5,10 +5,67 @@ Bridge.Housing = {
|
||||
function Bridge.Housing.RegisterClientProvider(name, provider)
|
||||
assert(type(name) == "string" and name ~= "", "Housing provider name must be a non-empty string")
|
||||
assert(type(provider) == "table" and type(provider.execute) == "function", "Housing client provider must implement execute")
|
||||
assert(
|
||||
provider.enrich_overview == nil or type(provider.enrich_overview) == "function",
|
||||
"Housing client provider enrich_overview must be a function"
|
||||
)
|
||||
assert(not Bridge.Housing.ClientProviders[name], ("Housing client provider '%s' is already registered"):format(name))
|
||||
Bridge.Housing.ClientProviders[name] = provider
|
||||
end
|
||||
|
||||
function Bridge.Housing.EnrichOverview(provider_name, properties)
|
||||
if type(properties) ~= "table" then
|
||||
return nil, "invalid_overview"
|
||||
end
|
||||
local provider = Bridge.Housing.ClientProviders[provider_name]
|
||||
if not provider or type(provider.enrich_overview) ~= "function" then
|
||||
return properties
|
||||
end
|
||||
|
||||
local success, enriched, error_code = pcall(provider.enrich_overview, properties)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Housing provider '%s' overview enrichment failed: %s",
|
||||
tostring(provider_name),
|
||||
tostring(enriched)
|
||||
)
|
||||
return properties
|
||||
end
|
||||
if type(enriched) ~= "table" then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Housing provider '%s' returned invalid overview enrichment: %s",
|
||||
tostring(provider_name),
|
||||
tostring(error_code or enriched)
|
||||
)
|
||||
return properties
|
||||
end
|
||||
|
||||
local entrances = {}
|
||||
for _, property in ipairs(enriched) do
|
||||
if type(property) == "table" and property.id ~= nil and entrances[property.id] == nil then
|
||||
entrances[property.id] = Bridge.Normalize.Coordinates(property.entrance)
|
||||
end
|
||||
end
|
||||
|
||||
local result = {}
|
||||
for _, property in ipairs(properties) do
|
||||
local entrance = type(property) == "table" and entrances[property.id] or nil
|
||||
if entrance then
|
||||
local normalized = {}
|
||||
for key, value in pairs(property) do
|
||||
normalized[key] = value
|
||||
end
|
||||
normalized.entrance = entrance
|
||||
result[#result + 1] = normalized
|
||||
else
|
||||
result[#result + 1] = property
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
function Bridge.Housing.Execute(provider_name, action, data)
|
||||
local provider = Bridge.Housing.ClientProviders[provider_name]
|
||||
if not provider then
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
local resource_name = "nolag_properties"
|
||||
|
||||
Bridge.Housing.RegisterClientProvider("nolag", {
|
||||
execute = function(action, data)
|
||||
if action ~= "grant_key" and action ~= "revoke_key" and action ~= "toggle_lock" then
|
||||
return false, "capability_unavailable"
|
||||
end
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return false, "invalid_request"
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:housing:nolag:execute", {
|
||||
action = action,
|
||||
propertyId = data.propertyId,
|
||||
providerId = data.providerId,
|
||||
target = data.target,
|
||||
identifier = data.identifier,
|
||||
})
|
||||
if type(result) == "table" and result.success then
|
||||
return true
|
||||
end
|
||||
return false, type(result) == "table" and result.error or "provider_rejected"
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
local provider_name = "quasar"
|
||||
local resource_name = "qs-housing"
|
||||
|
||||
local function table_like(value)
|
||||
local value_type = type(value)
|
||||
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
|
||||
end
|
||||
|
||||
local function field(value, key)
|
||||
if not table_like(value) then
|
||||
return nil
|
||||
end
|
||||
local success, result = pcall(function()
|
||||
return value[key]
|
||||
end)
|
||||
return success and result or nil
|
||||
end
|
||||
|
||||
local function decode_object(value)
|
||||
if table_like(value) then
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" or value == "" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and table_like(decoded) and decoded or nil
|
||||
end
|
||||
|
||||
local function normalized_coords(value)
|
||||
value = decode_object(value)
|
||||
return value and Bridge.Normalize.Coordinates(value) or nil
|
||||
end
|
||||
|
||||
local function property_tables(value, house)
|
||||
value = decode_object(value)
|
||||
if not value then
|
||||
return {}
|
||||
end
|
||||
local result = { value }
|
||||
local named = house and decode_object(field(value, house)) or nil
|
||||
if named and named ~= value then
|
||||
result[#result + 1] = named
|
||||
end
|
||||
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
|
||||
local nested = decode_object(field(value, key))
|
||||
if nested and nested ~= value and nested ~= named then
|
||||
result[#result + 1] = nested
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function entrance_for(value, house)
|
||||
for _, candidate in ipairs(property_tables(value, house)) do
|
||||
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "Enter" }) do
|
||||
local coords = normalized_coords(field(candidate, key))
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
|
||||
for _, key in ipairs({ "coords", "coordinates", "location", "position", "points" }) do
|
||||
local container = decode_object(field(candidate, key))
|
||||
if container then
|
||||
for _, nested_key in ipairs({
|
||||
"entrance", "Entrance", "entry", "Entry", "enter", "Enter", "door", "frontDoor",
|
||||
}) do
|
||||
local coords = normalized_coords(field(container, nested_key))
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
local coords = normalized_coords(container)
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function house_reference(value)
|
||||
if type(value) ~= "string" and type(value) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
local house = tostring(value):match("^%s*(.-)%s*$")
|
||||
return house ~= "" and house or nil
|
||||
end
|
||||
|
||||
local function house_entrance(house)
|
||||
local success, house_data = pcall(function()
|
||||
return exports["qs-housing"]:getHouseData(house)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] qs-housing:getHouseData failed for '%s': %s",
|
||||
house,
|
||||
tostring(house_data)
|
||||
)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
local entrance = entrance_for(house_data, house)
|
||||
if not entrance then
|
||||
return nil, "invalid_coordinates"
|
||||
end
|
||||
return entrance
|
||||
end
|
||||
|
||||
local function enrich_overview(properties)
|
||||
local result = {}
|
||||
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
|
||||
return result
|
||||
end
|
||||
|
||||
for _, property in ipairs(properties) do
|
||||
local house = type(property) == "table" and house_reference(property.providerId) or nil
|
||||
if house and property.id == provider_name .. ":" .. house then
|
||||
local entrance = normalized_coords(property.entrance)
|
||||
if not entrance then
|
||||
entrance = house_entrance(house)
|
||||
end
|
||||
if entrance then
|
||||
result[#result + 1] = {
|
||||
id = property.id,
|
||||
entrance = entrance,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterClientProvider(provider_name, {
|
||||
enrich_overview = enrich_overview,
|
||||
execute = function(action, data)
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
if action ~= "set_waypoint" then
|
||||
return false, "capability_unavailable"
|
||||
end
|
||||
local house = type(data) == "table" and house_reference(data.providerId) or nil
|
||||
if not house then
|
||||
return false, "invalid_property"
|
||||
end
|
||||
local entrance, error_code = house_entrance(house)
|
||||
if not entrance then
|
||||
return false, error_code
|
||||
end
|
||||
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
|
||||
return true
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
local provider_name = "rtx"
|
||||
local resource_name = "rtx_housing"
|
||||
|
||||
Bridge.Housing.RegisterClientProvider(provider_name, {
|
||||
execute = function(action, data)
|
||||
if action ~= "toggle_lock" then
|
||||
return false, "capability_unavailable"
|
||||
end
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
if type(data) ~= "table" or type(data.providerId) ~= "string" then
|
||||
return false, "invalid_request"
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rtx:execute", {
|
||||
action = action,
|
||||
providerId = data.providerId,
|
||||
})
|
||||
if result and result.success then
|
||||
return true
|
||||
end
|
||||
return false, result and result.error or "provider_rejected"
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
local provider_name = "rx"
|
||||
local resource_name = "RxHousing"
|
||||
|
||||
Bridge.Housing.RegisterClientProvider(provider_name, {
|
||||
execute = function(action, data)
|
||||
if action ~= "grant_key" and action ~= "revoke_key" then
|
||||
return false, "capability_unavailable"
|
||||
end
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return false, "invalid_request"
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rx:execute", {
|
||||
action = action,
|
||||
providerId = data.providerId,
|
||||
target = data.target,
|
||||
identifier = data.identifier,
|
||||
})
|
||||
if result and result.success then
|
||||
return true
|
||||
end
|
||||
return false, result and result.error or "provider_rejected"
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
local resource_name = "sn_properties"
|
||||
|
||||
Bridge.Housing.RegisterClientProvider("sn", {
|
||||
execute = function(action, data)
|
||||
if action ~= "grant_key" and action ~= "revoke_key" then
|
||||
return false, "capability_unavailable"
|
||||
end
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return false, "invalid_request"
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:housing:sn:execute", {
|
||||
action = action,
|
||||
propertyId = data.propertyId,
|
||||
providerId = data.providerId,
|
||||
identifier = data.identifier,
|
||||
})
|
||||
if type(result) == "table" and result.success then
|
||||
return true
|
||||
end
|
||||
return false, type(result) == "table" and result.error or "provider_rejected"
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
local provider_name = "vms"
|
||||
local resource_name = "vms_housing"
|
||||
|
||||
Bridge.Housing.RegisterClientProvider(provider_name, {
|
||||
execute = function()
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return false, "provider_unavailable"
|
||||
end
|
||||
return false, "capability_unavailable"
|
||||
end,
|
||||
})
|
||||
@@ -101,6 +101,10 @@ function Bridge.Radio.Join(primary, secondary)
|
||||
local selected = resolve_provider()
|
||||
if selected == "yaca" then
|
||||
local voice = exports["yaca-voice"]
|
||||
if not voice:isEnabled() then
|
||||
Bridge.Debug("error", "[sky_phone] Yaca is started but its voice system is disabled.")
|
||||
return false
|
||||
end
|
||||
if not voice:isRadioEnabled() then
|
||||
voice:enableRadio(true)
|
||||
Wait(100)
|
||||
@@ -150,9 +154,9 @@ end
|
||||
function Bridge.Radio.SetVolume(volume)
|
||||
local selected = resolve_provider()
|
||||
if selected == "yaca" then
|
||||
exports["yaca-voice"]:changeRadioChannelVolumeRaw(1, volume / 100)
|
||||
exports["yaca-voice"]:changeRadioChannelVolumeRaw(volume / 100, 1)
|
||||
if Bridge.Radio.SupportsSecondary() then
|
||||
exports["yaca-voice"]:changeRadioChannelVolumeRaw(2, volume / 100)
|
||||
exports["yaca-voice"]:changeRadioChannelVolumeRaw(volume / 100, 2)
|
||||
end
|
||||
elseif selected == "pma" then
|
||||
exports["pma-voice"]:setRadioVolume(volume)
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
local provider_name = "nolag"
|
||||
local resource_name = "nolag_properties"
|
||||
|
||||
local function same_identifier(left, right)
|
||||
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
|
||||
end
|
||||
|
||||
local normalized_coords = Bridge.Normalize.Coordinates
|
||||
|
||||
local function player_source(identifier)
|
||||
if identifier == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
for _, source in ipairs(Bridge.Framework.GetPlayers()) do
|
||||
if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then
|
||||
return source
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function player_name(source, fallback)
|
||||
if source then
|
||||
local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source)
|
||||
if type(name) == "string" then
|
||||
name = name:match("^%s*(.-)%s*$")
|
||||
if name ~= "" then
|
||||
return name
|
||||
end
|
||||
end
|
||||
end
|
||||
return tostring(fallback)
|
||||
end
|
||||
|
||||
local function get_properties(identifier)
|
||||
local success, properties = pcall(function()
|
||||
return exports[resource_name]:GetAllProperties(identifier, "user", true)
|
||||
end)
|
||||
if not success or type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:GetAllProperties failed: %s", tostring(properties))
|
||||
return nil
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function get_property_data(property_id)
|
||||
local success, property = pcall(function()
|
||||
return exports[resource_name]:GetPropertyData(property_id)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:GetPropertyData failed: %s", tostring(property))
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(property) ~= "table" then
|
||||
return nil, "property_not_found"
|
||||
end
|
||||
return property
|
||||
end
|
||||
|
||||
local function get_keyholders(property_id)
|
||||
local success, keyholders = pcall(function()
|
||||
return exports[resource_name]:GetKeyHolders(property_id)
|
||||
end)
|
||||
if not success or type(keyholders) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:GetKeyHolders failed: %s", tostring(keyholders))
|
||||
return nil
|
||||
end
|
||||
return keyholders
|
||||
end
|
||||
|
||||
local function get_players_in_property(property_id)
|
||||
local success, players = pcall(function()
|
||||
return exports[resource_name]:GetPlayersInProperty(property_id)
|
||||
end)
|
||||
if not success or type(players) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:GetPlayersInProperty failed: %s", tostring(players))
|
||||
return nil
|
||||
end
|
||||
return players
|
||||
end
|
||||
|
||||
local function property_access(property, identifier)
|
||||
if same_identifier(property.owner, identifier) then
|
||||
return "owner"
|
||||
end
|
||||
if same_identifier(property.renter, identifier) then
|
||||
return "keyholder"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function keyholder_exists(keyholders, identifier)
|
||||
if type(identifier) ~= "string" or identifier == "" then
|
||||
return false
|
||||
end
|
||||
return keyholders[identifier] ~= nil
|
||||
end
|
||||
|
||||
local function normalized_keys(property, keyholders)
|
||||
local keys = {}
|
||||
for identifier in pairs(keyholders) do
|
||||
if (type(identifier) == "string" or type(identifier) == "number")
|
||||
and not same_identifier(identifier, property.owner)
|
||||
then
|
||||
local normalized_identifier = tostring(identifier)
|
||||
local source = player_source(normalized_identifier)
|
||||
keys[#keys + 1] = {
|
||||
identifier = normalized_identifier,
|
||||
name = player_name(source, normalized_identifier),
|
||||
online = source ~= nil,
|
||||
revocable = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(keys, function(left, right)
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return keys
|
||||
end
|
||||
|
||||
local function normalized_property(property, details, access, keyholders)
|
||||
local property_id = tonumber(property.id)
|
||||
local entrance = normalized_coords(property.coords)
|
||||
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then
|
||||
return nil
|
||||
end
|
||||
|
||||
local manages_keys = access == "owner"
|
||||
return {
|
||||
id = ("nolag:%s"):format(property_id),
|
||||
providerId = tostring(property_id),
|
||||
name = tostring(property.label or details.label or ("Property %s"):format(property_id)),
|
||||
access = access,
|
||||
locked = property.doorLocked == true,
|
||||
entrance = entrance,
|
||||
capabilities = {
|
||||
lock = property.hasKey == true,
|
||||
keys = manages_keys,
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = manages_keys and normalized_keys(details, keyholders) or nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function find_property(properties, property_id)
|
||||
for _, property in pairs(properties) do
|
||||
if type(property) == "table" and tonumber(property.id) == property_id then
|
||||
return property
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function parse_property_id(data)
|
||||
if type(data) ~= "table" then
|
||||
return nil, "invalid_request"
|
||||
end
|
||||
local property_id = type(data.propertyId) == "string"
|
||||
and tonumber(data.propertyId:match("^nolag:(%d+)$")) or nil
|
||||
if not property_id then
|
||||
property_id = tonumber(data.providerId)
|
||||
end
|
||||
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
|
||||
return nil, "invalid_property"
|
||||
end
|
||||
return property_id
|
||||
end
|
||||
|
||||
local function resolve_property(source, data)
|
||||
local property_id, error_code = parse_property_id(data)
|
||||
if not property_id then
|
||||
return nil, nil, nil, nil, error_code
|
||||
end
|
||||
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, nil, nil, nil, "housing_unavailable"
|
||||
end
|
||||
|
||||
local properties = get_properties(identifier)
|
||||
if not properties then
|
||||
return nil, nil, nil, nil, "provider_error"
|
||||
end
|
||||
local property = find_property(properties, property_id)
|
||||
if not property or not normalized_coords(property.coords) then
|
||||
return nil, nil, nil, nil, "property_access_denied"
|
||||
end
|
||||
|
||||
local details, details_error = get_property_data(property_id)
|
||||
if not details then
|
||||
return nil, nil, nil, nil, details_error
|
||||
end
|
||||
local access = property_access(details, identifier)
|
||||
if not access then
|
||||
return nil, nil, nil, nil, "property_access_denied"
|
||||
end
|
||||
return property, details, property_id, access, nil, tostring(identifier)
|
||||
end
|
||||
|
||||
local function require_current_owner(property_id, identifier)
|
||||
local details, details_error = get_property_data(property_id)
|
||||
if not details then
|
||||
return nil, details_error
|
||||
end
|
||||
if property_access(details, identifier) ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
return details
|
||||
end
|
||||
|
||||
local function get_current_access_property(identifier, property_id)
|
||||
local properties = get_properties(identifier)
|
||||
if not properties then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
local property = find_property(properties, property_id)
|
||||
if not property then
|
||||
return nil, "property_access_denied"
|
||||
end
|
||||
return property
|
||||
end
|
||||
|
||||
local function key_candidates(source, property_id, details)
|
||||
local players = get_players_in_property(property_id)
|
||||
local keyholders = get_keyholders(property_id)
|
||||
if not players or not keyholders then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local candidates = {}
|
||||
local seen = {}
|
||||
for _, value in pairs(players) do
|
||||
local target = tonumber(value)
|
||||
local identifier = target and Bridge.Framework.GetIdentifier(target) or nil
|
||||
if target and target ~= source and identifier and not seen[target]
|
||||
and not same_identifier(identifier, details.owner)
|
||||
and not keyholder_exists(keyholders, tostring(identifier))
|
||||
then
|
||||
seen[target] = true
|
||||
candidates[#candidates + 1] = {
|
||||
id = target,
|
||||
name = player_name(target, identifier),
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(candidates, function(left, right)
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return candidates
|
||||
end
|
||||
|
||||
local function validate_grant_target(source, property_id, details, target)
|
||||
target = tonumber(target)
|
||||
if not target or target == source then
|
||||
return nil, "invalid_target"
|
||||
end
|
||||
|
||||
local players = get_players_in_property(property_id)
|
||||
local keyholders = get_keyholders(property_id)
|
||||
if not players or not keyholders then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local present = false
|
||||
for _, value in pairs(players) do
|
||||
if tonumber(value) == target then
|
||||
present = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not present then
|
||||
return nil, "target_not_in_property"
|
||||
end
|
||||
|
||||
local identifier = Bridge.Framework.GetIdentifier(target)
|
||||
if not identifier then
|
||||
return nil, "invalid_target"
|
||||
end
|
||||
identifier = tostring(identifier)
|
||||
if same_identifier(identifier, details.owner) or keyholder_exists(keyholders, identifier) then
|
||||
return nil, "key_already_exists"
|
||||
end
|
||||
return identifier
|
||||
end
|
||||
|
||||
local function execute_action(source, action, data)
|
||||
if not SkyPhone.AllowOperation(source, "housing_nolag_action", Config.Housing.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if action ~= "grant_key" and action ~= "revoke_key" and action ~= "toggle_lock" then
|
||||
return { success = false, error = "invalid_action" }
|
||||
end
|
||||
|
||||
local property, details, property_id, access, error_code, actor_identifier = resolve_property(source, data)
|
||||
if not property then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
if (action == "grant_key" or action == "revoke_key") and access ~= "owner" then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
if action == "toggle_lock" and property.hasKey ~= true then
|
||||
return { success = false, error = "capability_unavailable" }
|
||||
end
|
||||
|
||||
if action == "grant_key" then
|
||||
local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target)
|
||||
if not target_identifier then
|
||||
return { success = false, error = target_error }
|
||||
end
|
||||
|
||||
local current_details, current_error = require_current_owner(property_id, actor_identifier)
|
||||
if not current_details then
|
||||
return { success = false, error = current_error }
|
||||
end
|
||||
local success, result, provider_error = pcall(function()
|
||||
return exports[resource_name]:AddKey(source, property_id, target_identifier)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:AddKey failed: %s", tostring(result))
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local keyholders_after = get_keyholders(property_id)
|
||||
if not keyholders_after then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
if result ~= true then
|
||||
return { success = false, error = provider_error or "provider_rejected" }
|
||||
end
|
||||
return keyholder_exists(keyholders_after, target_identifier)
|
||||
and { success = true }
|
||||
or { success = false, error = "action_failed" }
|
||||
end
|
||||
|
||||
if action == "revoke_key" then
|
||||
local identifier = data.identifier
|
||||
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
|
||||
return { success = false, error = "invalid_target" }
|
||||
end
|
||||
local keyholders = get_keyholders(property_id)
|
||||
if not keyholders then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
if not keyholder_exists(keyholders, identifier) then
|
||||
return { success = false, error = "key_not_found" }
|
||||
end
|
||||
|
||||
local current_details, current_error = require_current_owner(property_id, actor_identifier)
|
||||
if not current_details then
|
||||
return { success = false, error = current_error }
|
||||
end
|
||||
local success, result, provider_error = pcall(function()
|
||||
return exports[resource_name]:RemoveKey(source, property_id, identifier)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:RemoveKey failed: %s", tostring(result))
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local keyholders_after = get_keyholders(property_id)
|
||||
if not keyholders_after then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
if result ~= true then
|
||||
return { success = false, error = provider_error or "provider_rejected" }
|
||||
end
|
||||
return not keyholder_exists(keyholders_after, identifier)
|
||||
and { success = true }
|
||||
or { success = false, error = "action_failed" }
|
||||
end
|
||||
|
||||
local current_property, current_error = get_current_access_property(actor_identifier, property_id)
|
||||
if not current_property then
|
||||
return { success = false, error = current_error }
|
||||
end
|
||||
if current_property.hasKey ~= true then
|
||||
return { success = false, error = "capability_unavailable" }
|
||||
end
|
||||
|
||||
local desired_state = current_property.doorLocked ~= true
|
||||
local success, result, provider_error = pcall(function()
|
||||
return exports[resource_name]:ToggleDoorlock(source, property_id, desired_state)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] nolag_properties:ToggleDoorlock failed: %s", tostring(result))
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local details_after, details_error = get_property_data(property_id)
|
||||
if not details_after then
|
||||
return { success = false, error = details_error }
|
||||
end
|
||||
if result ~= true then
|
||||
return { success = false, error = provider_error or "provider_rejected" }
|
||||
end
|
||||
return type(details_after.doorLocked) == "boolean" and details_after.doorLocked == desired_state
|
||||
and { success = true }
|
||||
or { success = false, error = "action_failed" }
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = function(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, "housing_unavailable"
|
||||
end
|
||||
identifier = tostring(identifier)
|
||||
|
||||
local properties = get_properties(identifier)
|
||||
if not properties then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local result = {}
|
||||
for _, property in pairs(properties) do
|
||||
local property_id = type(property) == "table" and tonumber(property.id) or nil
|
||||
if property_id then
|
||||
local details = get_property_data(property_id)
|
||||
local access = details and property_access(details, identifier) or nil
|
||||
local keyholders = access == "owner" and get_keyholders(property_id) or {}
|
||||
local normalized = access and keyholders
|
||||
and normalized_property(property, details, access, keyholders) or nil
|
||||
if normalized then
|
||||
result[#result + 1] = normalized
|
||||
if #result >= Config.Housing.MaximumProperties then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(result, function(left, right)
|
||||
if left.access ~= right.access then
|
||||
return left.access == "owner"
|
||||
end
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return result
|
||||
end,
|
||||
prepare = function(source, action, data)
|
||||
local property, details, property_id, access, error_code = resolve_property(source, data)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
if action == "set_waypoint" then
|
||||
return { coords = normalized_coords(property.coords) }
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
if action == "key_candidates" then
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
local candidates, candidates_error = key_candidates(source, property_id, details)
|
||||
return candidates and { candidates = candidates } or nil, candidates_error
|
||||
end
|
||||
if action == "grant_key" then
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target)
|
||||
if not target_identifier then
|
||||
return nil, target_error
|
||||
end
|
||||
return { providerId = tostring(property_id), target = tonumber(data.target) }
|
||||
end
|
||||
if action == "revoke_key" then
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
local identifier = data.identifier
|
||||
local keyholders = get_keyholders(property_id)
|
||||
if not keyholders then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(identifier) ~= "string" or not keyholder_exists(keyholders, identifier) then
|
||||
return nil, "key_not_found"
|
||||
end
|
||||
return { providerId = tostring(property_id), identifier = identifier }
|
||||
end
|
||||
if action == "toggle_lock" then
|
||||
if property.hasKey ~= true then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
return { providerId = tostring(property_id) }
|
||||
end
|
||||
return nil, "invalid_action"
|
||||
end,
|
||||
})
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:housing:nolag:execute", function(source, data)
|
||||
if type(data) ~= "table" or type(data.action) ~= "string" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
return execute_action(source, data.action, data)
|
||||
end)
|
||||
@@ -0,0 +1,237 @@
|
||||
local provider_name = "quasar"
|
||||
local resource_name = "qs-housing"
|
||||
|
||||
local function table_like(value)
|
||||
local value_type = type(value)
|
||||
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
|
||||
end
|
||||
|
||||
local function field(value, key)
|
||||
if not table_like(value) then
|
||||
return nil
|
||||
end
|
||||
local success, result = pcall(function()
|
||||
return value[key]
|
||||
end)
|
||||
return success and result or nil
|
||||
end
|
||||
|
||||
local function decode_object(value)
|
||||
if table_like(value) then
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" or value == "" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and table_like(decoded) and decoded or nil
|
||||
end
|
||||
|
||||
local function normalized_coords(value)
|
||||
value = decode_object(value)
|
||||
return value and Bridge.Normalize.Coordinates(value) or nil
|
||||
end
|
||||
|
||||
local function property_tables(property)
|
||||
if not table_like(property) then
|
||||
return {}
|
||||
end
|
||||
local result = { property }
|
||||
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
|
||||
local nested = decode_object(field(property, key))
|
||||
if nested and nested ~= property then
|
||||
result[#result + 1] = nested
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function entrance_for(property)
|
||||
for _, candidate in ipairs(property_tables(property)) do
|
||||
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do
|
||||
local coords = normalized_coords(field(candidate, key))
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
|
||||
for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do
|
||||
local container = decode_object(field(candidate, key))
|
||||
if container then
|
||||
for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do
|
||||
local coords = normalized_coords(field(container, nested_key))
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
local coords = normalized_coords(container)
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function non_empty_string(value)
|
||||
if type(value) ~= "string" and type(value) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
local text = tostring(value):match("^%s*(.-)%s*$")
|
||||
return text ~= "" and text or nil
|
||||
end
|
||||
|
||||
local function house_reference(key, property)
|
||||
local direct = non_empty_string(property)
|
||||
if direct then
|
||||
return direct
|
||||
end
|
||||
if table_like(property) then
|
||||
for _, name in ipairs({
|
||||
"house", "houseName", "house_name", "name", "identifier",
|
||||
}) do
|
||||
local value = non_empty_string(field(property, name))
|
||||
if value then
|
||||
return value
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(key) == "string" and not tonumber(key) then
|
||||
return non_empty_string(key)
|
||||
end
|
||||
if table_like(property) then
|
||||
for _, name in ipairs({ "propertyId", "property_id", "id" }) do
|
||||
local value = non_empty_string(field(property, name))
|
||||
if value then
|
||||
return value
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function property_name(property, house)
|
||||
for _, candidate in ipairs(property_tables(property)) do
|
||||
for _, key in ipairs({ "label", "address", "adress", "displayName", "display_name", "name" }) do
|
||||
local value = non_empty_string(field(candidate, key))
|
||||
if value then
|
||||
return value
|
||||
end
|
||||
end
|
||||
end
|
||||
return house
|
||||
end
|
||||
|
||||
local function get_player_properties(source)
|
||||
local success, properties = pcall(function()
|
||||
return exports[resource_name]:GetPlayerHouses(source)
|
||||
end)
|
||||
if success and type(properties) == "table" then
|
||||
return properties
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] qs-housing:GetPlayerHouses failed for source %s: %s",
|
||||
tostring(source),
|
||||
tostring(properties)
|
||||
)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local function normalized_properties(source)
|
||||
local properties, error_code = get_player_properties(source)
|
||||
if not properties then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local result = {}
|
||||
local seen = {}
|
||||
for key, property in pairs(properties) do
|
||||
local house = house_reference(key, property)
|
||||
if house and not seen[house] then
|
||||
local entrance = entrance_for(property)
|
||||
seen[house] = true
|
||||
result[#result + 1] = {
|
||||
id = provider_name .. ":" .. house,
|
||||
providerId = house,
|
||||
name = property_name(property, house),
|
||||
access = "owner",
|
||||
locked = false,
|
||||
entrance = entrance,
|
||||
capabilities = {
|
||||
lock = false,
|
||||
keys = false,
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(result, function(left, right)
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
|
||||
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
|
||||
while #result > maximum do
|
||||
result[#result] = nil
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function find_property(source, property_id)
|
||||
if type(property_id) ~= "string" then
|
||||
return nil, "invalid_property"
|
||||
end
|
||||
local house = property_id:match("^quasar:(.+)$")
|
||||
if not house or house == "" then
|
||||
return nil, "invalid_property"
|
||||
end
|
||||
|
||||
local properties, error_code = normalized_properties(source)
|
||||
if not properties then
|
||||
return nil, error_code
|
||||
end
|
||||
for _, property in ipairs(properties) do
|
||||
if property.providerId == house then
|
||||
return property
|
||||
end
|
||||
end
|
||||
return nil, "property_access_denied"
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = normalized_properties,
|
||||
prepare = function(source, action, data)
|
||||
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
|
||||
or action == "key_candidates"
|
||||
then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
if action ~= "set_waypoint" then
|
||||
return nil, "invalid_action"
|
||||
end
|
||||
|
||||
local property, error_code = find_property(source, data and data.propertyId)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
return {
|
||||
providerId = property.providerId,
|
||||
coords = property.entrance,
|
||||
}
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,547 @@
|
||||
local provider_name = "rtx"
|
||||
local resource_name = "rtx_housing"
|
||||
|
||||
local function provider_call(export_name, callback)
|
||||
local success, result = pcall(callback)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] %s:%s failed: %s",
|
||||
resource_name,
|
||||
export_name,
|
||||
tostring(result)
|
||||
)
|
||||
return false, nil
|
||||
end
|
||||
return true, result
|
||||
end
|
||||
|
||||
local function normalize_property_id(value)
|
||||
local number = Bridge.Normalize.FiniteNumber(value)
|
||||
if not number or number < 1 or number ~= math.floor(number) then
|
||||
return nil, nil
|
||||
end
|
||||
local integer = math.tointeger(number)
|
||||
if not integer then
|
||||
return nil, nil
|
||||
end
|
||||
return integer, tostring(integer)
|
||||
end
|
||||
|
||||
local function decode_table(value)
|
||||
local value_type = type(value)
|
||||
if value_type == "table" or value_type == "vector3" or value_type == "vector4" then
|
||||
return value
|
||||
end
|
||||
if value_type ~= "string" or value == "" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and type(decoded) == "table" and decoded or nil
|
||||
end
|
||||
|
||||
local function normalized_coords(value)
|
||||
local coords = Bridge.Normalize.Coordinates(value)
|
||||
if not coords then
|
||||
return nil
|
||||
end
|
||||
if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then
|
||||
return nil
|
||||
end
|
||||
return coords
|
||||
end
|
||||
|
||||
local function nested_coords(value)
|
||||
local data = decode_table(value)
|
||||
if not data then
|
||||
return nil
|
||||
end
|
||||
return normalized_coords(data.coords) or normalized_coords(data)
|
||||
end
|
||||
|
||||
local function property_zone_coords(property)
|
||||
local zone = decode_table(property.propertyzone)
|
||||
local points = zone and decode_table(zone.polyzonedata) or nil
|
||||
if not points then
|
||||
return nil
|
||||
end
|
||||
|
||||
local count = 0
|
||||
local x_total = 0.0
|
||||
local y_total = 0.0
|
||||
local z_total = 0.0
|
||||
local z_count = 0
|
||||
for _, point in pairs(points) do
|
||||
local point_type = type(point)
|
||||
if point_type == "table" or point_type == "vector3" or point_type == "vector4" then
|
||||
local x = Bridge.Normalize.FiniteNumber(point.x)
|
||||
local y = Bridge.Normalize.FiniteNumber(point.y)
|
||||
local z = Bridge.Normalize.FiniteNumber(point.z)
|
||||
if x and y then
|
||||
count = count + 1
|
||||
x_total = x_total + x
|
||||
y_total = y_total + y
|
||||
if z then
|
||||
z_count = z_count + 1
|
||||
z_total = z_total + z
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if count == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local z = z_count > 0 and z_total / z_count or nil
|
||||
if not z then
|
||||
local minimum_z = Bridge.Normalize.FiniteNumber(zone.minz)
|
||||
local maximum_z = Bridge.Normalize.FiniteNumber(zone.maxz)
|
||||
if minimum_z and maximum_z then
|
||||
z = (minimum_z + maximum_z) / 2.0
|
||||
end
|
||||
end
|
||||
return normalized_coords({
|
||||
x = x_total / count,
|
||||
y = y_total / count,
|
||||
z = z,
|
||||
})
|
||||
end
|
||||
|
||||
local function property_entrance(property)
|
||||
local coords = nested_coords(property.enter)
|
||||
or nested_coords(property.sellsign)
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
|
||||
local doors = decode_table(property.doors)
|
||||
if doors then
|
||||
for _, door in pairs(doors) do
|
||||
local door_data = decode_table(door)
|
||||
coords = door_data and nested_coords(door_data.door1) or nil
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
coords = nested_coords(property.garage)
|
||||
return coords or property_zone_coords(property)
|
||||
end
|
||||
|
||||
local function property_identifier(property, fallback)
|
||||
local value = type(property) == "table"
|
||||
and (property.houseid or property.id or property.propertyid)
|
||||
or nil
|
||||
return normalize_property_id(value or fallback)
|
||||
end
|
||||
|
||||
local function get_all_properties()
|
||||
local success, properties = provider_call("GetAllProperties", function()
|
||||
return exports[resource_name]:GetAllProperties()
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function get_owned_properties(source)
|
||||
local success, properties = provider_call("GetPlayerOwnedProperties", function()
|
||||
return exports[resource_name]:GetPlayerOwnedProperties(source)
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:GetPlayerOwnedProperties returned invalid data.", resource_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function get_property(property_id)
|
||||
local success, property = provider_call("GetPropertyData", function()
|
||||
return exports[resource_name]:GetPropertyData(property_id)
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if property == nil then
|
||||
return nil, "property_not_found"
|
||||
end
|
||||
if type(property) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:GetPropertyData returned invalid data.", resource_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return property
|
||||
end
|
||||
|
||||
local function boolean_export(export_name, callback)
|
||||
local success, value = provider_call(export_name, callback)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(value) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function lock_status(property_id)
|
||||
return boolean_export("GetPropertyLockStatus", function()
|
||||
return exports[resource_name]:GetPropertyLockStatus(property_id)
|
||||
end)
|
||||
end
|
||||
|
||||
local function collect_catalog(catalog, properties)
|
||||
local is_array = #properties > 0
|
||||
for key, property in pairs(properties) do
|
||||
if type(property) == "table" then
|
||||
local property_id, id_key = property_identifier(property, is_array and nil or key)
|
||||
if property_id then
|
||||
catalog[id_key] = {
|
||||
id = property_id,
|
||||
property = property,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function collect_owned(properties, catalog)
|
||||
local owned = {}
|
||||
local is_array = #properties > 0
|
||||
for key, value in pairs(properties) do
|
||||
local property = type(value) == "table" and value or nil
|
||||
local fallback = property and (is_array and nil or key)
|
||||
or (is_array and value or key)
|
||||
local property_id, id_key = property_identifier(property, fallback)
|
||||
if property_id then
|
||||
owned[id_key] = property_id
|
||||
if catalog and property then
|
||||
catalog[id_key] = {
|
||||
id = property_id,
|
||||
property = property,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return owned
|
||||
end
|
||||
|
||||
local function property_access(source, property, property_id, id_key, owned, identifier)
|
||||
if owned[id_key] then
|
||||
return "owner"
|
||||
end
|
||||
local owner = property.owner or property.Owner
|
||||
if identifier and owner ~= nil and tostring(owner) == tostring(identifier) then
|
||||
return "owner"
|
||||
end
|
||||
|
||||
local has_keys, error_code = boolean_export("CheckPropertyKeys", function()
|
||||
return exports[resource_name]:CheckPropertyKeys(source, property_id)
|
||||
end)
|
||||
if has_keys == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
if has_keys then
|
||||
return "keyholder"
|
||||
end
|
||||
|
||||
local has_permissions
|
||||
has_permissions, error_code = boolean_export("HasPlayerAnyPropertyPermissions", function()
|
||||
return exports[resource_name]:HasPlayerAnyPropertyPermissions(source, property_id)
|
||||
end)
|
||||
if has_permissions == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
return has_permissions and "keyholder" or nil
|
||||
end
|
||||
|
||||
local function can_control_lock(source, property_id, access)
|
||||
if access == "owner" then
|
||||
return true
|
||||
end
|
||||
return boolean_export("GetPropertyPermission", function()
|
||||
return exports[resource_name]:GetPropertyPermission(source, property_id, "unlocking")
|
||||
end)
|
||||
end
|
||||
|
||||
local function property_name(property, id_key)
|
||||
local name = property.propertyname or property.name
|
||||
if type(name) == "string" and name ~= "" then
|
||||
return name
|
||||
end
|
||||
local address = property.adress or property.address
|
||||
if type(address) == "string" and address ~= "" then
|
||||
return address
|
||||
end
|
||||
return ("Property %s"):format(id_key)
|
||||
end
|
||||
|
||||
local function normalized_property(property, property_id, id_key, access, coords, locked, can_lock)
|
||||
return {
|
||||
id = ("%s:%s"):format(provider_name, id_key),
|
||||
providerId = id_key,
|
||||
name = property_name(property, id_key),
|
||||
access = access,
|
||||
locked = locked,
|
||||
entrance = coords,
|
||||
capabilities = {
|
||||
lock = can_lock == true,
|
||||
keys = false,
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function actor_access(source, property, property_id, id_key)
|
||||
local owned_properties, error_code = get_owned_properties(source)
|
||||
if not owned_properties then
|
||||
return nil, nil, error_code
|
||||
end
|
||||
local owned = collect_owned(owned_properties)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local access
|
||||
access, error_code = property_access(
|
||||
source,
|
||||
property,
|
||||
property_id,
|
||||
id_key,
|
||||
owned,
|
||||
identifier
|
||||
)
|
||||
if error_code then
|
||||
return nil, nil, error_code
|
||||
end
|
||||
if not access then
|
||||
return nil, nil, "property_access_denied"
|
||||
end
|
||||
local can_lock
|
||||
can_lock, error_code = can_control_lock(source, property_id, access)
|
||||
if can_lock == nil then
|
||||
return nil, nil, error_code
|
||||
end
|
||||
return access, can_lock
|
||||
end
|
||||
|
||||
local function resolve_property(source, data)
|
||||
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
|
||||
return nil, nil, nil, nil, nil, "invalid_request"
|
||||
end
|
||||
local raw_id = data.propertyId:match("^rtx:(%d+)$")
|
||||
local property_id, id_key = normalize_property_id(raw_id)
|
||||
if not property_id then
|
||||
return nil, nil, nil, nil, nil, "invalid_property"
|
||||
end
|
||||
|
||||
local property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return nil, nil, nil, nil, nil, error_code
|
||||
end
|
||||
local access, can_lock
|
||||
access, can_lock, error_code = actor_access(source, property, property_id, id_key)
|
||||
if not access then
|
||||
return nil, nil, nil, nil, nil, error_code
|
||||
end
|
||||
local coords = property_entrance(property)
|
||||
if not coords then
|
||||
return nil, nil, nil, nil, nil, "invalid_coordinates"
|
||||
end
|
||||
return property, property_id, id_key, access, can_lock, nil, coords
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = function(source)
|
||||
local properties, error_code = get_all_properties()
|
||||
if not properties then
|
||||
return nil, error_code
|
||||
end
|
||||
local owned_properties
|
||||
owned_properties, error_code = get_owned_properties(source)
|
||||
if not owned_properties then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local catalog = {}
|
||||
collect_catalog(catalog, properties)
|
||||
local owned = collect_owned(owned_properties, catalog)
|
||||
for id_key, property_id in pairs(owned) do
|
||||
if not catalog[id_key] then
|
||||
local property
|
||||
property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
catalog[id_key] = { id = property_id, property = property }
|
||||
end
|
||||
end
|
||||
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local result = {}
|
||||
for id_key, entry in pairs(catalog) do
|
||||
local access
|
||||
access, error_code = property_access(
|
||||
source,
|
||||
entry.property,
|
||||
entry.id,
|
||||
id_key,
|
||||
owned,
|
||||
identifier
|
||||
)
|
||||
if error_code then
|
||||
return nil, error_code
|
||||
end
|
||||
if access then
|
||||
local coords = property_entrance(entry.property)
|
||||
if coords then
|
||||
local locked
|
||||
locked, error_code = lock_status(entry.id)
|
||||
if locked == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
local can_lock
|
||||
can_lock, error_code = can_control_lock(source, entry.id, access)
|
||||
if can_lock == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
result[#result + 1] = normalized_property(
|
||||
entry.property,
|
||||
entry.id,
|
||||
id_key,
|
||||
access,
|
||||
coords,
|
||||
locked,
|
||||
can_lock
|
||||
)
|
||||
else
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Ignored RTX property '%s' because no valid entrance was found.",
|
||||
id_key
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(result, function(left, right)
|
||||
if left.access ~= right.access then
|
||||
return left.access == "owner"
|
||||
end
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
|
||||
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50))
|
||||
local limited = {}
|
||||
for index = 1, math.min(#result, maximum) do
|
||||
limited[index] = result[index]
|
||||
end
|
||||
return limited
|
||||
end,
|
||||
prepare = function(source, action, data)
|
||||
local property, property_id, id_key, access, can_lock, error_code, coords =
|
||||
resolve_property(source, data)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
if action == "set_waypoint" then
|
||||
return { coords = coords }
|
||||
end
|
||||
if action == "toggle_lock" then
|
||||
if not can_lock then
|
||||
return nil, "property_access_denied"
|
||||
end
|
||||
return { providerId = id_key }
|
||||
end
|
||||
if action == "key_candidates" or action == "grant_key" or action == "revoke_key" then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
return nil, "invalid_action"
|
||||
end,
|
||||
})
|
||||
|
||||
local function execute_lock_action(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "housing_rtx_action", Config.Housing.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or data.action ~= "toggle_lock" then
|
||||
return { success = false, error = "invalid_action" }
|
||||
end
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return { success = false, error = "provider_unavailable" }
|
||||
end
|
||||
|
||||
local property_id, id_key = normalize_property_id(data.providerId)
|
||||
if not property_id then
|
||||
return { success = false, error = "invalid_property" }
|
||||
end
|
||||
local property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
|
||||
local access, can_lock
|
||||
access, can_lock, error_code = actor_access(source, property, property_id, id_key)
|
||||
if not access then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
if not can_lock then
|
||||
return { success = false, error = "property_access_denied" }
|
||||
end
|
||||
|
||||
local current
|
||||
current, error_code = lock_status(property_id)
|
||||
if current == nil then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
local requested = not current
|
||||
local setter_success = provider_call("SetPropertyLockStatus", function()
|
||||
return exports[resource_name]:SetPropertyLockStatus(property_id, requested)
|
||||
end)
|
||||
if not setter_success then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local verified
|
||||
verified, error_code = lock_status(property_id)
|
||||
if verified == nil then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
if verified ~= requested then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] %s lock update verification failed for property %s.",
|
||||
resource_name,
|
||||
id_key
|
||||
)
|
||||
return { success = false, error = "action_failed" }
|
||||
end
|
||||
return { success = true }
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:housing:rtx:execute", function(source, data)
|
||||
return execute_lock_action(source, data)
|
||||
end)
|
||||
@@ -0,0 +1,730 @@
|
||||
local provider_name = "rx"
|
||||
local resource_name = "RxHousing"
|
||||
|
||||
local function positive_integer(value)
|
||||
local number = Bridge.Normalize.FiniteNumber(value)
|
||||
if not number or number < 1 or number ~= math.floor(number) then
|
||||
return nil
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
local function table_like(value)
|
||||
local value_type = type(value)
|
||||
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
|
||||
end
|
||||
|
||||
local function field(value, key)
|
||||
if not table_like(value) then
|
||||
return nil
|
||||
end
|
||||
local success, result = pcall(function()
|
||||
return value[key]
|
||||
end)
|
||||
return success and result or nil
|
||||
end
|
||||
|
||||
local function decode_object(value)
|
||||
if table_like(value) then
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" or value == "" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and table_like(decoded) and decoded or nil
|
||||
end
|
||||
|
||||
local function non_empty_string(value)
|
||||
if type(value) ~= "string" and type(value) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
local text = tostring(value):match("^%s*(.-)%s*$")
|
||||
return text ~= "" and text or nil
|
||||
end
|
||||
|
||||
local function normalized_coords(value)
|
||||
value = decode_object(value)
|
||||
return value and Bridge.Normalize.Coordinates(value) or nil
|
||||
end
|
||||
|
||||
local function entrance_for(property)
|
||||
if not table_like(property) then
|
||||
return nil
|
||||
end
|
||||
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do
|
||||
local coords = normalized_coords(field(property, key))
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
|
||||
for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do
|
||||
local container = decode_object(field(property, key))
|
||||
if container then
|
||||
for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do
|
||||
local coords = normalized_coords(field(container, nested_key))
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
local coords = normalized_coords(container)
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function owner_identifier(property)
|
||||
local owner = field(property, "owner")
|
||||
local direct = non_empty_string(owner)
|
||||
if direct then
|
||||
return direct
|
||||
end
|
||||
if not table_like(owner) then
|
||||
return nil
|
||||
end
|
||||
for _, key in ipairs({ "identifier", "citizenid", "citizenId", "id" }) do
|
||||
local value = non_empty_string(field(owner, key))
|
||||
if value then
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function same_identifier(left, right)
|
||||
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
|
||||
end
|
||||
|
||||
local function property_name(property, property_id)
|
||||
for _, key in ipairs({ "label", "name", "address", "adress" }) do
|
||||
local value = non_empty_string(field(property, key))
|
||||
if value then
|
||||
return value
|
||||
end
|
||||
end
|
||||
return ("Property %s"):format(property_id)
|
||||
end
|
||||
|
||||
local function call_export(name, callback)
|
||||
local success, result = pcall(callback)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] RxHousing:%s failed: %s", name, tostring(result))
|
||||
return nil, false
|
||||
end
|
||||
return result, true
|
||||
end
|
||||
|
||||
local function get_all_properties()
|
||||
local properties, success = call_export("GetAllProperties", function()
|
||||
return exports[resource_name]:GetAllProperties()
|
||||
end)
|
||||
if not success or type(properties) ~= "table" then
|
||||
if success then
|
||||
Bridge.Debug("error", "[sky_phone] RxHousing:GetAllProperties returned invalid data.")
|
||||
end
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function property_id_for(key, property)
|
||||
if table_like(property) then
|
||||
for _, name in ipairs({ "id", "propertyId", "property_id" }) do
|
||||
local property_id = positive_integer(field(property, name))
|
||||
if property_id then
|
||||
return property_id
|
||||
end
|
||||
end
|
||||
end
|
||||
return positive_integer(key)
|
||||
end
|
||||
|
||||
local function property_entries(properties)
|
||||
local result = {}
|
||||
local seen = {}
|
||||
for key, value in pairs(properties) do
|
||||
local property = decode_object(value)
|
||||
local property_id = property and property_id_for(key, property) or nil
|
||||
if property_id and not seen[property_id] then
|
||||
seen[property_id] = true
|
||||
result[#result + 1] = { id = property_id, data = property }
|
||||
end
|
||||
end
|
||||
table.sort(result, function(left, right)
|
||||
return left.id < right.id
|
||||
end)
|
||||
return result
|
||||
end
|
||||
|
||||
local function get_owned_property_ids(identifier)
|
||||
local properties, success = call_export("GetOwnedProperties", function()
|
||||
return exports[resource_name]:GetOwnedProperties(identifier)
|
||||
end)
|
||||
if not success or type(properties) ~= "table" then
|
||||
if success then
|
||||
Bridge.Debug("error", "[sky_phone] RxHousing:GetOwnedProperties returned invalid data.")
|
||||
end
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local result = {}
|
||||
local is_array = #properties > 0
|
||||
local direct_id = property_id_for(nil, properties)
|
||||
if direct_id then
|
||||
result[direct_id] = true
|
||||
return result
|
||||
end
|
||||
|
||||
for key, value in pairs(properties) do
|
||||
local property = decode_object(value)
|
||||
local property_id = property and property_id_for(key, property)
|
||||
or positive_integer(value)
|
||||
if not property_id and (value == true or value == 1) then
|
||||
property_id = positive_integer(key)
|
||||
end
|
||||
if not property_id and not is_array then
|
||||
property_id = positive_integer(key)
|
||||
end
|
||||
if property_id then
|
||||
result[property_id] = true
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function get_property(property_id)
|
||||
local property, success = call_export("GetProperty", function()
|
||||
return exports[resource_name]:GetProperty(property_id)
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
property = decode_object(property)
|
||||
if not property then
|
||||
return nil, "property_not_found"
|
||||
end
|
||||
return property
|
||||
end
|
||||
|
||||
local function boolean_result(value)
|
||||
return value == true or value == 1 or value == "true"
|
||||
end
|
||||
|
||||
local function has_key(property_id, identifier)
|
||||
local result, success = call_export("HasKey", function()
|
||||
return exports[resource_name]:HasKey(property_id, identifier)
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return boolean_result(result)
|
||||
end
|
||||
|
||||
local function online_source(identifier)
|
||||
for _, player_source in ipairs(Bridge.Framework.GetPlayers() or {}) do
|
||||
if same_identifier(Bridge.Framework.GetIdentifier(player_source), identifier) then
|
||||
return player_source
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function player_name(player_source)
|
||||
local first_name = Bridge.Framework.GetFirstname(player_source)
|
||||
local last_name = Bridge.Framework.GetLastname(player_source)
|
||||
local name = table.concat({ tostring(first_name or ""), tostring(last_name or "") }, " ")
|
||||
:match("^%s*(.-)%s*$")
|
||||
if name ~= "" then
|
||||
return name
|
||||
end
|
||||
return GetPlayerName(player_source) or tostring(player_source)
|
||||
end
|
||||
|
||||
local function keyholder_identifier(key, value)
|
||||
if type(value) == "string" then
|
||||
return non_empty_string(value)
|
||||
end
|
||||
if table_like(value) then
|
||||
for _, name in ipairs({ "identifier", "citizenid", "citizenId", "playerIdentifier", "player_identifier" }) do
|
||||
local identifier = non_empty_string(field(value, name))
|
||||
if identifier then
|
||||
return identifier
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(key) == "string" and not tonumber(key) and (value == true or table_like(value)) then
|
||||
return non_empty_string(key)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function supplied_keyholder_name(value)
|
||||
if not table_like(value) then
|
||||
return nil
|
||||
end
|
||||
local direct = non_empty_string(field(value, "name") or field(value, "label"))
|
||||
if direct then
|
||||
return direct
|
||||
end
|
||||
local first_name = non_empty_string(field(value, "firstname") or field(value, "firstName")) or ""
|
||||
local last_name = non_empty_string(field(value, "lastname") or field(value, "lastName")) or ""
|
||||
local name = (first_name .. " " .. last_name):match("^%s*(.-)%s*$")
|
||||
return name ~= "" and name or nil
|
||||
end
|
||||
|
||||
local function get_keyholder_values(property_id)
|
||||
local values, success = call_export("GetPropertyKeyholders", function()
|
||||
return exports[resource_name]:GetPropertyKeyholders(property_id)
|
||||
end)
|
||||
if not success or type(values) ~= "table" then
|
||||
if success then
|
||||
Bridge.Debug("error", "[sky_phone] RxHousing:GetPropertyKeyholders returned invalid data.")
|
||||
end
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return values
|
||||
end
|
||||
|
||||
local function normalized_keyholders(property_id, excluded_identifier)
|
||||
local values, error_code = get_keyholder_values(property_id)
|
||||
if not values then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local result = {}
|
||||
local seen = {}
|
||||
for key, value in pairs(values) do
|
||||
local identifier = keyholder_identifier(key, value)
|
||||
if identifier and not same_identifier(identifier, excluded_identifier) and not seen[identifier] then
|
||||
seen[identifier] = true
|
||||
local player_source = online_source(identifier)
|
||||
result[#result + 1] = {
|
||||
identifier = identifier,
|
||||
name = player_source and player_name(player_source)
|
||||
or supplied_keyholder_name(value)
|
||||
or identifier,
|
||||
online = player_source ~= nil,
|
||||
revocable = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(result, function(left, right)
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return result
|
||||
end
|
||||
|
||||
local function access_for(property_id, identifier, owned_properties)
|
||||
if owned_properties[property_id] then
|
||||
return "owner"
|
||||
end
|
||||
local allowed, error_code = has_key(property_id, identifier)
|
||||
if allowed == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
return allowed and "keyholder" or nil
|
||||
end
|
||||
|
||||
local function normalized_property(property_id, property, access, actor_identifier)
|
||||
local entrance = entrance_for(property)
|
||||
if not entrance then
|
||||
return nil
|
||||
end
|
||||
|
||||
local keys = nil
|
||||
if access == "owner" then
|
||||
local error_code
|
||||
keys, error_code = normalized_keyholders(property_id, actor_identifier)
|
||||
if not keys then
|
||||
return nil, error_code
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
id = ("rx:%s"):format(property_id),
|
||||
providerId = tostring(property_id),
|
||||
name = property_name(property, property_id),
|
||||
access = access,
|
||||
locked = false,
|
||||
entrance = entrance,
|
||||
capabilities = {
|
||||
lock = false,
|
||||
keys = access == "owner",
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = keys,
|
||||
}
|
||||
end
|
||||
|
||||
local function get_overview(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, "housing_unavailable"
|
||||
end
|
||||
local owned_properties, owned_error = get_owned_property_ids(identifier)
|
||||
if not owned_properties then
|
||||
return nil, owned_error
|
||||
end
|
||||
local properties, error_code = get_all_properties()
|
||||
if not properties then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local result = {}
|
||||
for _, entry in ipairs(property_entries(properties)) do
|
||||
local access, access_error = access_for(entry.id, identifier, owned_properties)
|
||||
if access_error then
|
||||
return nil, access_error
|
||||
end
|
||||
if access then
|
||||
local property, normalize_error = normalized_property(entry.id, entry.data, access, identifier)
|
||||
if normalize_error then
|
||||
return nil, normalize_error
|
||||
end
|
||||
if property then
|
||||
result[#result + 1] = property
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(result, function(left, right)
|
||||
if left.access ~= right.access then
|
||||
return left.access == "owner"
|
||||
end
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
|
||||
while #result > maximum do
|
||||
result[#result] = nil
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function parse_property_id(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
return positive_integer(value:match("^rx:(%d+)$"))
|
||||
end
|
||||
|
||||
local function resolve_property(source, data)
|
||||
local property_id = parse_property_id(data and data.propertyId)
|
||||
if not property_id then
|
||||
return nil, nil, nil, "invalid_property"
|
||||
end
|
||||
local property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return nil, nil, nil, error_code
|
||||
end
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, nil, nil, "housing_unavailable"
|
||||
end
|
||||
local owned_properties, owned_error = get_owned_property_ids(identifier)
|
||||
if not owned_properties then
|
||||
return nil, nil, nil, owned_error
|
||||
end
|
||||
local access, access_error = access_for(property_id, identifier, owned_properties)
|
||||
if access_error then
|
||||
return nil, nil, nil, access_error
|
||||
end
|
||||
if not access then
|
||||
return nil, nil, nil, "property_access_denied"
|
||||
end
|
||||
if not entrance_for(property) then
|
||||
return nil, nil, nil, "invalid_coordinates"
|
||||
end
|
||||
return property, property_id, access
|
||||
end
|
||||
|
||||
local function player_source_for(key, value)
|
||||
local direct = positive_integer(value)
|
||||
if direct then
|
||||
return direct
|
||||
end
|
||||
if table_like(value) then
|
||||
for _, name in ipairs({ "source", "playerId", "player_id", "serverId", "server_id", "id" }) do
|
||||
local player_source = positive_integer(field(value, name))
|
||||
if player_source then
|
||||
return player_source
|
||||
end
|
||||
end
|
||||
end
|
||||
if value == true or table_like(value) then
|
||||
return positive_integer(key)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function players_in_property(property_id)
|
||||
local values, success = call_export("GetPlayersInProperty", function()
|
||||
return exports[resource_name]:GetPlayersInProperty(property_id)
|
||||
end)
|
||||
if not success or type(values) ~= "table" then
|
||||
if success then
|
||||
Bridge.Debug("error", "[sky_phone] RxHousing:GetPlayersInProperty returned invalid data.")
|
||||
end
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local result = {}
|
||||
local seen = {}
|
||||
for key, value in pairs(values) do
|
||||
local player_source = player_source_for(key, value)
|
||||
if player_source and not seen[player_source] and GetPlayerName(player_source) then
|
||||
seen[player_source] = true
|
||||
result[#result + 1] = player_source
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function key_candidates(source, property, property_id)
|
||||
local players, error_code = players_in_property(property_id)
|
||||
if not players then
|
||||
return nil, error_code
|
||||
end
|
||||
local owner = Bridge.Framework.GetIdentifier(source) or owner_identifier(property)
|
||||
local result = {}
|
||||
for _, target in ipairs(players) do
|
||||
local identifier = Bridge.Framework.GetIdentifier(target)
|
||||
if target ~= source and identifier and not same_identifier(identifier, owner) then
|
||||
local allowed, access_error = has_key(property_id, identifier)
|
||||
if allowed == nil then
|
||||
return nil, access_error
|
||||
end
|
||||
if not allowed then
|
||||
result[#result + 1] = {
|
||||
id = target,
|
||||
name = player_name(target),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(result, function(left, right)
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return result
|
||||
end
|
||||
|
||||
local function has_keyholder(property_id, identifier)
|
||||
local keyholders, error_code = normalized_keyholders(property_id)
|
||||
if not keyholders then
|
||||
return nil, error_code
|
||||
end
|
||||
for _, keyholder in ipairs(keyholders) do
|
||||
if same_identifier(keyholder.identifier, identifier) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function execute_key_action(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "housing_rx_action", Config.Housing.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if GetResourceState(resource_name) ~= "started" then
|
||||
return { success = false, error = "provider_unavailable" }
|
||||
end
|
||||
if type(data) ~= "table" or (data.action ~= "grant_key" and data.action ~= "revoke_key") then
|
||||
return { success = false, error = "invalid_action" }
|
||||
end
|
||||
|
||||
local property_id = positive_integer(data.providerId)
|
||||
if not property_id then
|
||||
return { success = false, error = "invalid_property" }
|
||||
end
|
||||
local property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
if not entrance_for(property) then
|
||||
return { success = false, error = "invalid_coordinates" }
|
||||
end
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
local owned_properties, owned_error = get_owned_property_ids(identifier)
|
||||
if not owned_properties then
|
||||
return { success = false, error = owned_error }
|
||||
end
|
||||
if not owned_properties[property_id] then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
|
||||
if data.action == "grant_key" then
|
||||
local target = positive_integer(data.target)
|
||||
local target_identifier = target and Bridge.Framework.GetIdentifier(target) or nil
|
||||
if not target or target == source or not target_identifier or not GetPlayerName(target) then
|
||||
return { success = false, error = "invalid_target" }
|
||||
end
|
||||
if same_identifier(target_identifier, identifier) then
|
||||
return { success = false, error = "invalid_target" }
|
||||
end
|
||||
|
||||
local players, players_error = players_in_property(property_id)
|
||||
if not players then
|
||||
return { success = false, error = players_error }
|
||||
end
|
||||
local inside = false
|
||||
for _, player_source in ipairs(players) do
|
||||
if player_source == target then
|
||||
inside = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not inside then
|
||||
return { success = false, error = "target_not_in_property" }
|
||||
end
|
||||
|
||||
local allowed, access_error = has_key(property_id, target_identifier)
|
||||
if allowed == nil then
|
||||
return { success = false, error = access_error }
|
||||
end
|
||||
if allowed then
|
||||
return { success = false, error = "key_already_exists" }
|
||||
end
|
||||
|
||||
local current_owned, current_error = get_owned_property_ids(identifier)
|
||||
if not current_owned then
|
||||
return { success = false, error = current_error }
|
||||
end
|
||||
if not current_owned[property_id] then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
|
||||
local _, success = call_export("AddKeyholder", function()
|
||||
return exports[resource_name]:AddKeyholder(property_id, target_identifier)
|
||||
end)
|
||||
if not success then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local allowed_after, verify_error = has_key(property_id, target_identifier)
|
||||
if allowed_after == nil then
|
||||
return { success = false, error = verify_error }
|
||||
end
|
||||
return allowed_after
|
||||
and { success = true }
|
||||
or { success = false, error = "action_failed" }
|
||||
end
|
||||
|
||||
local target_identifier = non_empty_string(data.identifier)
|
||||
if not target_identifier or same_identifier(target_identifier, identifier) then
|
||||
return { success = false, error = "invalid_target" }
|
||||
end
|
||||
local exists, key_error = has_keyholder(property_id, target_identifier)
|
||||
if exists == nil then
|
||||
return { success = false, error = key_error }
|
||||
end
|
||||
if not exists then
|
||||
return { success = false, error = "key_not_found" }
|
||||
end
|
||||
|
||||
local current_owned, current_error = get_owned_property_ids(identifier)
|
||||
if not current_owned then
|
||||
return { success = false, error = current_error }
|
||||
end
|
||||
if not current_owned[property_id] then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
|
||||
local _, success = call_export("RemoveKeyholder", function()
|
||||
return exports[resource_name]:RemoveKeyholder(property_id, target_identifier)
|
||||
end)
|
||||
if not success then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local exists_after, verify_error = has_keyholder(property_id, target_identifier)
|
||||
if exists_after == nil then
|
||||
return { success = false, error = verify_error }
|
||||
end
|
||||
return not exists_after
|
||||
and { success = true }
|
||||
or { success = false, error = "action_failed" }
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = get_overview,
|
||||
prepare = function(source, action, data)
|
||||
if action == "toggle_lock" then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
|
||||
local property, property_id, access, error_code = resolve_property(source, data)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
if action == "set_waypoint" then
|
||||
return { coords = entrance_for(property) }
|
||||
end
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
if action == "key_candidates" then
|
||||
local candidates, candidates_error = key_candidates(source, property, property_id)
|
||||
if not candidates then
|
||||
return nil, candidates_error
|
||||
end
|
||||
return { candidates = candidates }
|
||||
end
|
||||
if action == "grant_key" then
|
||||
local target = positive_integer(data and data.target)
|
||||
if not target then
|
||||
return nil, "invalid_target"
|
||||
end
|
||||
local candidates, candidates_error = key_candidates(source, property, property_id)
|
||||
if not candidates then
|
||||
return nil, candidates_error
|
||||
end
|
||||
for _, candidate in ipairs(candidates) do
|
||||
if candidate.id == target then
|
||||
return { providerId = tostring(property_id), target = target }
|
||||
end
|
||||
end
|
||||
return nil, "target_not_in_property"
|
||||
end
|
||||
if action == "revoke_key" then
|
||||
local target_identifier = non_empty_string(data and data.identifier)
|
||||
local actor_identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not target_identifier or same_identifier(target_identifier, actor_identifier) then
|
||||
return nil, "invalid_target"
|
||||
end
|
||||
local exists, key_error = has_keyholder(property_id, target_identifier)
|
||||
if exists == nil then
|
||||
return nil, key_error
|
||||
end
|
||||
if not exists then
|
||||
return nil, "key_not_found"
|
||||
end
|
||||
return { providerId = tostring(property_id), identifier = target_identifier }
|
||||
end
|
||||
return nil, "invalid_action"
|
||||
end,
|
||||
})
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:housing:rx:execute", function(source, data)
|
||||
return execute_key_action(source, data)
|
||||
end)
|
||||
@@ -0,0 +1,393 @@
|
||||
local provider_name = "sn"
|
||||
local resource_name = "sn_properties"
|
||||
|
||||
local function same_identifier(left, right)
|
||||
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
|
||||
end
|
||||
|
||||
local function normalized_property_id(value)
|
||||
if type(value) == "table" then
|
||||
value = value.id or value.propertyId or value.property_id
|
||||
end
|
||||
local property_id = tonumber(value)
|
||||
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
|
||||
return nil
|
||||
end
|
||||
return property_id
|
||||
end
|
||||
|
||||
local normalized_coords = Bridge.Normalize.Coordinates
|
||||
|
||||
local function get_all_properties()
|
||||
local success, properties = pcall(function()
|
||||
return exports[resource_name]:getAllProperties()
|
||||
end)
|
||||
if not success or type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] sn_properties:getAllProperties failed: %s", tostring(properties))
|
||||
return nil
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function collect_owned_property_ids(value, result)
|
||||
local direct_id = normalized_property_id(value)
|
||||
if direct_id then
|
||||
result[direct_id] = true
|
||||
return
|
||||
end
|
||||
if type(value) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local array_length = #value
|
||||
local is_dense_array = array_length > 0
|
||||
if is_dense_array then
|
||||
local entries = 0
|
||||
for key in pairs(value) do
|
||||
entries = entries + 1
|
||||
if type(key) ~= "number" or key < 1 or key ~= math.floor(key) or key > array_length then
|
||||
is_dense_array = false
|
||||
end
|
||||
end
|
||||
is_dense_array = is_dense_array and entries == array_length
|
||||
end
|
||||
|
||||
for key, entry in pairs(value) do
|
||||
local entry_id = normalized_property_id(entry)
|
||||
if entry_id then
|
||||
result[entry_id] = true
|
||||
else
|
||||
local key_id = normalized_property_id(key)
|
||||
local keyed_property = key_id and entry ~= nil and entry ~= false
|
||||
and (entry == true or type(key) == "string" or not is_dense_array)
|
||||
if keyed_property then
|
||||
result[key_id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function get_owned_property_ids(source)
|
||||
local success, properties = pcall(function()
|
||||
return exports[resource_name]:getPlayerProperties(source)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties failed: %s", tostring(properties))
|
||||
return nil
|
||||
end
|
||||
|
||||
local result = {}
|
||||
if properties == nil or properties == false then
|
||||
return result
|
||||
end
|
||||
if type(properties) ~= "table" and type(properties) ~= "number" and type(properties) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties returned an invalid value")
|
||||
return nil
|
||||
end
|
||||
collect_owned_property_ids(properties, result)
|
||||
return result
|
||||
end
|
||||
|
||||
local function player_source(identifier)
|
||||
for _, source in ipairs(Bridge.Framework.GetPlayers()) do
|
||||
if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then
|
||||
return source
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function player_name(source, fallback)
|
||||
if source then
|
||||
local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source)
|
||||
if type(name) == "string" then
|
||||
name = name:match("^%s*(.-)%s*$")
|
||||
if name ~= "" then
|
||||
return name
|
||||
end
|
||||
end
|
||||
end
|
||||
return tostring(fallback)
|
||||
end
|
||||
|
||||
local function property_keys(property)
|
||||
return type(property.keys) == "table" and property.keys or {}
|
||||
end
|
||||
|
||||
local function property_access(property, identifier, owned_property_ids)
|
||||
local property_id = normalized_property_id(property)
|
||||
if property_id and owned_property_ids[property_id] then
|
||||
return "owner"
|
||||
end
|
||||
if property_keys(property)[tostring(identifier)] ~= nil then
|
||||
return "keyholder"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function normalized_keys(property, actor_identifier)
|
||||
local keys = {}
|
||||
for identifier in pairs(property_keys(property)) do
|
||||
if (type(identifier) == "string" or type(identifier) == "number")
|
||||
and not same_identifier(identifier, actor_identifier)
|
||||
then
|
||||
local normalized_identifier = tostring(identifier)
|
||||
local source = player_source(normalized_identifier)
|
||||
keys[#keys + 1] = {
|
||||
identifier = normalized_identifier,
|
||||
name = player_name(source, normalized_identifier),
|
||||
online = source ~= nil,
|
||||
revocable = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(keys, function(left, right)
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return keys
|
||||
end
|
||||
|
||||
local function normalized_property(property, access, actor_identifier)
|
||||
local property_id = normalized_property_id(property)
|
||||
local entrance = normalized_coords(property.coords)
|
||||
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then
|
||||
return nil
|
||||
end
|
||||
|
||||
local owner = access == "owner"
|
||||
return {
|
||||
id = ("sn:%s"):format(property_id),
|
||||
providerId = tostring(property_id),
|
||||
name = tostring(property.label or ("Property %s"):format(property_id)),
|
||||
access = access,
|
||||
locked = false,
|
||||
entrance = entrance,
|
||||
capabilities = {
|
||||
lock = false,
|
||||
keyGrant = false,
|
||||
keys = owner,
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = owner and normalized_keys(property, actor_identifier) or nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function find_property(properties, property_id)
|
||||
if normalized_property_id(properties) == property_id then
|
||||
return properties
|
||||
end
|
||||
for _, property in pairs(properties) do
|
||||
if type(property) == "table" and tonumber(property.id) == property_id then
|
||||
return property
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function parse_property_id(data)
|
||||
if type(data) ~= "table" then
|
||||
return nil, "invalid_request"
|
||||
end
|
||||
local property_id = type(data.propertyId) == "string"
|
||||
and tonumber(data.propertyId:match("^sn:(%d+)$")) or nil
|
||||
if not property_id then
|
||||
property_id = tonumber(data.providerId)
|
||||
end
|
||||
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
|
||||
return nil, "invalid_property"
|
||||
end
|
||||
return property_id
|
||||
end
|
||||
|
||||
local function resolve_property(source, data)
|
||||
local property_id, error_code = parse_property_id(data)
|
||||
if not property_id then
|
||||
return nil, nil, nil, error_code
|
||||
end
|
||||
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, nil, nil, "housing_unavailable"
|
||||
end
|
||||
identifier = tostring(identifier)
|
||||
|
||||
local owned_property_ids = get_owned_property_ids(source)
|
||||
if not owned_property_ids then
|
||||
return nil, nil, nil, "provider_error"
|
||||
end
|
||||
|
||||
local properties = get_all_properties()
|
||||
if not properties then
|
||||
return nil, nil, nil, "provider_error"
|
||||
end
|
||||
local property = find_property(properties, property_id)
|
||||
if not property or not normalized_coords(property.coords) then
|
||||
return nil, nil, nil, "property_not_found"
|
||||
end
|
||||
|
||||
local access = property_access(property, identifier, owned_property_ids)
|
||||
if not access then
|
||||
return nil, nil, nil, "property_access_denied"
|
||||
end
|
||||
return property, property_id, access, nil, identifier, owned_property_ids
|
||||
end
|
||||
|
||||
local function execute_action(source, action, data)
|
||||
if not SkyPhone.AllowOperation(source, "housing_sn_action", Config.Housing.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
if action == "grant_key" then
|
||||
return { success = false, error = "capability_unavailable" }
|
||||
end
|
||||
if action ~= "revoke_key" then
|
||||
return { success = false, error = "invalid_action" }
|
||||
end
|
||||
|
||||
local property, property_id, access, error_code, actor_identifier, owned_property_ids = resolve_property(source, data)
|
||||
if not property then
|
||||
return { success = false, error = error_code }
|
||||
end
|
||||
if access ~= "owner" or not owned_property_ids[property_id] then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
|
||||
local identifier = data.identifier
|
||||
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
|
||||
return { success = false, error = "invalid_target" }
|
||||
end
|
||||
if property_keys(property)[identifier] == nil then
|
||||
return { success = false, error = "key_not_found" }
|
||||
end
|
||||
|
||||
local current_owned_property_ids = get_owned_property_ids(source)
|
||||
if not current_owned_property_ids then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
if not current_owned_property_ids[property_id] then
|
||||
return { success = false, error = "owner_required" }
|
||||
end
|
||||
|
||||
local success, result = pcall(function()
|
||||
return exports[resource_name]:removeKeyholder(identifier, property_id)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[sky_phone] sn_properties:removeKeyholder failed: %s", tostring(result))
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
|
||||
local updated_properties = get_all_properties()
|
||||
if not updated_properties then
|
||||
return { success = false, error = "provider_error" }
|
||||
end
|
||||
local updated_property = find_property(updated_properties, property_id)
|
||||
if not updated_property then
|
||||
return { success = false, error = "property_not_found" }
|
||||
end
|
||||
if property_keys(updated_property)[identifier] ~= nil then
|
||||
return { success = false, error = result == false and "provider_rejected" or "action_failed" }
|
||||
end
|
||||
return { success = true }
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = function(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, "housing_unavailable"
|
||||
end
|
||||
identifier = tostring(identifier)
|
||||
|
||||
local owned_property_ids = get_owned_property_ids(source)
|
||||
if not owned_property_ids then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local properties = get_all_properties()
|
||||
if not properties then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
|
||||
local result = {}
|
||||
for _, property in pairs(properties) do
|
||||
if type(property) == "table" then
|
||||
local access = property_access(property, identifier, owned_property_ids)
|
||||
local normalized = access and normalized_property(property, access, identifier) or nil
|
||||
if normalized then
|
||||
result[#result + 1] = normalized
|
||||
if #result >= Config.Housing.MaximumProperties then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(result, function(left, right)
|
||||
if left.access ~= right.access then
|
||||
return left.access == "owner"
|
||||
end
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
return result
|
||||
end,
|
||||
prepare = function(source, action, data)
|
||||
local property, property_id, access, error_code, actor_identifier = resolve_property(source, data)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
if action == "set_waypoint" then
|
||||
return { coords = normalized_coords(property.coords) }
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
if action == "toggle_lock" then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "key_candidates" then
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
return { candidates = {} }
|
||||
end
|
||||
if action == "grant_key" then
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "revoke_key" then
|
||||
if access ~= "owner" then
|
||||
return nil, "owner_required"
|
||||
end
|
||||
local identifier = data.identifier
|
||||
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
|
||||
return nil, "invalid_target"
|
||||
end
|
||||
if property_keys(property)[identifier] == nil then
|
||||
return nil, "key_not_found"
|
||||
end
|
||||
return { providerId = tostring(property_id), identifier = identifier }
|
||||
end
|
||||
return nil, "invalid_action"
|
||||
end,
|
||||
})
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:housing:sn:execute", function(source, data)
|
||||
if type(data) ~= "table" or type(data.action) ~= "string" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
return execute_action(source, data.action, data)
|
||||
end)
|
||||
@@ -0,0 +1,428 @@
|
||||
local provider_name = "vms"
|
||||
local resource_name = "vms_housing"
|
||||
|
||||
local function provider_call(export_name, callback)
|
||||
local success, result = pcall(callback)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] %s:%s failed: %s",
|
||||
resource_name,
|
||||
export_name,
|
||||
tostring(result)
|
||||
)
|
||||
return false, nil
|
||||
end
|
||||
return true, result
|
||||
end
|
||||
|
||||
local function normalize_property_id(value)
|
||||
if type(value) == "number" then
|
||||
local number = Bridge.Normalize.FiniteNumber(value)
|
||||
if not number or number < 1 or number ~= math.floor(number) then
|
||||
return nil, nil
|
||||
end
|
||||
local integer = math.tointeger(number)
|
||||
return integer, integer and tostring(integer) or nil
|
||||
end
|
||||
if type(value) ~= "string" then
|
||||
return nil, nil
|
||||
end
|
||||
local normalized = value:match("^%s*(.-)%s*$")
|
||||
if normalized == "" or #normalized > 128 or normalized:find("%c") then
|
||||
return nil, nil
|
||||
end
|
||||
return normalized, normalized
|
||||
end
|
||||
|
||||
local function decode_table(value)
|
||||
local value_type = type(value)
|
||||
if value_type == "table" or value_type == "vector3" or value_type == "vector4" then
|
||||
return value
|
||||
end
|
||||
if value_type ~= "string" or value == "" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and type(decoded) == "table" and decoded or nil
|
||||
end
|
||||
|
||||
local function normalized_coords(value)
|
||||
local coords = Bridge.Normalize.Coordinates(value)
|
||||
if not coords then
|
||||
return nil
|
||||
end
|
||||
if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then
|
||||
return nil
|
||||
end
|
||||
return coords
|
||||
end
|
||||
|
||||
local function property_identifier(property, fallback)
|
||||
local value = type(property) == "table"
|
||||
and (property.id or property.propertyId or property.property_id)
|
||||
or nil
|
||||
return normalize_property_id(value or fallback)
|
||||
end
|
||||
|
||||
local function get_all_properties()
|
||||
local success, properties = provider_call("GetAllProperties", function()
|
||||
return exports[resource_name]:GetAllProperties()
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function get_player_properties(source)
|
||||
local success, properties = provider_call("GetPlayerProperties", function()
|
||||
return exports[resource_name]:GetPlayerProperties(source)
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(properties) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:GetPlayerProperties returned invalid data.", resource_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return properties
|
||||
end
|
||||
|
||||
local function get_property(property_id)
|
||||
local success, property = provider_call("GetProperty", function()
|
||||
return exports[resource_name]:GetProperty(property_id)
|
||||
end)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if property == nil then
|
||||
return nil, "property_not_found"
|
||||
end
|
||||
if type(property) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:GetProperty returned invalid data.", resource_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return property
|
||||
end
|
||||
|
||||
local function boolean_export(export_name, callback)
|
||||
local success, value = provider_call(export_name, callback)
|
||||
if not success then
|
||||
return nil, "provider_error"
|
||||
end
|
||||
if type(value) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name)
|
||||
return nil, "provider_error"
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function collect_catalog(catalog, properties)
|
||||
local direct_id, direct_key = property_identifier(properties)
|
||||
if direct_id then
|
||||
catalog[direct_key] = { id = direct_id, property = properties }
|
||||
return
|
||||
end
|
||||
|
||||
local is_array = #properties > 0
|
||||
for key, property in pairs(properties) do
|
||||
if type(property) == "table" then
|
||||
local property_id, id_key = property_identifier(property, is_array and nil or key)
|
||||
if property_id then
|
||||
catalog[id_key] = {
|
||||
id = property_id,
|
||||
property = property,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function collect_player_properties(properties, catalog)
|
||||
local direct = {}
|
||||
local direct_id, direct_key = property_identifier(properties)
|
||||
if direct_id then
|
||||
direct[direct_key] = direct_id
|
||||
if catalog then
|
||||
catalog[direct_key] = { id = direct_id, property = properties }
|
||||
end
|
||||
return direct
|
||||
end
|
||||
|
||||
local is_array = #properties > 0
|
||||
for key, value in pairs(properties) do
|
||||
local property = type(value) == "table" and value or nil
|
||||
local fallback = property and (is_array and nil or key)
|
||||
or (is_array and value or key)
|
||||
local property_id, id_key = property_identifier(property, fallback)
|
||||
if property_id then
|
||||
direct[id_key] = property_id
|
||||
if catalog and property then
|
||||
catalog[id_key] = {
|
||||
id = property_id,
|
||||
property = property,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return direct
|
||||
end
|
||||
|
||||
local function identifiers_match(value, identifier)
|
||||
return identifier ~= nil
|
||||
and value ~= nil
|
||||
and tostring(value) ~= ""
|
||||
and tostring(value) == tostring(identifier)
|
||||
end
|
||||
|
||||
local function property_access(source, property, property_id, id_key, direct, identifier)
|
||||
if identifiers_match(property.owner, identifier) then
|
||||
return "owner"
|
||||
end
|
||||
if identifiers_match(property.renter, identifier) or direct[id_key] then
|
||||
return "keyholder"
|
||||
end
|
||||
if not identifier then
|
||||
return nil
|
||||
end
|
||||
|
||||
local has_keys, error_code = boolean_export("HasKeys", function()
|
||||
return exports[resource_name]:HasKeys(source, identifier, property_id)
|
||||
end)
|
||||
if has_keys == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
if has_keys then
|
||||
return "keyholder"
|
||||
end
|
||||
|
||||
local has_permissions
|
||||
has_permissions, error_code = boolean_export("HasAnyPermission", function()
|
||||
return exports[resource_name]:HasAnyPermission(property_id, identifier)
|
||||
end)
|
||||
if has_permissions == nil then
|
||||
return nil, error_code
|
||||
end
|
||||
return has_permissions and "keyholder" or nil
|
||||
end
|
||||
|
||||
local function property_metadata(property)
|
||||
return decode_table(property.metadata) or {}
|
||||
end
|
||||
|
||||
local function property_entrance(property, building_cache)
|
||||
local object_id = property.object_id
|
||||
if object_id ~= nil and tostring(object_id) ~= "" then
|
||||
local normalized_object_id, object_key = normalize_property_id(object_id)
|
||||
if normalized_object_id then
|
||||
local building = nil
|
||||
local cache_hit = building_cache and building_cache[object_key] ~= nil
|
||||
if cache_hit then
|
||||
building = building_cache[object_key]
|
||||
else
|
||||
local object, error_code = get_property(normalized_object_id)
|
||||
if not object and error_code ~= "property_not_found" then
|
||||
return nil, error_code
|
||||
end
|
||||
building = object or false
|
||||
if building_cache then
|
||||
building_cache[object_key] = building
|
||||
end
|
||||
end
|
||||
if type(building) == "table" and building.type == "building" then
|
||||
local coords = normalized_coords(property_metadata(building).enter)
|
||||
if coords then
|
||||
return coords
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local metadata = property_metadata(property)
|
||||
return normalized_coords(metadata.enter) or normalized_coords(metadata.menu)
|
||||
end
|
||||
|
||||
local function property_name(property, id_key)
|
||||
if type(property.name) == "string" and property.name ~= "" then
|
||||
return property.name
|
||||
end
|
||||
if type(property.address) == "string" and property.address ~= "" then
|
||||
return property.address
|
||||
end
|
||||
return ("Property %s"):format(id_key)
|
||||
end
|
||||
|
||||
local function normalized_property(property, id_key, access, coords)
|
||||
local metadata = property_metadata(property)
|
||||
return {
|
||||
id = ("%s:%s"):format(provider_name, id_key),
|
||||
providerId = id_key,
|
||||
name = property_name(property, id_key),
|
||||
access = access,
|
||||
locked = metadata.locked == true,
|
||||
entrance = coords,
|
||||
capabilities = {
|
||||
lock = false,
|
||||
keys = false,
|
||||
waypoint = true,
|
||||
cctv = false,
|
||||
garageStatus = false,
|
||||
},
|
||||
cctv = { enabled = false },
|
||||
garage = nil,
|
||||
keys = nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function resolve_property(source, data)
|
||||
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
|
||||
return nil, nil, nil, nil, "invalid_request"
|
||||
end
|
||||
local raw_id = data.propertyId:match("^vms:(.+)$")
|
||||
local property_id, id_key = normalize_property_id(raw_id)
|
||||
if not property_id then
|
||||
return nil, nil, nil, nil, "invalid_property"
|
||||
end
|
||||
|
||||
local property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return nil, nil, nil, nil, error_code
|
||||
end
|
||||
local player_properties
|
||||
player_properties, error_code = get_player_properties(source)
|
||||
if not player_properties then
|
||||
return nil, nil, nil, nil, error_code
|
||||
end
|
||||
local direct = collect_player_properties(player_properties)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local access
|
||||
access, error_code = property_access(
|
||||
source,
|
||||
property,
|
||||
property_id,
|
||||
id_key,
|
||||
direct,
|
||||
identifier
|
||||
)
|
||||
if error_code then
|
||||
return nil, nil, nil, nil, error_code
|
||||
end
|
||||
if not access then
|
||||
return nil, nil, nil, nil, "property_access_denied"
|
||||
end
|
||||
local coords
|
||||
coords, error_code = property_entrance(property)
|
||||
if not coords then
|
||||
return nil, nil, nil, nil, error_code or "invalid_coordinates"
|
||||
end
|
||||
return property, property_id, id_key, coords
|
||||
end
|
||||
|
||||
Bridge.Housing.RegisterProvider(provider_name, {
|
||||
resource_name = resource_name,
|
||||
is_available = function()
|
||||
return GetResourceState(resource_name) == "started"
|
||||
end,
|
||||
get_overview = function(source)
|
||||
local properties, error_code = get_all_properties()
|
||||
if not properties then
|
||||
return nil, error_code
|
||||
end
|
||||
local player_properties
|
||||
player_properties, error_code = get_player_properties(source)
|
||||
if not player_properties then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local catalog = {}
|
||||
collect_catalog(catalog, properties)
|
||||
local direct = collect_player_properties(player_properties, catalog)
|
||||
for id_key, property_id in pairs(direct) do
|
||||
if not catalog[id_key] then
|
||||
local property
|
||||
property, error_code = get_property(property_id)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
catalog[id_key] = { id = property_id, property = property }
|
||||
end
|
||||
end
|
||||
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local building_cache = {}
|
||||
local result = {}
|
||||
for id_key, entry in pairs(catalog) do
|
||||
local access
|
||||
access, error_code = property_access(
|
||||
source,
|
||||
entry.property,
|
||||
entry.id,
|
||||
id_key,
|
||||
direct,
|
||||
identifier
|
||||
)
|
||||
if error_code then
|
||||
return nil, error_code
|
||||
end
|
||||
if access then
|
||||
local coords
|
||||
coords, error_code = property_entrance(entry.property, building_cache)
|
||||
if error_code then
|
||||
return nil, error_code
|
||||
end
|
||||
if coords then
|
||||
result[#result + 1] = normalized_property(
|
||||
entry.property,
|
||||
id_key,
|
||||
access,
|
||||
coords
|
||||
)
|
||||
else
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Ignored VMS property '%s' because no valid entrance was found.",
|
||||
id_key
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(result, function(left, right)
|
||||
if left.access ~= right.access then
|
||||
return left.access == "owner"
|
||||
end
|
||||
return string.lower(left.name) < string.lower(right.name)
|
||||
end)
|
||||
|
||||
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50))
|
||||
local limited = {}
|
||||
for index = 1, math.min(#result, maximum) do
|
||||
limited[index] = result[index]
|
||||
end
|
||||
return limited
|
||||
end,
|
||||
prepare = function(source, action, data)
|
||||
local property, property_id, id_key, coords, error_code =
|
||||
resolve_property(source, data)
|
||||
if not property then
|
||||
return nil, error_code
|
||||
end
|
||||
if action == "set_waypoint" then
|
||||
return { coords = coords }
|
||||
end
|
||||
if action == "key_candidates" or action == "grant_key" or action == "revoke_key"
|
||||
or action == "toggle_lock"
|
||||
then
|
||||
return nil, "capability_unavailable"
|
||||
end
|
||||
if action == "open_cctv" then
|
||||
return nil, "cctv_unavailable"
|
||||
end
|
||||
return nil, "invalid_action"
|
||||
end,
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
local call_provider_resources = {
|
||||
yaca = "yaca-voice",
|
||||
pma = "pma-voice",
|
||||
saltychat = "saltychat",
|
||||
}
|
||||
local call_provider_aliases = {
|
||||
["yaca-voice"] = "yaca",
|
||||
["pma-voice"] = "pma",
|
||||
salty = "saltychat",
|
||||
}
|
||||
@@ -17,6 +19,26 @@ local radio_provider_aliases = {
|
||||
salty = "saltychat",
|
||||
}
|
||||
|
||||
local function yaca_is_enabled()
|
||||
if GetResourceState("yaca-voice") ~= "started" then
|
||||
return false
|
||||
end
|
||||
|
||||
local success, enabled = pcall(function()
|
||||
return exports["yaca-voice"]:isEnabled()
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca could not report its availability: %s",
|
||||
tostring(enabled),
|
||||
{ always = true }
|
||||
)
|
||||
return false
|
||||
end
|
||||
return enabled == true
|
||||
end
|
||||
|
||||
local function resolve_call_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
local selected = call_provider_aliases[configured] or configured
|
||||
@@ -51,11 +73,17 @@ function Bridge.Calls.GetProvider()
|
||||
end
|
||||
|
||||
function Bridge.Calls.IsAvailable()
|
||||
return resolve_call_provider() ~= nil
|
||||
local selected = resolve_call_provider()
|
||||
return selected ~= nil and (selected ~= "yaca" or yaca_is_enabled())
|
||||
end
|
||||
|
||||
function Bridge.Calls.SupportsSpeaker()
|
||||
return Bridge.Speaker.IsEnabled() and resolve_call_provider() == "saltychat"
|
||||
local selected = resolve_call_provider()
|
||||
return Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")
|
||||
end
|
||||
|
||||
function Bridge.Calls.SupportsMute()
|
||||
return resolve_call_provider() == "yaca"
|
||||
end
|
||||
|
||||
function Bridge.Calls.Start(identifier, player_handles)
|
||||
@@ -63,6 +91,37 @@ function Bridge.Calls.Start(identifier, player_handles)
|
||||
if selected == "pma" then
|
||||
return true, selected
|
||||
end
|
||||
if selected == "yaca" then
|
||||
if not yaca_is_enabled() then
|
||||
return false, selected
|
||||
end
|
||||
local caller_source = tonumber(player_handles[1])
|
||||
local target_source = tonumber(player_handles[2])
|
||||
if not caller_source or not target_source then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca refused call %s because a player source was invalid.",
|
||||
tostring(identifier),
|
||||
{ always = true }
|
||||
)
|
||||
return false, selected
|
||||
end
|
||||
|
||||
local success, error_message = pcall(function()
|
||||
exports["yaca-voice"]:callPlayer(caller_source, target_source, true)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca could not start call %s: %s",
|
||||
tostring(identifier),
|
||||
tostring(error_message),
|
||||
{ always = true }
|
||||
)
|
||||
return false, selected
|
||||
end
|
||||
return true, selected
|
||||
end
|
||||
if selected ~= "saltychat" then
|
||||
return false, nil
|
||||
end
|
||||
@@ -85,6 +144,36 @@ end
|
||||
|
||||
function Bridge.Calls.Stop(identifier, player_handles, provider)
|
||||
local selected = provider or resolve_call_provider()
|
||||
if selected == "yaca" then
|
||||
if GetResourceState("yaca-voice") ~= "started" then
|
||||
return
|
||||
end
|
||||
local caller_source = tonumber(player_handles[1])
|
||||
local target_source = tonumber(player_handles[2])
|
||||
if not caller_source or not target_source then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca could not stop call %s because a player source was invalid.",
|
||||
tostring(identifier),
|
||||
{ always = true }
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local success, error_message = pcall(function()
|
||||
exports["yaca-voice"]:callPlayer(caller_source, target_source, false)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca could not stop call %s: %s",
|
||||
tostring(identifier),
|
||||
tostring(error_message),
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
if selected ~= "saltychat" or GetResourceState("saltychat") ~= "started" then
|
||||
return
|
||||
end
|
||||
@@ -108,17 +197,47 @@ function Bridge.Calls.SetSpeaker(player_source, enabled, provider)
|
||||
return false
|
||||
end
|
||||
local selected = provider or resolve_call_provider()
|
||||
if selected ~= "saltychat" or GetResourceState("saltychat") ~= "started" then
|
||||
local resource_name = call_provider_resources[selected]
|
||||
if (selected ~= "yaca" and selected ~= "saltychat")
|
||||
or GetResourceState(resource_name) ~= "started"
|
||||
then
|
||||
return false
|
||||
end
|
||||
|
||||
local success, error_message = pcall(function()
|
||||
exports.saltychat:SetPhoneSpeaker(tonumber(player_source), enabled == true)
|
||||
if selected == "yaca" then
|
||||
exports["yaca-voice"]:enablePhoneSpeaker(tonumber(player_source), enabled == true)
|
||||
else
|
||||
exports.saltychat:SetPhoneSpeaker(tonumber(player_source), enabled == true)
|
||||
end
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] SaltyChat could not update the phone speaker for source %s: %s",
|
||||
"[sky_phone] %s could not update the phone speaker for source %s: %s",
|
||||
selected == "yaca" and "Yaca" or "SaltyChat",
|
||||
tostring(player_source),
|
||||
tostring(error_message),
|
||||
{ always = true }
|
||||
)
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Calls.SetMuted(player_source, enabled, provider)
|
||||
local selected = provider or resolve_call_provider()
|
||||
if selected ~= "yaca" or GetResourceState("yaca-voice") ~= "started" then
|
||||
return false
|
||||
end
|
||||
|
||||
local success, error_message = pcall(function()
|
||||
exports["yaca-voice"]:muteOnPhone(tonumber(player_source), enabled == true)
|
||||
end)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca could not update the phone mute state for source %s: %s",
|
||||
tostring(player_source),
|
||||
tostring(error_message),
|
||||
{ always = true }
|
||||
|
||||
@@ -5,12 +5,50 @@ Bridge.Database = Bridge.Database or {}
|
||||
Bridge.Framework = Bridge.Framework or {}
|
||||
Bridge.Inventory = Bridge.Inventory or {}
|
||||
Bridge.Radio = Bridge.Radio or {}
|
||||
Bridge.Normalize = Bridge.Normalize or {}
|
||||
Bridge.Speaker = Bridge.Speaker or {}
|
||||
|
||||
function Bridge.Normalize.FiniteNumber(value)
|
||||
local number = tonumber(value)
|
||||
if not number or number ~= number or number == math.huge or number == -math.huge then
|
||||
return nil
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
function Bridge.Normalize.Coordinates(value)
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
local success, x, y, z = pcall(function()
|
||||
return value.x or value[1], value.y or value[2], value.z or value[3]
|
||||
end)
|
||||
if not success then
|
||||
return nil
|
||||
end
|
||||
|
||||
x = Bridge.Normalize.FiniteNumber(x)
|
||||
y = Bridge.Normalize.FiniteNumber(y)
|
||||
z = Bridge.Normalize.FiniteNumber(z)
|
||||
if not x or not y or not z then
|
||||
return nil
|
||||
end
|
||||
return { x = x, y = y, z = z }
|
||||
end
|
||||
|
||||
function Bridge.Speaker.IsEnabled()
|
||||
return not Config.Speaker or Config.Speaker.Enabled ~= false
|
||||
end
|
||||
|
||||
function Bridge.Calls.SupportsMute()
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Calls.SetMuted()
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Radio.SupportsSpeaker()
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
SkyPhoneFocus = {}
|
||||
|
||||
local blocked_phone_controls = { 19, 24, 140, 141, 142, 257, 263, 264 }
|
||||
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||
local focused_control_groups = { 0, 1, 2 }
|
||||
|
||||
@@ -39,10 +39,10 @@ function SkyPhoneFocus.Resolve(state)
|
||||
or state.payphone_focus
|
||||
or state.sim_picker_open
|
||||
or (state.camera_active and state.camera_nui_focused)
|
||||
local cursor = focused and not (game_input and state.cursor_disabled)
|
||||
local cursor = focused
|
||||
return {
|
||||
block_game = cursor,
|
||||
block_look = game_input and not state.cursor_disabled,
|
||||
block_game = cursor and (not game_input or state.text_input_focused),
|
||||
block_look = game_input,
|
||||
cursor = cursor,
|
||||
focused = focused,
|
||||
game_input = game_input,
|
||||
|
||||
@@ -169,6 +169,17 @@ RegisterNUICallback("housing:overview", function(data, cb)
|
||||
return
|
||||
end
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
|
||||
if type(result) == "table" and result.success and type(result.data) == "table" then
|
||||
local properties, error_code = Bridge.Housing.EnrichOverview(
|
||||
result.data.provider,
|
||||
result.data.properties
|
||||
)
|
||||
if not properties then
|
||||
cb({ success = false, error = error_code or "provider_error" })
|
||||
return
|
||||
end
|
||||
result.data.properties = properties
|
||||
end
|
||||
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
|
||||
end)
|
||||
|
||||
@@ -211,11 +222,24 @@ RegisterNUICallback("housing:command", function(data, cb)
|
||||
end
|
||||
if data.action == "set_waypoint" then
|
||||
local coords = prepared.data.coords
|
||||
if type(coords) ~= "table" or not tonumber(coords.x) or not tonumber(coords.y) then
|
||||
if coords == nil then
|
||||
local success, error_code = Bridge.Housing.Execute(
|
||||
prepared.data.provider,
|
||||
data.action,
|
||||
prepared.data
|
||||
)
|
||||
cb(success and { success = true }
|
||||
or { success = false, error = error_code or "invalid_coordinates" })
|
||||
return
|
||||
end
|
||||
|
||||
local x = type(coords) == "table" and camera_number(coords.x) or nil
|
||||
local y = type(coords) == "table" and camera_number(coords.y) or nil
|
||||
if not x or not y then
|
||||
cb({ success = false, error = "invalid_coordinates" })
|
||||
return
|
||||
end
|
||||
SetNewWaypoint(tonumber(coords.x) + 0.0, tonumber(coords.y) + 0.0)
|
||||
SetNewWaypoint(x + 0.0, y + 0.0)
|
||||
cb({ success = true })
|
||||
return
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ local activity_suspended = false
|
||||
local phone_block_game = false
|
||||
local phone_block_look = false
|
||||
local phone_game_input = false
|
||||
local phone_cursor_disabled = false
|
||||
local phone_text_input_focused = false
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function()
|
||||
return is_open
|
||||
@@ -160,6 +160,37 @@ local server_callbacks = {
|
||||
"picstagram:report",
|
||||
"picstagram:admin-reports",
|
||||
"picstagram:admin-resolve-report",
|
||||
"skypic:bootstrap",
|
||||
"skypic:create-profile",
|
||||
"skypic:delete-account",
|
||||
"skypic:update-profile",
|
||||
"skypic:search",
|
||||
"skypic:add-friend",
|
||||
"skypic:respond-friend",
|
||||
"skypic:remove-friend",
|
||||
"skypic:block",
|
||||
"skypic:send-snap",
|
||||
"skypic:open-snap",
|
||||
"skypic:replay-snap",
|
||||
"skypic:publish-story",
|
||||
"skypic:stories",
|
||||
"skypic:view-story",
|
||||
"skypic:story-viewers",
|
||||
"skypic:remove-story",
|
||||
"skypic:spotlight-feed",
|
||||
"skypic:publish-spotlight",
|
||||
"skypic:view-spotlight",
|
||||
"skypic:like-spotlight",
|
||||
"skypic:spotlight-comments",
|
||||
"skypic:comment-spotlight",
|
||||
"skypic:delete-spotlight-comment",
|
||||
"skypic:remove-spotlight",
|
||||
"skypic:report-spotlight",
|
||||
"skypic:thread",
|
||||
"skypic:send-message",
|
||||
"skypic:mark-thread",
|
||||
"skypic:save-message",
|
||||
"skypic:delete-message",
|
||||
"feather:bootstrap",
|
||||
"feather:create-profile",
|
||||
"feather:update-profile",
|
||||
@@ -245,6 +276,7 @@ local server_callbacks = {
|
||||
"calls:dial",
|
||||
"calls:answer",
|
||||
"calls:set-speaker",
|
||||
"calls:set-muted",
|
||||
"calls:decline",
|
||||
"calls:hangup",
|
||||
"calls:block",
|
||||
@@ -326,20 +358,17 @@ local function update_nui_focus()
|
||||
call_focus = call_focus,
|
||||
camera_active = camera_active,
|
||||
camera_nui_focused = camera_nui_focused,
|
||||
cursor_disabled = phone_cursor_disabled,
|
||||
is_open = is_open,
|
||||
notification_focus = notification_focus,
|
||||
payphone_focus = payphone_focus,
|
||||
sim_picker_open = sim_picker_open,
|
||||
text_input_focused = phone_text_input_focused,
|
||||
})
|
||||
SetNuiFocus(focus.focused, focus.cursor)
|
||||
SetNuiFocusKeepInput(focus.keep_input)
|
||||
phone_block_game = focus.block_game == true
|
||||
phone_block_look = focus.block_look == true
|
||||
phone_game_input = focus.game_input
|
||||
if not phone_game_input and not phone_block_game then
|
||||
phone_cursor_disabled = false
|
||||
end
|
||||
TriggerEvent("sky_phone:client:cameraFocusApplied", {
|
||||
active = camera_active,
|
||||
cursor = focus.cursor,
|
||||
@@ -356,10 +385,6 @@ CreateThread(function()
|
||||
else
|
||||
SkyPhoneFocus.ApplyGameInputControls(phone_block_look)
|
||||
end
|
||||
if IsDisabledControlJustPressed(0, 19) then
|
||||
phone_cursor_disabled = not phone_cursor_disabled
|
||||
update_nui_focus()
|
||||
end
|
||||
Wait(0)
|
||||
else
|
||||
Wait(250)
|
||||
@@ -417,6 +442,7 @@ local function close_phone(close_device_session)
|
||||
open_requested = false
|
||||
call_focus = false
|
||||
activity_suspended = false
|
||||
phone_text_input_focused = false
|
||||
TriggerEvent("sky_phone:animation:phone", false)
|
||||
is_open = false
|
||||
if was_open then
|
||||
@@ -499,6 +525,7 @@ RegisterNUICallback("ui:ready", function(data, cb)
|
||||
-- Browser state is recreated on a CEF reload. A notification focus claim
|
||||
-- cannot survive unless its notification is replayed as part of this handshake.
|
||||
notification_focus = false
|
||||
phone_text_input_focused = false
|
||||
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
|
||||
SkyPhoneApps.SendCatalog()
|
||||
if open_requested and device_payload then
|
||||
@@ -554,6 +581,16 @@ RegisterNUICallback("ui:opened", function(data, cb)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("ui:input-focus", function(data, cb)
|
||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
phone_text_input_focused = data.active and (is_open or open_requested)
|
||||
update_nui_focus()
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("close", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
@@ -867,6 +904,18 @@ RegisterNetEvent("sky_phone:picstagram:new", function(data)
|
||||
SendNUIMessage({ type = "picstagram:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:skypic:new", function(data)
|
||||
local skypic_locale = locale.Nui.Apps.skypic
|
||||
local notification_text = skypic_locale.notifications[data.kind] or skypic_locale.notifications.default
|
||||
data.title = skypic_locale.name
|
||||
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
|
||||
SendNUIMessage({ type = "skypic:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:skypic:changed", function(data)
|
||||
SendNUIMessage({ type = "skypic:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:feather:new", function(data)
|
||||
local feather_locale = locale.Nui.Apps.feather
|
||||
local notification_text = feather_locale.notifications[data.kind] or feather_locale.notifications.default
|
||||
@@ -1085,7 +1134,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
active_call_payload = nil
|
||||
activity_suspended = false
|
||||
phone_game_input = false
|
||||
phone_cursor_disabled = false
|
||||
phone_text_input_focused = false
|
||||
SetNuiFocusKeepInput(false)
|
||||
SetNuiFocus(false, false)
|
||||
|
||||
|
||||
@@ -113,9 +113,12 @@ end
|
||||
local function send_state(call, source, state, channel)
|
||||
local outgoing = source == call.caller_source
|
||||
local speaker_supported = Bridge.Speaker.IsEnabled() and (
|
||||
call.voice_provider == "saltychat"
|
||||
call.voice_provider == "yaca"
|
||||
or call.voice_provider == "saltychat"
|
||||
or (not call.voice_provider and Bridge.Calls.SupportsSpeaker())
|
||||
)
|
||||
local mute_supported = call.voice_provider == "yaca"
|
||||
or (not call.voice_provider and Bridge.Calls.SupportsMute())
|
||||
local payload = {
|
||||
id = call.id,
|
||||
state = state,
|
||||
@@ -126,6 +129,8 @@ local function send_state(call, source, state, channel)
|
||||
channel = channel,
|
||||
speakerEnabled = call.speakers and call.speakers[source] == true or false,
|
||||
speakerSupported = speaker_supported,
|
||||
muted = call.muted and call.muted[source] == true or false,
|
||||
muteSupported = mute_supported,
|
||||
}
|
||||
if call.payphone and outgoing then
|
||||
payload.elapsedSeconds = call.payphone.elapsed_seconds or 0
|
||||
@@ -252,6 +257,7 @@ local function finish_call(call, status)
|
||||
end
|
||||
Bridge.Calls.Stop(call.id, player_handles, call.voice_provider)
|
||||
call.speakers = {}
|
||||
call.muted = {}
|
||||
call.voice_started = false
|
||||
end
|
||||
local ended_at = os.time()
|
||||
@@ -1247,6 +1253,7 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
|
||||
call.voice_provider = voice_provider
|
||||
call.voice_started = true
|
||||
call.speakers = {}
|
||||
call.muted = {}
|
||||
call.answered_at = os.time()
|
||||
call.channel = next_voice_channel
|
||||
next_voice_channel = next_voice_channel + 1
|
||||
@@ -1281,7 +1288,7 @@ Bridge.Callbacks.Register("sky_phone:calls:set-speaker", function(source, data)
|
||||
if not call or call.id ~= data.id or not call.answered_at or call.ended or not call.voice_started then
|
||||
return { success = false, error = "call_not_found" }
|
||||
end
|
||||
if call.voice_provider ~= "saltychat" then
|
||||
if call.voice_provider ~= "yaca" and call.voice_provider ~= "saltychat" then
|
||||
return { success = false, error = "speaker_unsupported" }
|
||||
end
|
||||
if not Bridge.Speaker.IsEnabled() then
|
||||
@@ -1302,6 +1309,37 @@ Bridge.Callbacks.Register("sky_phone:calls:set-speaker", function(source, data)
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calls:set-muted", function(source, data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.enabled) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
if not SkyPhone.AllowOperation(source, "call_mute", 30, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
local call_id = active_by_source[source]
|
||||
local call = call_id and calls[call_id] or nil
|
||||
if not call or call.id ~= data.id or not call.answered_at or call.ended or not call.voice_started then
|
||||
return { success = false, error = "call_not_found" }
|
||||
end
|
||||
if call.voice_provider ~= "yaca" then
|
||||
return { success = false, error = "mute_unsupported" }
|
||||
end
|
||||
if not Bridge.Calls.SetMuted(source, data.enabled, call.voice_provider) then
|
||||
return { success = false, error = "voice_unavailable" }
|
||||
end
|
||||
|
||||
call.muted[source] = data.enabled
|
||||
send_state(call, source, "connected", call.channel)
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
muted = data.enabled,
|
||||
muteSupported = true,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calls:decline", function(source, data)
|
||||
local call = type(data) == "table" and calls[data.id] or nil
|
||||
if not call or call.callee_source ~= source or call.answered_at or call.rerouting then
|
||||
|
||||
@@ -2907,9 +2907,302 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_profiles",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "handle", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "display_name", type = "VARCHAR(40) NOT NULL" },
|
||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "avatar_seed", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "story_privacy", type = "ENUM('friends', 'everyone') NOT NULL DEFAULT 'friends'" },
|
||||
{ name = "quick_add", type = "TINYINT(1) NOT NULL DEFAULT 1" },
|
||||
{ name = "allow_story_replies", type = "TINYINT(1) NOT NULL DEFAULT 1" },
|
||||
{ name = "snap_score", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "friend_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "status", type = "ENUM('active', 'hidden', 'removed') NOT NULL DEFAULT 'active'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_skypic_profile_account", columns = "(`account_id`)" },
|
||||
{ name = "uniq_sky_phone_skypic_profile_handle", columns = "(`handle`)" },
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_quick_add", columns = "(`status`, `quick_add`, `updated_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_friendships",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_a_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_b_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "requested_by_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "status", type = "ENUM('pending', 'accepted') NOT NULL DEFAULT 'pending'" },
|
||||
{ name = "profile_a_last_snap_on", type = "DATE NULL" },
|
||||
{ name = "profile_b_last_snap_on", type = "DATE NULL" },
|
||||
{ name = "streak_updated_on", type = "DATE NULL" },
|
||||
{ name = "streak_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "best_streak", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "accepted_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_skypic_friend_pair", columns = "(`profile_a_id`, `profile_b_id`)" },
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_friend_a", columns = "(`profile_a_id`, `status`, `updated_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_friend_b", columns = "(`profile_b_id`, `status`, `updated_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_friend_requests", columns = "(`status`, `requested_by_id`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_streaks", columns = "(`status`, `streak_updated_on`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "profile_a_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "profile_b_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "requested_by_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_blocks",
|
||||
columns = {
|
||||
{ name = "blocker_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "blocked_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = { "blocker_profile_id", "blocked_profile_id" },
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_blocked", columns = "(`blocked_profile_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "blocker_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "blocked_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_messages",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "friendship_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "recipient_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "message_type", type = "ENUM('text', 'snap_photo', 'snap_video') NOT NULL" },
|
||||
{ name = "body", type = "VARCHAR(2000) NOT NULL DEFAULT ''" },
|
||||
{ name = "caption", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "overlay_text", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "overlay_color", type = "CHAR(7) NOT NULL DEFAULT '#FFFFFF'", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "view_seconds", type = "TINYINT UNSIGNED NULL" },
|
||||
{ name = "allow_replay", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "read_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "opened_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "replayed_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "saved_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "expires_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "sender_deleted_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "recipient_deleted_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "deleted_at", type = "DATETIME(6) NULL" },
|
||||
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_message_thread", columns = "(`friendship_id`, `created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_skypic_message_inbox", columns = "(`recipient_profile_id`, `read_at`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_message_expiry", columns = "(`message_type`, `expires_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_message_deleted", columns = "(`deleted_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_message_media", columns = "(`media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "friendship_id", references = "`sky_phone_skypic_friendships` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "sender_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "recipient_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE RESTRICT" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_stories",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "caption", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "overlay_text", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "overlay_color", type = "CHAR(7) NOT NULL DEFAULT '#FFFFFF'", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "view_seconds", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
{ name = "privacy", type = "ENUM('friends', 'everyone') NOT NULL" },
|
||||
{ name = "status", type = "ENUM('active', 'removed') NOT NULL DEFAULT 'active'" },
|
||||
{ name = "expires_at", type = "DATETIME(6) NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
{ name = "updated_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_story_profile", columns = "(`profile_id`, `status`, `expires_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_story_expiry", columns = "(`status`, `expires_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_story_media", columns = "(`media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE RESTRICT" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_story_views",
|
||||
columns = {
|
||||
{ name = "story_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "viewer_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "viewed_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = { "story_id", "viewer_profile_id" },
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_story_viewer", columns = "(`viewer_profile_id`, `viewed_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "story_id", references = "`sky_phone_skypic_stories` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "viewer_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_spotlights",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "caption", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "overlay_text", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "overlay_color", type = "CHAR(7) NOT NULL DEFAULT '#FFFFFF'", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "kind", type = "ENUM('organic', 'sponsored') NOT NULL DEFAULT 'organic'" },
|
||||
{ name = "ad_headline", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
|
||||
{ name = "comments_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" },
|
||||
{ name = "status", type = "ENUM('active', 'removed') NOT NULL DEFAULT 'active'" },
|
||||
{ name = "expires_at", type = "DATETIME(6) NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
{ name = "updated_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_spotlight_feed", columns = "(`status`, `created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_skypic_spotlight_profile", columns = "(`profile_id`, `status`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_spotlight_expiry", columns = "(`status`, `expires_at`)" },
|
||||
{ name = "idx_sky_phone_skypic_spotlight_media", columns = "(`media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE RESTRICT" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_spotlight_views",
|
||||
columns = {
|
||||
{ name = "spotlight_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "viewer_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "viewed_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = { "spotlight_id", "viewer_profile_id" },
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_spotlight_viewer", columns = "(`viewer_profile_id`, `viewed_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "viewer_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_spotlight_likes",
|
||||
columns = {
|
||||
{ name = "spotlight_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = { "spotlight_id", "profile_id" },
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_spotlight_liker", columns = "(`profile_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_spotlight_comments",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "spotlight_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "body", type = "VARCHAR(500) NOT NULL" },
|
||||
{ name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" },
|
||||
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_spotlight_comments", columns = "(`spotlight_id`, `status`, `created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_skypic_spotlight_comment_author", columns = "(`profile_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_skypic_spotlight_reports",
|
||||
columns = {
|
||||
{ name = "spotlight_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reporter_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reason", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "status", type = "ENUM('pending', 'reviewed', 'dismissed') NOT NULL DEFAULT 'pending'" },
|
||||
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
|
||||
},
|
||||
primaryKey = { "spotlight_id", "reporter_profile_id" },
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_skypic_spotlight_report_queue", columns = "(`status`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "reporter_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
Bridge.Database.EnsureIndex(
|
||||
"sky_phone_skypic_profiles",
|
||||
"uniq_sky_phone_skypic_profile_account",
|
||||
"(`account_id`)",
|
||||
{ unique = true }
|
||||
)
|
||||
Bridge.Database.EnsureIndex(
|
||||
"sky_phone_skypic_profiles",
|
||||
"uniq_sky_phone_skypic_profile_handle",
|
||||
"(`handle`)",
|
||||
{ unique = true }
|
||||
)
|
||||
Bridge.Database.EnsureIndex(
|
||||
"sky_phone_skypic_friendships",
|
||||
"uniq_sky_phone_skypic_friend_pair",
|
||||
"(`profile_a_id`, `profile_b_id`)",
|
||||
{ unique = true }
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`)
|
||||
SELECT `id`, `media_id`, 1 FROM `sky_phone_fliptok_videos`
|
||||
|
||||
@@ -37,6 +37,7 @@ local valid_apps = {
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
skypic = true,
|
||||
}
|
||||
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ local allowed_remote_mimes = {
|
||||
},
|
||||
}
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
if type(result) == "table" then
|
||||
return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function media_config()
|
||||
return Config.Media.FiveManage
|
||||
end
|
||||
@@ -821,6 +831,24 @@ local function is_required_flare_profile_photo(media_id)
|
||||
return rows[1] ~= nil
|
||||
end
|
||||
|
||||
local function is_referenced_by_skypic(media_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT 1 AS `in_use`
|
||||
FROM `sky_phone_skypic_messages`
|
||||
WHERE `media_id` = ?
|
||||
UNION ALL
|
||||
SELECT 1 AS `in_use`
|
||||
FROM `sky_phone_skypic_stories`
|
||||
WHERE `media_id` = ?
|
||||
UNION ALL
|
||||
SELECT 1 AS `in_use`
|
||||
FROM `sky_phone_skypic_spotlights`
|
||||
WHERE `media_id` = ?
|
||||
LIMIT 1
|
||||
]], { media_id, media_id, media_id })
|
||||
return rows[1] ~= nil
|
||||
end
|
||||
|
||||
local function delete_owned_media(src, owner, media_id)
|
||||
local condition, params = owner_condition(owner)
|
||||
local query_params = { media_id }
|
||||
@@ -838,25 +866,74 @@ local function delete_owned_media(src, owner, media_id)
|
||||
if row.media_type == "photo" and is_required_flare_profile_photo(media_id) then
|
||||
return false, "profile_photo_required"
|
||||
end
|
||||
-- All SkyPic media foreign keys are RESTRICT. Keep the remote object intact until
|
||||
-- cleanup has physically removed every referencing row.
|
||||
if is_referenced_by_skypic(media_id) then
|
||||
return false, "media_in_use"
|
||||
end
|
||||
local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id)
|
||||
if pending_deletes[delete_key] then
|
||||
return false, "operation_in_progress"
|
||||
end
|
||||
pending_deletes[delete_key] = src
|
||||
local delete_remote = false
|
||||
if row.origin == "phone_upload" then
|
||||
local references = Bridge.Database.Query(
|
||||
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
|
||||
{ row.remote_id }
|
||||
)
|
||||
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[delete_key] = nil
|
||||
return false, delete_error
|
||||
end
|
||||
delete_remote = (tonumber(references[1] and references[1].count) or 0) <= 1
|
||||
end
|
||||
-- Delete the database parent first and repeat the SkyPic guard inside the
|
||||
-- same statement. The RESTRICT foreign keys then make this atomic against
|
||||
-- a concurrent snap/story/spotlight insert: either that insert wins and this DELETE
|
||||
-- affects zero rows, or this DELETE wins and the later insert cannot refer
|
||||
-- to a missing media row.
|
||||
local delete_params = {}
|
||||
for _, value in ipairs(query_params) do
|
||||
delete_params[#delete_params + 1] = value
|
||||
end
|
||||
delete_params[#delete_params + 1] = media_id
|
||||
delete_params[#delete_params + 1] = media_id
|
||||
delete_params[#delete_params + 1] = media_id
|
||||
local result = Bridge.Database.Query(([[
|
||||
DELETE FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sky_phone_skypic_messages`
|
||||
WHERE `media_id` = ?
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sky_phone_skypic_stories`
|
||||
WHERE `media_id` = ?
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sky_phone_skypic_spotlights`
|
||||
WHERE `media_id` = ?
|
||||
)
|
||||
]]):format(condition), delete_params)
|
||||
if affected_rows(result) ~= 1 then
|
||||
pending_deletes[delete_key] = nil
|
||||
if is_referenced_by_skypic(media_id) then
|
||||
return false, "media_in_use"
|
||||
end
|
||||
return false, "not_found"
|
||||
end
|
||||
if delete_remote then
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
-- The user-visible record is already gone and no new FK reference
|
||||
-- can be created. Report the logical delete as complete and leave a
|
||||
-- precise audit trail for provider-side orphan cleanup.
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Media %s was deleted locally but remote object %s could not be removed (%s).",
|
||||
tostring(media_id),
|
||||
tostring(row.remote_id),
|
||||
tostring(delete_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params)
|
||||
pending_deletes[delete_key] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -910,12 +910,16 @@ function SkyPhone.NotifyAccount(account_id, event_name, data)
|
||||
end
|
||||
end
|
||||
|
||||
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
||||
function SkyPhone.NotifyAccountDevices(account_id, event_name, data, required_app_auth)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
|
||||
SELECT d.`imei`, d.`device_name`, account.`email` AS `account_email`,
|
||||
settings.`payload` AS `settings`, app_auth.`payload` AS `app_auth`
|
||||
FROM `sky_phone_devices` d
|
||||
JOIN `sky_phone_accounts` account ON account.`id` = d.`account_id`
|
||||
LEFT JOIN `sky_phone_device_data` settings
|
||||
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
|
||||
LEFT JOIN `sky_phone_device_data` app_auth
|
||||
ON app_auth.`device_imei` = d.`imei` AND app_auth.`namespace` = 'appAuth'
|
||||
WHERE d.`account_id` = ?
|
||||
]], { account_id })
|
||||
local devices = {}
|
||||
@@ -923,7 +927,31 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
||||
devices[row.imei] = row
|
||||
end
|
||||
|
||||
local function has_required_app_session(device)
|
||||
if required_app_auth == nil then
|
||||
return true
|
||||
end
|
||||
if type(required_app_auth) ~= 'string' or type(device.app_auth) ~= 'string' then
|
||||
return false
|
||||
end
|
||||
local decoded, app_auth = pcall(json.decode, device.app_auth)
|
||||
if not decoded or type(app_auth) ~= 'table' or app_auth.version ~= 1
|
||||
or app_auth.accountEmail ~= device.account_email or type(app_auth.signedIn) ~= 'table'
|
||||
then
|
||||
return false
|
||||
end
|
||||
for _, app_id in ipairs(app_auth.signedIn) do
|
||||
if app_id == required_app_auth then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function notify_device(source, device)
|
||||
if not has_required_app_session(device) then
|
||||
return
|
||||
end
|
||||
local payload = {}
|
||||
for key, value in pairs(data) do
|
||||
payload[key] = value
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@ local function seed_hash(seed)
|
||||
return value
|
||||
end
|
||||
|
||||
local function darkchat_identifiers(account_id)
|
||||
local account_key = tostring(account_id)
|
||||
local dark_id = "DC" .. seed_hash("darkchat:id:" .. account_key):upper()
|
||||
local invite_code = "I" .. seed_hash("darkchat:invite:" .. account_key):sub(1, 10):upper()
|
||||
return dark_id, invite_code
|
||||
end
|
||||
|
||||
local function database_uuid()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = rows[1] and rows[1].id
|
||||
@@ -127,6 +134,71 @@ local function reserve_sim(owner_identifier, firstname, lastname)
|
||||
return { id = sim_id, phone_number = number, sim_type = "registered" }
|
||||
end
|
||||
|
||||
local function restore_sim_attachment(sim_id, current_imei, previous_imei)
|
||||
local statements = {
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?",
|
||||
params = { current_imei, sim_id },
|
||||
},
|
||||
}
|
||||
if previous_imei then
|
||||
statements[#statements + 1] = {
|
||||
query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL",
|
||||
params = { sim_id, previous_imei },
|
||||
}
|
||||
end
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return false
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query(
|
||||
"SELECT `imei` FROM `sky_phone_devices` WHERE `sim_id` = ? LIMIT 1",
|
||||
{ sim_id }
|
||||
)
|
||||
if previous_imei then
|
||||
return rows[1] and rows[1].imei == previous_imei
|
||||
end
|
||||
return rows[1] == nil
|
||||
end
|
||||
|
||||
local function move_sim_to_device(sim_id, imei)
|
||||
local rows = Bridge.Database.Query(
|
||||
"SELECT `imei` FROM `sky_phone_devices` WHERE `sim_id` = ? LIMIT 1",
|
||||
{ sim_id }
|
||||
)
|
||||
local previous_imei = rows[1] and rows[1].imei or nil
|
||||
if previous_imei == imei then
|
||||
return previous_imei
|
||||
end
|
||||
|
||||
local moved = Bridge.Database.Transaction({
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `sim_id` = ? AND `imei` <> ?",
|
||||
params = { sim_id, imei },
|
||||
},
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL",
|
||||
params = { sim_id, imei },
|
||||
},
|
||||
})
|
||||
if not moved then
|
||||
error("[sky_phone] Test data could not move the player's SIM to the selected phone.")
|
||||
end
|
||||
|
||||
rows = Bridge.Database.Query(
|
||||
"SELECT `sim_id` FROM `sky_phone_devices` WHERE `imei` = ? LIMIT 1",
|
||||
{ imei }
|
||||
)
|
||||
if not rows[1] or rows[1].sim_id ~= sim_id then
|
||||
if not restore_sim_attachment(sim_id, imei, previous_imei) then
|
||||
error("[sky_phone] Test data could not verify the SIM move or restore its previous device.")
|
||||
end
|
||||
error("[sky_phone] Test data could not verify the SIM move.")
|
||||
end
|
||||
|
||||
return previous_imei
|
||||
end
|
||||
|
||||
local function ensure_bot(label, email_local, imei, firstname, lastname)
|
||||
local account = ensure_account(email_local .. "@" .. Config.Mail.Domain)
|
||||
local sim = reserve_sim("sky_phone:testbot:" .. label, firstname, lastname)
|
||||
@@ -667,36 +739,57 @@ local function seed_social_apps(context)
|
||||
INSERT IGNORE INTO `sky_phone_flare_profile_photos` (`profile_id`, `media_id`, `sort_order`)
|
||||
VALUES (?, ?, 1), (?, ?, 1)
|
||||
]], { flare_user, context.media.user_portrait, flare_bot, context.media.bot_two_portrait })
|
||||
local match_id = stable_uuid(context.key .. ":flare:match")
|
||||
local account_a = math.min(account_id, bot_two_id)
|
||||
local account_b = math.max(account_id, bot_two_id)
|
||||
local proposed_match_id = stable_uuid(
|
||||
("sky_phone:testdata:flare:match:%s:%s"):format(account_a, account_b)
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`)
|
||||
VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `created_at` = `created_at`
|
||||
]], { match_id, account_a, account_b })
|
||||
local flare_message = stable_uuid(context.key .. ":flare:message")
|
||||
]], { proposed_match_id, account_a, account_b })
|
||||
local match_rows = Bridge.Database.Query([[
|
||||
SELECT `id` FROM `sky_phone_flare_matches`
|
||||
WHERE `account_a_id` = ? AND `account_b_id` = ? LIMIT 1
|
||||
]], { account_a, account_b })
|
||||
local match_id = match_rows[1] and match_rows[1].id or nil
|
||||
if type(match_id) ~= "string" then
|
||||
error("[sky_phone] Test data Flare match could not be loaded.")
|
||||
end
|
||||
local flare_body = "Hey! Bereit für einen vollständigen App-Test?"
|
||||
local message_rows = Bridge.Database.Query([[
|
||||
SELECT `id` FROM `sky_phone_flare_messages`
|
||||
WHERE `match_id` = ? AND `sender_account_id` = ? AND `body` = ?
|
||||
ORDER BY `created_at`, `id` LIMIT 1
|
||||
]], { match_id, bot_two_id, flare_body })
|
||||
local flare_message = message_rows[1] and message_rows[1].id
|
||||
or stable_uuid("sky_phone:testdata:flare:message:" .. match_id)
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_flare_messages` (`id`, `match_id`, `sender_account_id`, `body`, `read_at`)
|
||||
VALUES (?, ?, ?, 'Hey! Bereit für einen vollständigen App-Test?', NULL)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL
|
||||
]], { flare_message, match_id, bot_two_id })
|
||||
]], { flare_message, match_id, bot_two_id, flare_body })
|
||||
end
|
||||
|
||||
local function seed_private_and_services(context)
|
||||
local account_id = context.account.id
|
||||
local bot_id = context.bot_one.account.id
|
||||
local user_dark_id, user_invite_code = darkchat_identifiers(account_id)
|
||||
local bot_dark_id, bot_invite_code = darkchat_identifiers(bot_id)
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_darkchat_profiles`
|
||||
(`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`)
|
||||
VALUES (?, ?, ?, 'NightTester', 42, 'private', 1)
|
||||
ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`)
|
||||
]], { account_id, ("DARK%010d"):format(account_id % 10000000000), ("INV%08d"):format(account_id % 100000000) })
|
||||
ON DUPLICATE KEY UPDATE `dark_id` = VALUES(`dark_id`), `invite_code` = VALUES(`invite_code`),
|
||||
`alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`)
|
||||
]], { account_id, user_dark_id, user_invite_code })
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_darkchat_profiles`
|
||||
(`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`)
|
||||
VALUES (?, 'DARK0000000001', 'INV00000001', 'GhostAlex', 17, 'full', 1)
|
||||
ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`)
|
||||
]], { bot_id })
|
||||
VALUES (?, ?, ?, 'GhostAlex', 17, 'full', 1)
|
||||
ON DUPLICATE KEY UPDATE `dark_id` = VALUES(`dark_id`), `invite_code` = VALUES(`invite_code`),
|
||||
`alias` = VALUES(`alias`)
|
||||
]], { bot_id, bot_dark_id, bot_invite_code })
|
||||
local dark_user = ensure_numeric_profile("sky_phone_darkchat_profiles", account_id)
|
||||
local dark_bot = ensure_numeric_profile("sky_phone_darkchat_profiles", bot_id)
|
||||
Bridge.Database.Query([[
|
||||
@@ -897,7 +990,7 @@ local function seed_for_source(source)
|
||||
sim = rows[1]
|
||||
else
|
||||
sim = reserve_sim(identifier, Bridge.Framework.GetFirstname(source), Bridge.Framework.GetLastname(source))
|
||||
Bridge.Database.Query("UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ?", { sim.id, imei })
|
||||
local previous_imei = move_sim_to_device(sim.id, imei)
|
||||
local metadata = phone_slot.metadata or {}
|
||||
metadata.sim_id = sim.id
|
||||
metadata.phone_number = sim.phone_number
|
||||
@@ -908,6 +1001,11 @@ local function seed_for_source(source)
|
||||
Config.Sim.NumberPrefix
|
||||
)
|
||||
if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata) then
|
||||
if not restore_sim_attachment(sim.id, imei, previous_imei) then
|
||||
error(
|
||||
"[sky_phone] Test data could not update the phone item's SIM metadata or restore its previous device."
|
||||
)
|
||||
end
|
||||
error("[sky_phone] Test data could not update the phone item's SIM metadata.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
local RELEASE_API_URL = "https://api.github.com/repos/sky-systems/sky_phone/releases/latest"
|
||||
local RELEASE_PAGE_URL = "https://github.com/sky-systems/sky_phone/releases/latest"
|
||||
|
||||
local function parse_version(version)
|
||||
if type(version) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local major, minor, patch = version:match("^v?(%d+)%.(%d+)%.(%d+)$")
|
||||
if not major then
|
||||
return nil
|
||||
end
|
||||
|
||||
return tonumber(major), tonumber(minor), tonumber(patch)
|
||||
end
|
||||
|
||||
local function compare_versions(installed_version, release_version)
|
||||
local installed_major, installed_minor, installed_patch = parse_version(installed_version)
|
||||
local release_major, release_minor, release_patch = parse_version(release_version)
|
||||
if not installed_major or not release_major then
|
||||
return nil
|
||||
end
|
||||
|
||||
if installed_major ~= release_major then
|
||||
return installed_major < release_major and -1 or 1
|
||||
end
|
||||
|
||||
if installed_minor ~= release_minor then
|
||||
return installed_minor < release_minor and -1 or 1
|
||||
end
|
||||
|
||||
if installed_patch ~= release_patch then
|
||||
return installed_patch < release_patch and -1 or 1
|
||||
end
|
||||
|
||||
return 0
|
||||
end
|
||||
|
||||
local function print_update_notice(installed_version, release_version)
|
||||
local border = "======================================================================"
|
||||
print(([[
|
||||
^1%s^0
|
||||
^1 SKY PHONE UPDATE AVAILABLE ^0
|
||||
^1%s^0
|
||||
^3 Installed version: ^1%s^0
|
||||
^3 Latest release: ^2%s^0
|
||||
|
||||
^5 Download: %s^0
|
||||
^1%s^0]]):format(border, border, installed_version, release_version, RELEASE_PAGE_URL, border))
|
||||
end
|
||||
|
||||
local function check_for_update()
|
||||
local resource_name = GetCurrentResourceName()
|
||||
local installed_version = GetResourceMetadata(resource_name, "version", 0)
|
||||
if not parse_version(installed_version) then
|
||||
print(("^3[sky_phone] Update check skipped because fxmanifest.lua has an invalid version: %s.^0")
|
||||
:format(tostring(installed_version)))
|
||||
return
|
||||
end
|
||||
|
||||
PerformHttpRequest(RELEASE_API_URL, function(status_code, response_body)
|
||||
if status_code ~= 200 then
|
||||
print(("^3[sky_phone] GitHub update check failed with HTTP %s.^0"):format(tostring(status_code)))
|
||||
return
|
||||
end
|
||||
|
||||
local decoded, release = pcall(json.decode, response_body)
|
||||
local release_version = decoded and type(release) == "table" and release.tag_name or nil
|
||||
local comparison = compare_versions(installed_version, release_version)
|
||||
if not comparison then
|
||||
print(("^3[sky_phone] GitHub update check returned an invalid release tag: %s.^0")
|
||||
:format(tostring(release_version)))
|
||||
return
|
||||
end
|
||||
|
||||
if comparison < 0 then
|
||||
print_update_notice(installed_version, release_version)
|
||||
elseif comparison > 0 then
|
||||
print(("^3[sky_phone] Installed version %s is newer than the latest GitHub release %s.^0")
|
||||
:format(installed_version, release_version))
|
||||
else
|
||||
print(("^2[sky_phone] Version %s is up to date.^0"):format(installed_version))
|
||||
end
|
||||
end, "GET", "", {
|
||||
["Accept"] = "application/vnd.github+json",
|
||||
["User-Agent"] = "sky_phone-update-check",
|
||||
["X-GitHub-Api-Version"] = "2022-11-28",
|
||||
})
|
||||
end
|
||||
|
||||
CreateThread(check_for_update)
|
||||
@@ -66,6 +66,7 @@ local RESERVED_APP_IDS = {
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
skypic = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
|
||||
@@ -1483,3 +1483,197 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_profiles` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`display_name` VARCHAR(40) NOT NULL,
|
||||
`bio` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`avatar_media_id` BIGINT UNSIGNED NULL,
|
||||
`avatar_seed` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`story_privacy` ENUM('friends','everyone') NOT NULL DEFAULT 'friends',
|
||||
`quick_add` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`allow_story_replies` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`snap_score` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`friend_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` ENUM('active','hidden','removed') NOT NULL DEFAULT 'active',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_skypic_profile_account` (`account_id`),
|
||||
UNIQUE KEY `uniq_sky_phone_skypic_profile_handle` (`handle`),
|
||||
KEY `idx_sky_phone_skypic_quick_add` (`status`,`quick_add`,`updated_at`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_friendships` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_a_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_b_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`requested_by_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`status` ENUM('pending','accepted') NOT NULL DEFAULT 'pending',
|
||||
`profile_a_last_snap_on` DATE NULL,
|
||||
`profile_b_last_snap_on` DATE NULL,
|
||||
`streak_updated_on` DATE NULL,
|
||||
`streak_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`best_streak` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`accepted_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_skypic_friend_pair` (`profile_a_id`,`profile_b_id`),
|
||||
KEY `idx_sky_phone_skypic_friend_a` (`profile_a_id`,`status`,`updated_at`),
|
||||
KEY `idx_sky_phone_skypic_friend_b` (`profile_b_id`,`status`,`updated_at`),
|
||||
KEY `idx_sky_phone_skypic_friend_requests` (`status`,`requested_by_id`,`created_at`),
|
||||
KEY `idx_sky_phone_skypic_streaks` (`status`,`streak_updated_on`),
|
||||
FOREIGN KEY (`profile_a_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`profile_b_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`requested_by_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_blocks` (
|
||||
`blocker_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`blocked_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`blocker_profile_id`,`blocked_profile_id`),
|
||||
KEY `idx_sky_phone_skypic_blocked` (`blocked_profile_id`,`created_at`),
|
||||
FOREIGN KEY (`blocker_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`blocked_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_messages` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`friendship_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`sender_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`recipient_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`message_type` ENUM('text','snap_photo','snap_video') NOT NULL,
|
||||
`body` VARCHAR(2000) NOT NULL DEFAULT '',
|
||||
`caption` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`overlay_text` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`overlay_color` CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '#FFFFFF',
|
||||
`media_id` BIGINT UNSIGNED NULL,
|
||||
`view_seconds` TINYINT UNSIGNED NULL,
|
||||
`allow_replay` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`read_at` DATETIME(6) NULL,
|
||||
`opened_at` DATETIME(6) NULL,
|
||||
`replayed_at` DATETIME(6) NULL,
|
||||
`saved_at` DATETIME(6) NULL,
|
||||
`expires_at` DATETIME(6) NULL,
|
||||
`sender_deleted_at` DATETIME(6) NULL,
|
||||
`recipient_deleted_at` DATETIME(6) NULL,
|
||||
`deleted_at` DATETIME(6) NULL,
|
||||
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_skypic_message_thread` (`friendship_id`,`created_at`,`id`),
|
||||
KEY `idx_sky_phone_skypic_message_inbox` (`recipient_profile_id`,`read_at`,`created_at`),
|
||||
KEY `idx_sky_phone_skypic_message_expiry` (`message_type`,`expires_at`),
|
||||
KEY `idx_sky_phone_skypic_message_deleted` (`deleted_at`),
|
||||
KEY `idx_sky_phone_skypic_message_media` (`media_id`),
|
||||
FOREIGN KEY (`friendship_id`) REFERENCES `sky_phone_skypic_friendships` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`sender_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`recipient_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_stories` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`media_id` BIGINT UNSIGNED NOT NULL,
|
||||
`caption` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`overlay_text` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`overlay_color` CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '#FFFFFF',
|
||||
`view_seconds` TINYINT UNSIGNED NOT NULL,
|
||||
`privacy` ENUM('friends','everyone') NOT NULL,
|
||||
`status` ENUM('active','removed') NOT NULL DEFAULT 'active',
|
||||
`expires_at` DATETIME(6) NOT NULL,
|
||||
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_skypic_story_profile` (`profile_id`,`status`,`expires_at`),
|
||||
KEY `idx_sky_phone_skypic_story_expiry` (`status`,`expires_at`),
|
||||
KEY `idx_sky_phone_skypic_story_media` (`media_id`),
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_story_views` (
|
||||
`story_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`viewer_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`viewed_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`story_id`,`viewer_profile_id`),
|
||||
KEY `idx_sky_phone_skypic_story_viewer` (`viewer_profile_id`,`viewed_at`),
|
||||
FOREIGN KEY (`story_id`) REFERENCES `sky_phone_skypic_stories` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`viewer_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlights` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`media_id` BIGINT UNSIGNED NOT NULL,
|
||||
`caption` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`overlay_text` VARCHAR(160) NOT NULL DEFAULT '',
|
||||
`overlay_color` CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '#FFFFFF',
|
||||
`kind` ENUM('organic','sponsored') NOT NULL DEFAULT 'organic',
|
||||
`ad_headline` VARCHAR(80) NOT NULL DEFAULT '',
|
||||
`comments_enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`status` ENUM('active','removed') NOT NULL DEFAULT 'active',
|
||||
`expires_at` DATETIME(6) NOT NULL,
|
||||
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_feed` (`status`,`created_at`,`id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_profile` (`profile_id`,`status`,`created_at`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_expiry` (`status`,`expires_at`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_media` (`media_id`),
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlight_views` (
|
||||
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`viewer_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`viewed_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`spotlight_id`,`viewer_profile_id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_viewer` (`viewer_profile_id`,`viewed_at`),
|
||||
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`viewer_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlight_likes` (
|
||||
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`spotlight_id`,`profile_id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_liker` (`profile_id`,`created_at`),
|
||||
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlight_comments` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`body` VARCHAR(500) NOT NULL,
|
||||
`status` ENUM('visible','removed') NOT NULL DEFAULT 'visible',
|
||||
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_comments` (`spotlight_id`,`status`,`created_at`,`id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_comment_author` (`profile_id`,`created_at`),
|
||||
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlight_reports` (
|
||||
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`reporter_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`reason` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`details` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`status` ENUM('pending','reviewed','dismissed') NOT NULL DEFAULT 'pending',
|
||||
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`spotlight_id`,`reporter_profile_id`),
|
||||
KEY `idx_sky_phone_skypic_spotlight_report_queue` (`status`,`created_at`),
|
||||
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`reporter_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
+16
-14
@@ -29,11 +29,11 @@ local function resolve(overrides)
|
||||
call_focus = false,
|
||||
camera_active = false,
|
||||
camera_nui_focused = true,
|
||||
cursor_disabled = false,
|
||||
is_open = false,
|
||||
notification_focus = false,
|
||||
payphone_focus = false,
|
||||
sim_picker_open = false,
|
||||
text_input_focused = false,
|
||||
}
|
||||
for key, value in pairs(overrides or {}) do
|
||||
state[key] = value
|
||||
@@ -71,22 +71,23 @@ assert(
|
||||
and movable_phone.focused
|
||||
and movable_phone.keep_input
|
||||
and movable_phone.game_input
|
||||
and movable_phone.block_game,
|
||||
"an open phone with the cursor active must block GTA hotkeys while keeping NUI input"
|
||||
and not movable_phone.block_game
|
||||
and movable_phone.block_look,
|
||||
"an open phone must allow movement without hiding the NUI cursor"
|
||||
)
|
||||
|
||||
local cursor_disabled_phone = resolve({
|
||||
local typing_phone = resolve({
|
||||
allow_movement = true,
|
||||
cursor_disabled = true,
|
||||
is_open = true,
|
||||
text_input_focused = true,
|
||||
})
|
||||
assert(
|
||||
not cursor_disabled_phone.cursor
|
||||
and cursor_disabled_phone.focused
|
||||
and cursor_disabled_phone.keep_input
|
||||
and cursor_disabled_phone.game_input
|
||||
and not cursor_disabled_phone.block_game,
|
||||
"toggling Alt must release the NUI cursor while preserving phone and GTA input"
|
||||
typing_phone.cursor
|
||||
and typing_phone.focused
|
||||
and typing_phone.keep_input
|
||||
and typing_phone.game_input
|
||||
and typing_phone.block_game,
|
||||
"a focused phone text input must block GTA controls without hiding the NUI cursor"
|
||||
)
|
||||
|
||||
SkyPhoneFocus.ApplyFocusedControls()
|
||||
@@ -98,9 +99,10 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
|
||||
all_controls_disabled = {}
|
||||
firing_disabled = false
|
||||
SkyPhoneFocus.ApplyGameInputControls(true)
|
||||
for _, control in ipairs({ 19, 24, 140, 141, 142, 257, 263, 264 }) do
|
||||
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
|
||||
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
|
||||
end
|
||||
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")
|
||||
for _, control in ipairs({ 1, 2, 3, 4, 5, 6 }) do
|
||||
assert(disabled_controls[control], ("look control %d must be disabled while the phone cursor is active"):format(control))
|
||||
end
|
||||
@@ -111,8 +113,8 @@ assert(firing_disabled, "player attacks must remain disabled while the phone is
|
||||
disabled_controls = {}
|
||||
firing_disabled = false
|
||||
SkyPhoneFocus.ApplyGameInputControls(false)
|
||||
assert(not disabled_controls[1] and not disabled_controls[2], "Alt cursor toggle must restore camera look")
|
||||
assert(firing_disabled, "player attacks must remain disabled after the cursor is toggled off")
|
||||
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
|
||||
assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
|
||||
|
||||
local movable_notification = resolve({ allow_movement = true, notification_focus = true })
|
||||
assert(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
local source_path = "sky_phone/source/server/update_check.lua"
|
||||
local original_print = print
|
||||
|
||||
local function run_check(installed_version, status_code, release_version)
|
||||
local output = {}
|
||||
local request
|
||||
|
||||
print = function(message)
|
||||
output[#output + 1] = tostring(message)
|
||||
end
|
||||
GetCurrentResourceName = function()
|
||||
return "sky_phone"
|
||||
end
|
||||
GetResourceMetadata = function(resource_name, key, index)
|
||||
assert(resource_name == "sky_phone", "update check must read its own resource metadata")
|
||||
assert(key == "version" and index == 0, "update check must read the fxmanifest version")
|
||||
return installed_version
|
||||
end
|
||||
PerformHttpRequest = function(url, callback, method, body, headers)
|
||||
request = {
|
||||
url = url,
|
||||
callback = callback,
|
||||
method = method,
|
||||
body = body,
|
||||
headers = headers,
|
||||
}
|
||||
end
|
||||
CreateThread = function(callback)
|
||||
callback()
|
||||
end
|
||||
json = {
|
||||
decode = function()
|
||||
return { tag_name = release_version }
|
||||
end,
|
||||
}
|
||||
|
||||
dofile(source_path)
|
||||
|
||||
if request then
|
||||
assert(request.url == "https://api.github.com/repos/sky-systems/sky_phone/releases/latest")
|
||||
assert(request.method == "GET" and request.body == "", "update check must use a read-only GET request")
|
||||
assert(request.headers["Accept"] == "application/vnd.github+json")
|
||||
assert(request.headers["User-Agent"] == "sky_phone-update-check")
|
||||
request.callback(status_code, "{}")
|
||||
end
|
||||
|
||||
return table.concat(output, "\n"), request
|
||||
end
|
||||
|
||||
local current_output = run_check("0.1.0", 200, "0.1.0")
|
||||
assert(current_output:find("Version 0.1.0 is up to date", 1, true), "matching versions must report up to date")
|
||||
|
||||
local outdated_output = run_check("0.1.0", 200, "0.2.0")
|
||||
assert(outdated_output:find("SKY PHONE UPDATE AVAILABLE", 1, true), "newer releases must show an update notice")
|
||||
assert(outdated_output:find("Installed version: ^10.1.0", 1, true), "notice must show the manifest version")
|
||||
assert(outdated_output:find("Latest release: ^20.2.0", 1, true), "notice must show the release tag")
|
||||
|
||||
local ahead_output = run_check("1.0.0", 200, "0.9.9")
|
||||
assert(ahead_output:find("newer than the latest GitHub release", 1, true), "ahead versions must be distinguished")
|
||||
|
||||
local invalid_output, invalid_request = run_check("development", 200, "0.1.0")
|
||||
assert(not invalid_request, "invalid manifest versions must not make an HTTP request")
|
||||
assert(invalid_output:find("fxmanifest.lua has an invalid version", 1, true), "invalid manifests must be visible")
|
||||
|
||||
local failed_output = run_check("0.1.0", 429, "0.1.0")
|
||||
assert(failed_output:find("GitHub update check failed with HTTP 429", 1, true), "HTTP failures must be visible")
|
||||
|
||||
print = original_print
|
||||
io.write("Sky Phone update check tests passed\n")
|
||||
Reference in New Issue
Block a user