Compare commits

...

4 Commits

Author SHA1 Message Date
Dominik9906 42999d462b FIX - keep native dropdown options readable 2026-08-27 00:16:11 +02:00
Dominik9906 1bf44dd917 ENH - configure radio job permissions 2026-08-26 23:41:00 +02:00
Dominik9906 d199097e43 FIX - remove hardware button focus box 2026-08-26 21:17:50 +02:00
Dominik9906 804da7e984 FIX - UI and Bug Fix 2026-08-25 18:43:38 +02:00
25 changed files with 370 additions and 35 deletions
+8 -10
View File
@@ -86,6 +86,7 @@ import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { consumeEscape } from '@/utils/keyboard'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -1315,20 +1316,17 @@ async function closePhone(): Promise<void> {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
if (simPicker.value) {
event.preventDefault()
if (!consumeEscape(event)) return
void closeSimPicker()
return
}
queueMicrotask(() => {
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
})
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
@@ -70,6 +70,43 @@ describe('browser development preview contract', () => {
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
})
it('clips composited app and overlay layers to the curved display', () => {
expect(mainCss).toMatch(
/\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
)
})
it('replaces the CEF button focus rectangle around the home indicator', () => {
expect(mainCss).toMatch(
/\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
)
expect(mainCss).toMatch(
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
)
})
it('replaces rectangular CEF focus outlines on the side hardware controls', () => {
expect(mainCss).toMatch(
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
)
expect(mainCss).toMatch(
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
)
expect(mainCss).not.toMatch(
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/s,
)
})
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
expect(source).toContain(
'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
)
expect(source).not.toMatch(
/function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
)
})
it('maps the visible device side controls to phone actions', () => {
expect(source).toContain('@click="toggleHardwareAlertMute"')
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
+32 -4
View File
@@ -380,9 +380,27 @@ button {
.phone-hardware-button:disabled {
cursor: default;
}
.phone-hardware-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -6px;
.phone-hardware-button:focus {
outline: none;
}
.phone-hardware-button:focus-visible::after {
position: absolute;
top: 50%;
width: 3px;
height: 24px;
border-radius: 999px;
background: #0a84ff;
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
content: '';
transform: translateY(-50%);
}
.phone-hardware-button--action:focus-visible::after,
.phone-hardware-button--volume-up:focus-visible::after,
.phone-hardware-button--volume-down:focus-visible::after {
right: 7px;
}
.phone-hardware-button--power:focus-visible::after {
left: 7px;
}
.phone-hardware-button--action {
top: 176px;
@@ -460,6 +478,7 @@ button {
}
}
.phone-screen {
--phone-screen-radius: 40px;
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
@@ -469,7 +488,8 @@ button {
height: 98%;
overflow: hidden;
background: #08080a;
border-radius: 40px;
border-radius: var(--phone-screen-radius);
clip-path: inset(0 round var(--phone-screen-radius));
}
.phone-screen--camera-landscape {
background: transparent;
@@ -856,6 +876,14 @@ button {
border-radius: 10px;
box-shadow: 0 1px 4px #0008;
}
.phone-home-indicator:focus {
outline: none;
}
.phone-home-indicator:focus-visible span {
box-shadow:
0 0 0 2px #0a84ff,
0 1px 4px #0008;
}
.phone-home-indicator--interactive {
cursor: pointer;
}
@@ -18,6 +18,7 @@ import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescrip
export type AdminConfigEditorLabels = {
addField: string
addJob: string
addRow: string
configuredSecret: string
convertToList: string
@@ -27,6 +28,7 @@ export type AdminConfigEditorLabels = {
emptyTable: string
entry: string
general: string
jobPlaceholder: string
keyPlaceholder: string
list: string
remove: string
@@ -149,6 +151,11 @@ const canExtendTable = computed(
!vectorType.value &&
(!tableStructure.value || tableStructure.value.mutableKeys === true),
)
const isJobTable = computed(
() =>
props.path === 'Radio.DisplayName.AllowedJobs' ||
/^Radio\.LockedChannels\[\d+\]\.jobs$/.test(props.path),
)
const usesFixedTableLayout = computed(
() =>
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
@@ -371,6 +378,19 @@ function updateOptionalString(event: Event): void {
}
}
function updateNewObjectKey(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const value = isJobTable.value
? target.value
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '')
.slice(0, 64)
: target.value
newObjectKey.value = value
target.value = value
}
function toggleOptionalString(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -1015,11 +1035,15 @@ function mapEntryStructure(
@submit.prevent="addTableField"
>
<input
v-model="newObjectKey"
:value="newObjectKey"
type="text"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
:placeholder="
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
"
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
autocomplete="off"
@input="updateNewObjectKey"
/>
<select
v-if="!tableStructure"
@@ -1039,7 +1063,7 @@ function mapEntryStructure(
{{ structureTypeLabel(tableStructure.template) }}
</span>
<button type="submit" :disabled="disabled || !canAddTableField">
<Plus :size="13" />{{ labels.addField }}
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
</button>
</form>
</div>
+2
View File
@@ -400,6 +400,7 @@ function configuratorDescription(
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
addField: t('configurator.table.addField'),
addJob: t('configurator.table.addJob'),
addRow: t('configurator.table.addRow'),
configuredSecret: t('configurator.secretConfigured'),
convertToList: t('configurator.table.convertToList'),
@@ -409,6 +410,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
emptyTable: t('configurator.table.emptyTable'),
entry: t('configurator.table.entry'),
general: configuratorLocaleText('configurator.table.general', 'General'),
jobPlaceholder: t('configurator.table.jobPlaceholder'),
keyPlaceholder: t('configurator.table.keyPlaceholder'),
list: t('configurator.table.list'),
remove: t('configurator.table.remove'),
@@ -24,4 +24,13 @@ describe('EasyShareSheet Sky UI contract', () => {
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
)
})
it('only exposes and opens installed share destinations', () => {
expect(source).toContain("if (appStore.isInstalled('flare'))")
expect(source).toContain("if (appStore.isInstalled('darkchat'))")
expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
expect(source).toContain('if (!appStore.isInstalled(kind)) return')
expect(source).toContain('if (!appStore.isInstalled(appId)) return')
expect(source).not.toContain('appStore.homeLayout.hidden.includes')
})
})
+6 -3
View File
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
}> = []
const phoneNumbers = new Set<string>()
if (!appStore.homeLayout.hidden.includes('flare')) {
if (appStore.isInstalled('flare')) {
for (const match of flare.matches) {
people.push({
avatar: match.profile.photoUrls[0],
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
phoneNumbers.add(contact.phone_number)
}
if (!appStore.homeLayout.hidden.includes('darkchat')) {
if (appStore.isInstalled('darkchat')) {
for (const conversation of darkChat.conversations.slice(0, 8)) {
people.push({
kind: 'darkchat',
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
})
const shareApps = computed(() =>
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
.filter((id) => !appStore.homeLayout.hidden.includes(id))
.filter((id) => appStore.isInstalled(id))
.flatMap((id) => {
const app = getPhoneApp(id)
return app ? [{ app, id }] : []
@@ -195,18 +195,21 @@ function endDrag(event: PointerEvent): void {
}
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind, targetId)) return
close()
void router.push(`/apps/${kind}`)
}
function openChatApp(kind: EasyShareChatApp): void {
if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind)) return
close()
void router.push(`/apps/${kind}`)
}
function openShareApp(appId: EasyShareDestinationApp): void {
if (!appStore.isInstalled(appId)) return
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
@@ -0,0 +1,85 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const resourceSource = (path: string): string =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const configSource = resourceSource('config/config.lua')
const configuratorSource = resourceSource(
'source/server/phone_configurator.lua',
)
const radioSource = resourceSource('source/server/radio.lua')
const editorSource = readFileSync(
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
'utf8',
)
describe('radio configurator access contract', () => {
it('lets the server allow everyone or enforce configured job grades', () => {
expect(configSource).toMatch(
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
)
const start = radioSource.indexOf(
'local function can_set_display_name(source)',
)
const end = radioSource.indexOf(
'\nlocal function normalize_display_name',
start,
)
const permissionSource = radioSource.slice(start, end)
expect(permissionSource).toContain('if config.AllowEveryone == true then')
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
)
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
})
it('keeps both radio job tables mutable and validates their value types', () => {
expect(configuratorSource).toContain(
'path == "Radio.DisplayName.AllowedJobs"',
)
expect(configuratorSource).toContain(
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
)
expect(configuratorSource).toContain(
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
)
expect(configuratorSource).toMatch(
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
)
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
})
it('shows a compact job-name input for both radio job tables', () => {
expect(editorSource).toContain(
"props.path === 'Radio.DisplayName.AllowedJobs'",
)
expect(editorSource).toContain(
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
)
expect(editorSource).toContain(
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
)
expect(editorSource).toContain(
'isJobTable ? labels.addJob : labels.addField',
)
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
expect(editorSource).toContain('.toLowerCase()')
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
expect(editorSource).toContain('.slice(0, 64)')
for (const locale of ['en', 'de', 'es']) {
const localeSource = resourceSource(`config/locales/${locale}.lua`)
expect(localeSource).toContain(
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
)
expect(localeSource).toContain(
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
)
}
})
})
+17
View File
@@ -264,6 +264,23 @@ describe('app store', () => {
expect(apps.homeLayout.hidden).not.toContain('snake')
})
it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
mocks.phone.saveDeviceNamespace.mockClear()
for (const appId of ['banking', 'picstagram'] as const) {
expect(apps.isInstalled(appId)).toBe(true)
expect(apps.uninstallApp(appId)).toBe(true)
expect(apps.isInstalled(appId)).toBe(false)
expect(apps.claimedApps).not.toContain(appId)
expect(apps.uninstalledApps).toContain(appId)
expect(apps.homeLayout.hidden).toContain(appId)
}
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
})
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
const apps = useAppStoreStore()
+3
View File
@@ -965,10 +965,12 @@ const adminPanelFallbackLocales = {
},
addRow: 'Add row',
addField: 'Add field',
addJob: 'Add job',
remove: 'Remove',
emptyList: 'No rows yet. Add the first row with plus.',
emptyTable: 'No fields yet. Add the first key below.',
keyPlaceholder: 'New key',
jobPlaceholder: 'Job name',
convertToList: 'Use list',
convertToMap: 'Use typed key table',
convertToTable: 'Use key table',
@@ -3027,6 +3029,7 @@ const defaultLocales: LocaleTree = {
uninstallTitle: 'Uninstall this app?',
uninstallBody:
'{app} will be removed from this phone. You can download it again from the App Store.',
uninstallFailed: 'The app could not be uninstalled. Please try again.',
},
details: {
skyStudios: 'Sky Studios',
+3 -2
View File
@@ -1265,8 +1265,9 @@ label.sky-list-item__row {
opacity: 0;
}
.sky-field--floating-label .sky-field__select option {
color: var(--sky-text, #000000);
.sky-field__select option {
background: var(--sky-native-select-option-background, #ffffff);
color: var(--sky-native-select-option-text, #000000);
}
.sky-field--floating-label.sky-field--outline:not(
+17
View File
@@ -164,6 +164,23 @@ describe('SkyField', () => {
)
})
it('keeps native select options readable on the CEF popup surface', () => {
const controls = readFileSync(
new URL('../controls.css', import.meta.url),
'utf8',
)
const tokens = readFileSync(
new URL('../tokens.css', import.meta.url),
'utf8',
)
expect(controls).toMatch(
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
)
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
})
it('raises floating labels only after the field has a value', async () => {
const emptyApp = createSSRApp(SkyField, {
floatingLabel: true,
+2
View File
@@ -188,6 +188,8 @@
--sky-hairline: rgba(0, 0, 0, 0.2);
--sky-field-outline: rgba(0, 0, 0, 0.3);
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
--sky-native-select-option-background: #ffffff;
--sky-native-select-option-text: #000000;
--sky-pressed: rgba(0, 0, 0, 0.1);
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
@@ -111,4 +111,36 @@ describe('admin configurator defaults', () => {
false,
)
})
it('uses the configured access value when adding radio jobs', () => {
const channelJobs: AdminConfiguratorStructure = {
entryDefault: true,
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
const displayNameJobs: AdminConfiguratorStructure = {
entryDefault: 0,
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'number' },
}
expect(
createMutableTableEntry(
channelJobs,
'Radio.LockedChannels[1].jobs',
'mechanic',
),
).toBe(true)
expect(
createMutableTableEntry(
displayNameJobs,
'Radio.DisplayName.AllowedJobs',
'mechanic',
),
).toBe(0)
})
})
@@ -101,6 +101,10 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain(
'appStore.uninstallApp(uninstallCandidate.value.id)',
)
expect(source).toContain('@click.stop="requestUninstall(app)"')
expect(source).toContain('if (!appStore.uninstallApp(')
expect(source).toContain('Apps.appStore.account.uninstallFailed')
expect(source).toContain('role="alert"')
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
expect(source).toContain('class="store-account__grabber"')
+32 -6
View File
@@ -63,6 +63,7 @@ const openedAt = new Date()
const featuredSlide = ref(0)
const profileOpened = ref(false)
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
const uninstallError = ref('')
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
const storeScroll = ref<ComponentPublicInstance | null>(null)
const featuredScroller = ref<HTMLElement | null>(null)
@@ -288,6 +289,16 @@ function closeProfile(): void {
profileDragOffset.value = 0
}
function requestUninstall(app: LaunchablePhoneAppDefinition): void {
uninstallError.value = ''
uninstallCandidate.value = app
}
function closeUninstallDialog(): void {
uninstallError.value = ''
uninstallCandidate.value = null
}
function beginProfileDrag(event: PointerEvent): void {
if (!profileOpened.value || event.button !== 0) return
profileDragPointerId = event.pointerId
@@ -318,8 +329,11 @@ function endProfileDrag(event: PointerEvent): void {
function confirmUninstall(): void {
if (!uninstallCandidate.value) return
appStore.uninstallApp(uninstallCandidate.value.id)
uninstallCandidate.value = null
if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
return
}
closeUninstallDialog()
}
function highlightStyle(index: number): Record<string, string> {
@@ -1107,7 +1121,7 @@ watch(
app: getPhoneAppLabel(app, phone.t),
})
"
@click="uninstallCandidate = app"
@click.stop="requestUninstall(app)"
>
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
</button>
@@ -1119,8 +1133,8 @@ watch(
<SkyDialog
:opened="Boolean(uninstallCandidate)"
role="alertdialog"
@backdropclick="uninstallCandidate = null"
@escape="uninstallCandidate = null"
@backdropclick="closeUninstallDialog"
@escape="closeUninstallDialog"
>
<template #title>
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
@@ -1132,8 +1146,15 @@ watch(
})
}}
</p>
<p
v-if="uninstallError"
class="store-account__uninstall-error"
role="alert"
>
{{ uninstallError }}
</p>
<template #buttons>
<SkyDialogButton @click="uninstallCandidate = null">
<SkyDialogButton @click="closeUninstallDialog">
{{ phone.t('Common.cancel') }}
</SkyDialogButton>
<SkyDialogButton strong @click="confirmUninstall">
@@ -1476,6 +1497,11 @@ watch(
background: var(--sky-danger-soft);
}
.store-account__uninstall-error {
color: var(--sky-danger);
font-size: 12px;
}
.store-scroll {
min-height: 0;
flex: 1 1 auto;
+2 -1
View File
@@ -216,8 +216,9 @@ Config.Radio = {
AutoRejoin = false,
DisplayName = {
Enabled = true,
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
MaxLength = 32,
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
police = 0,
sheriff = 0,
fib = 0,
+4 -1
View File
@@ -2022,10 +2022,11 @@ Locales["de"] = {
},
account = {
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
apps = "Installation von Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
uninstallTitle = "Diese App deinstallieren?",
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
uninstallFailed = "Die App konnte nicht deinstalliert werden. Bitte versuche es erneut.",
},
details = {
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
@@ -2140,6 +2141,8 @@ Locales["de"] = {
},
}
Locales["de"].Nui.AdminPanel.configurator.table.addJob = "Job hinzufügen"
Locales["de"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Jobname"
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Animationsdateien",
Clips = "Clips",
+3
View File
@@ -2026,6 +2026,7 @@ Locales["en"] = {
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
uninstallTitle = "Uninstall this app?",
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
uninstallFailed = "The app could not be uninstalled. Please try again.",
},
details = {
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
@@ -2140,6 +2141,8 @@ Locales["en"] = {
},
}
Locales["en"].Nui.AdminPanel.configurator.table.addJob = "Add job"
Locales["en"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Job name"
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Dictionaries",
Clips = "Clips",
+3
View File
@@ -2026,6 +2026,7 @@ Locales["es"] = {
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
uninstallTitle = "¿Desinstalar esta aplicación?",
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
uninstallFailed = "No se ha podido desinstalar la aplicación. Inténtalo de nuevo.",
},
details = {
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
@@ -2140,6 +2141,8 @@ Locales["es"] = {
},
}
Locales["es"].Nui.AdminPanel.configurator.table.addJob = "Añadir trabajo"
Locales["es"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Nombre del trabajo"
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Diccionarios",
Clips = "Los clips",
+1 -1
View File
@@ -1,6 +1,6 @@
SkyPhoneFocus = {}
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_controls = { 24, 140, 141, 142, 199, 200, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
local hold_to_look_enabled = false
+31 -1
View File
@@ -274,12 +274,22 @@ local function upgrade_legacy_map(defaults, saved)
return { __skyType = "map", entries = entries }
end
local function radio_job_entry_default(path)
if path == "Radio.DisplayName.AllowedJobs" then
return 0
end
if type(path) == "string" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") then
return true
end
return nil
end
local function merge_values(defaults, saved, path, excluded_paths)
path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved)
end
if path == "Companies.Definitions" then
if path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil then
return copy_value(saved)
end
if defaults.__skyType == "map" and not saved.__skyType then
@@ -492,6 +502,16 @@ local function empty_structure(scope, path)
if scope ~= "config" then
return nil
end
local radio_job_default = radio_job_entry_default(path)
if radio_job_default ~= nil then
return {
entryDefault = radio_job_default,
fields = {},
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = type(radio_job_default) },
}
end
if path == "Garage.VehicleImages.ModelNames" then
return {
entries = {},
@@ -712,6 +732,16 @@ local function build_structure(value, scope, path)
for key, child in pairs(value) do
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
end
local radio_job_default = scope == "config" and radio_job_entry_default(path) or nil
if radio_job_default ~= nil then
return {
entryDefault = radio_job_default,
fields = fields,
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = type(radio_job_default) },
}
end
return {
fields = fields,
kind = "table",
+6
View File
@@ -50,8 +50,14 @@ local function can_set_display_name(source)
if type(config) ~= "table" or not config.Enabled then
return false
end
if config.AllowEveryone == true then
return true
end
local job = Bridge.Framework.GetJob(source)
if type(job) ~= "table" or type(job.name) ~= "string" then
return false
end
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
end
+2 -1
View File
@@ -201,8 +201,9 @@ Config.Radio = {
AutoRejoin = false,
DisplayName = {
Enabled = true,
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
MaxLength = 32,
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
police = 0,
sheriff = 0,
fib = 0,
+1 -1
View File
@@ -252,7 +252,7 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
all_controls_disabled = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(true)
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
for _, control in ipairs({ 24, 140, 141, 142, 199, 200, 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")