Files
Leon.Schmidt 312ae8a3a3 FIX - repair company actions and key rebindings (#32)
* FIX - repair company detail actions

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

* FIX - preserve phone key rebindings

Use the stable sky_phone_toggle command as the RegisterKeyMapping identifier. The previous revisioned command names detached FiveM's persisted keyboard settings from the active handler whenever the mapping identity changed.
2026-08-23 18:23:09 +02:00

583 lines
17 KiB
Lua

SkyPhoneClient = {}
local is_open = false
local open_requested = false
local open_without_focus = false
local device_payload = nil
local equipped_phone_number = nil
local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local admin_panel_open = false
local suggested_admin_command = nil
local suggested_test_data_command = nil
local active_development_command = nil
local registered_development_commands = {}
local phone_key_mapping_registered = false
local refresh_development_command
local refresh_phone_key_mapping
local refresh_test_data_command_suggestion
local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then
return nil
end
return device_payload.device.sim.number
end
local function set_equipped_phone_number(next_number)
if next_number ~= nil and type(next_number) ~= "string" then
next_number = nil
end
if next_number == equipped_phone_number then
return
end
equipped_phone_number = next_number
TriggerEvent("sky_phone:client:phoneNumberChanged", next_number)
end
local function update_equipped_phone_number(data)
set_equipped_phone_number(type(data) == "table"
and type(data.device) == "table"
and type(data.device.sim) == "table"
and data.device.sim.number
or nil)
end
local function get_authoritative_phone_number()
local result = Bridge.Callbacks.Trigger("sky_phone:device:equipped-number", {})
if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then
return nil
end
local phone_number = result.data.phoneNumber
set_equipped_phone_number(phone_number)
return phone_number
end
local function get_phone_state()
return {
inCall = SkyPhoneCalls.IsActive(),
onScreen = is_open,
open = is_open,
phoneNumber = get_equipped_phone_number(),
}
end
SkyPhoneClient.GetState = get_phone_state
SkyPhoneClient.GetEquippedPhoneNumber = get_authoritative_phone_number
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function apply_disabled_apps(payload)
if type(payload) ~= "table" then
return
end
local disabled = {}
for app_id, enabled in pairs(Config.Apps or {}) do
if type(app_id) == "string" and enabled == false then
disabled[#disabled + 1] = app_id
end
end
table.sort(disabled)
payload.disabledApps = disabled
end
local function send_admin_panel_open()
SendNUIMessage({
type = "admin:open",
data = {
lang = locale_name,
locales = locale.Nui,
fallbackLocales = Locales.en.Nui,
},
})
end
local function close_admin_panel()
if not admin_panel_open then
return
end
admin_panel_open = false
SkyPhoneFocus.SetAdminPanel(false)
SendNUIMessage({ type = "admin:close" })
end
local function send_open_message()
if not device_payload then
return
end
local payload = device_payload
payload.lang = locale_name
payload.locales = locale.Nui
payload.fallbackLocales = Locales.en.Nui
SkyPhoneApps.SendCatalog()
SendNUIMessage({
type = "app:open",
data = payload,
openHome = open_home_requested,
})
open_home_requested = false
end
local function refresh_admin_command_suggestion()
if suggested_admin_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command)
suggested_admin_command = nil
end
if Config.AdminPanel.Enabled then
suggested_admin_command = Config.AdminPanel.Command
TriggerEvent(
"chat:addSuggestion",
"/" .. suggested_admin_command,
locale.AdminCommand.CommandDescription
)
end
end
AddEventHandler("sky_phone:configurator:updated", function()
locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
refresh_development_command()
refresh_phone_key_mapping()
refresh_test_data_command_suggestion()
refresh_admin_command_suggestion()
SkyPhoneApps.SendCatalog()
if is_open and device_payload then
apply_disabled_apps(device_payload)
device_payload.lang = locale_name
device_payload.locales = locale.Nui
device_payload.fallbackLocales = Locales.en.Nui
SendNUIMessage({ type = "device:updated", data = device_payload })
end
if admin_panel_open then
send_admin_panel_open()
end
end)
local function open_phone()
if is_open or not device_payload then
return
end
send_open_message()
end
local function close_phone(close_device_session)
local was_requested = open_requested
local was_open = is_open
open_requested = false
open_without_focus = false
TriggerEvent("sky_phone:animation:phone", false)
is_open = false
if was_open then
SkyPhoneApps.SetPhoneOpen(false)
TriggerEvent("sky_phone:nuiClosed")
TriggerEvent("sky_phone:client:phoneToggled", false)
end
SkyPhoneFocus.SetPhone(false)
if was_requested or was_open then
SendNUIMessage({ type = "app:close" })
if close_device_session ~= false then
Bridge.Callbacks.Trigger("sky_phone:device:close", {})
end
end
end
local function request_phone_open(callback_name)
if is_open or open_requested then
return true
end
open_requested = true
local result = Bridge.Callbacks.Trigger(callback_name, {})
if type(result) == "table" and result.success == true then
return true
end
open_requested = false
open_without_focus = false
return false
end
local function toggle_phone(open, no_focus)
if open ~= nil and type(open) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
return false
end
if no_focus ~= nil and type(no_focus) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle focus state.")
return false
end
local should_open = open
if should_open == nil then
should_open = not (is_open or open_requested)
end
if not should_open then
if open == nil and open_requested and not is_open then
return true
end
close_phone()
return true
end
open_without_focus = no_focus == true
if is_open or open_requested then
if is_open then
SkyPhoneFocus.SetPhone(true, open_without_focus)
end
return true
end
return request_phone_open("sky_phone:device:open-request")
end
SkyPhoneClient.Toggle = toggle_phone
AddEventHandler("sky_phone:client:forceClose", function()
close_phone()
end)
local function run_development_command()
if is_open then
close_phone()
return
end
if open_requested then
return
end
open_without_focus = false
request_phone_open("sky_phone:device:development-open")
end
refresh_development_command = function()
local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil
if command_name ~= nil and (type(command_name) ~= "string" or command_name == "") then
error("[sky_phone] Config.Command must be a non-empty command name.")
end
if active_development_command then
TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)
end
active_development_command = command_name
if not command_name then
return
end
if not registered_development_commands[command_name] then
registered_development_commands[command_name] = true
RegisterCommand(command_name, function()
if active_development_command == command_name and Config.Phone.DevelopmentCommand then
run_development_command()
end
end, false)
end
TriggerEvent("chat:addSuggestion", "/" .. command_name, locale.CommandDescription)
end
refresh_development_command()
local function run_phone_toggle()
if is_open then
close_phone()
return
end
if open_requested then
return
end
open_without_focus = false
request_phone_open("sky_phone:device:open-request")
end
RegisterCommand("sky_phone_live_activity_open", function()
if not live_activity_active or is_open or open_requested then
return
end
open_home_requested = true
if not request_phone_open("sky_phone:device:open-request") then
open_home_requested = false
end
end, false)
RegisterCommand("sky_phone_toggle", function()
if not Config.Phone.Keybind then
return
end
run_phone_toggle()
end, false)
refresh_phone_key_mapping = function()
local key_name = Config.Phone.Keybind
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end
if phone_key_mapping_registered or not key_name then
return
end
-- FiveM persists player rebindings by command name, so this identifier must remain stable.
phone_key_mapping_registered = true
RegisterKeyMapping(
"sky_phone_toggle",
locale.Controls.OpenPhone,
"keyboard",
key_name
)
end
refresh_phone_key_mapping()
RegisterKeyMapping(
"sky_phone_live_activity_open",
locale.Controls.OpenPhone,
"keyboard",
"SPACE"
)
refresh_test_data_command_suggestion = function()
if suggested_test_data_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command)
suggested_test_data_command = nil
end
if Config.TestData.Enabled then
suggested_test_data_command = Config.TestData.Command
TriggerEvent("chat:addSuggestion", "/" .. suggested_test_data_command, locale.TestData.CommandDescription)
end
end
refresh_test_data_command_suggestion()
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
local test_data_locale = locale.TestData
local message = success and test_data_locale.Success or test_data_locale.Failed
if success and type(detail) == "string" and detail ~= "" then
message = message:gsub("{email}", detail)
end
Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000)
end)
RegisterNUICallback("ui:ready", function(data, cb)
if type(data) ~= "table" or data.protocolVersion ~= 1 then
cb({ success = false, error = "unsupported_protocol" })
return
end
nui_generation = nui_generation + 1
-- Browser state is recreated on a CEF reload. Browser-owned focus claims
-- cannot survive unless their UI is replayed as part of this handshake.
SkyPhoneFocus.BeginNuiHydration()
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
if admin_panel_open then
send_admin_panel_open()
end
SkyPhoneCalls.ReplayNui()
SkyPhoneSimPicker.ReplayNui()
SkyPhoneFocus.Reapply()
TriggerEvent("sky_phone:client:nuiReady", {
generation = nui_generation,
protocolVersion = 1,
})
cb({
success = true,
data = {
generation = nui_generation,
protocolVersion = 1,
},
})
end)
RegisterNUICallback("ui:opened", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if not open_requested or not device_payload then
Bridge.Debug(
"warn",
"[sky_phone] Ignored a NUI open confirmation without a pending device open.",
{ always = true }
)
SendNUIMessage({ type = "app:close" })
SkyPhoneFocus.Reapply()
cb({ success = false, error = "open_not_requested" })
return
end
local was_open = is_open
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
if not was_open then
TriggerEvent("sky_phone:client:phoneToggled", true)
end
SkyPhoneFocus.SetPhone(true, open_without_focus)
TriggerEvent("sky_phone:animation:phone", true)
cb({ success = true })
end)
RegisterNetEvent("sky_phone:admin:launch", function()
if is_open or open_requested then
close_phone()
end
admin_panel_open = true
SkyPhoneFocus.SetAdminPanel(true)
send_admin_panel_open()
end)
RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
local messages = locale.AdminCommand.Errors
Bridge.Framework.Notify(
"iFruit",
messages[error_code] or messages.default,
"error",
5000
)
end)
RegisterNUICallback("admin:close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
close_admin_panel()
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
SkyPhoneFocus.SetTextInputFocused(
data.active and (is_open or open_requested or admin_panel_open)
)
cb({ success = true })
end)
RegisterNUICallback("ui:live-activity", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
live_activity_active = data.active
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
close_phone()
cb({ success = true })
end)
RegisterNetEvent("sky_phone:device:open", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
if not is_open then
open_requested = false
open_without_focus = false
end
return
end
Bridge.Debug(
"debug",
"[sky_phone] Client received device open for IMEI %s account_linked=%s.",
tostring(data.device.imei),
tostring(data.account ~= nil),
{ always = true }
)
apply_disabled_apps(data)
device_payload = data
update_equipped_phone_number(data)
open_requested = true
if is_open then
SkyPhoneApps.SendCatalog()
SendNUIMessage({ type = "device:updated", data = data })
return
end
open_phone()
end)
RegisterNetEvent("sky_phone:device:updated", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
return
end
apply_disabled_apps(data)
device_payload = data
update_equipped_phone_number(data)
SendNUIMessage({ type = "device:updated", data = data })
end)
RegisterNetEvent("sky_phone:device:invalidated", function()
TriggerEvent("sky_phone:animation:reset")
close_phone(false)
device_payload = nil
update_equipped_phone_number(nil)
live_activity_active = false
open_home_requested = false
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
if not is_open then
open_requested = false
open_without_focus = false
open_home_requested = false
end
Bridge.Debug(
"debug",
"[sky_phone] Client received device error: %s.",
tostring(error_code),
{ always = true }
)
local message = locale.DeviceErrors[error_code] or locale.DeviceErrors.default
Bridge.Framework.Notify("iFruit", message, "error", 5000)
end)
CreateThread(function()
refresh_admin_command_suggestion()
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
is_open = false
open_requested = false
open_without_focus = false
admin_panel_open = false
TriggerEvent("sky_phone:animation:reset")
SkyPhoneCalls.Reset()
SkyPhoneSimPicker.Reset()
SkyPhoneFocus.Reset()
if active_development_command then
TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)
end
if suggested_test_data_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command)
end
if suggested_admin_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command)
end
end)