mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 15:01:35 +00:00
312ae8a3a3
* 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.
583 lines
17 KiB
Lua
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)
|