mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 07:01:39 +00:00
e58f1b6c18
Resolve configured and regional locales through a centralized English fallback for Lua and NUI consumers. Complete the German and English app translation keys, localize remaining picker and attachment copy, and add contract tests that detect missing locale entries.
1134 lines
46 KiB
Lua
1134 lines
46 KiB
Lua
SkyPhoneEasyShare = {}
|
|
|
|
Bridge.Database.AfterMigration("sky_phone", function()
|
|
local active_transfers = {}
|
|
local valid_kinds = {
|
|
contact = true,
|
|
document = true,
|
|
link = true,
|
|
location = true,
|
|
media = true,
|
|
note = true,
|
|
photo = true,
|
|
playlist = true,
|
|
post = true,
|
|
profile = true,
|
|
text = true,
|
|
track = true,
|
|
video = true,
|
|
}
|
|
local valid_apps = {
|
|
calendar = true,
|
|
camera = true,
|
|
citymarkt = true,
|
|
companies = true,
|
|
crewlink = true,
|
|
darkchat = true,
|
|
feather = true,
|
|
flare = true,
|
|
fliptok = true,
|
|
house = true,
|
|
["local-pages"] = true,
|
|
mail = true,
|
|
map = true,
|
|
messages = true,
|
|
music = true,
|
|
notes = true,
|
|
phone = true,
|
|
photos = true,
|
|
picstagram = true,
|
|
}
|
|
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
|
|
|
|
Bridge.Database.Query([[
|
|
UPDATE `sky_phone_easyshare_transfers`
|
|
SET `status` = 'expired', `updated_at` = CURRENT_TIMESTAMP, `completed_at` = CURRENT_TIMESTAMP
|
|
WHERE `status` IN ('pending', 'transferring')
|
|
]], {})
|
|
|
|
local function uuid()
|
|
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
|
local id = rows[1] and rows[1].id
|
|
if type(id) ~= "string" then
|
|
error("[sky_phone] Database did not generate an EasyShare id.")
|
|
end
|
|
return id
|
|
end
|
|
|
|
local rich_note_prefix = "sky-note-html-v1:"
|
|
|
|
local function plain_note_body(body)
|
|
if body:sub(1, #rich_note_prefix) ~= rich_note_prefix then
|
|
return body
|
|
end
|
|
|
|
return body:sub(#rich_note_prefix + 1)
|
|
:gsub("<[bB][rR]%s*/?>", "\n")
|
|
:gsub("</[pP]%s*>", "\n")
|
|
:gsub("</[hH][1-6]%s*>", "\n")
|
|
:gsub("<[lL][iI][^>]*>", "- ")
|
|
:gsub("</[lL][iI]%s*>", "\n")
|
|
:gsub("</[bB][lL][oO][cC][kK][qQ][uU][oO][tT][eE]%s*>", "\n")
|
|
:gsub("<[^>]*>", "")
|
|
:gsub(" ", " ")
|
|
:gsub("<", "<")
|
|
:gsub(">", ">")
|
|
:gsub(""", '"')
|
|
:gsub("'", "'")
|
|
:gsub("&", "&")
|
|
:gsub("[ \t]+\n", "\n")
|
|
:gsub("\n\n\n+", "\n\n")
|
|
:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
local function trim(value, maximum)
|
|
if type(value) ~= "string" then
|
|
return nil
|
|
end
|
|
local result = value:match("^%s*(.-)%s*$")
|
|
if result == "" or #result > maximum then
|
|
return nil
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function current_device(source)
|
|
local session, error_response = SkyPhone.RequireSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
local device = SkyPhone.LoadDevice(session.imei)
|
|
if not device then
|
|
return nil, { success = false, error = "device_not_found" }
|
|
end
|
|
return device
|
|
end
|
|
|
|
local function owner_condition(device, alias)
|
|
local prefix = alias and ("`" .. alias .. "`.") or ""
|
|
if device.account_id then
|
|
return prefix .. "`account_id` = ?", { tonumber(device.account_id) }
|
|
end
|
|
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { device.imei }
|
|
end
|
|
|
|
local function append_params(target, values)
|
|
for _, value in ipairs(values) do
|
|
target[#target + 1] = value
|
|
end
|
|
end
|
|
|
|
local function first_row(query, params)
|
|
local rows = Bridge.Database.Query(query, params)
|
|
return rows[1]
|
|
end
|
|
|
|
local function canonical_profile(device, app_id, id)
|
|
if app_id == "picstagram" then
|
|
local profile = first_row([[
|
|
SELECT p.`display_name`, p.`handle`, p.`bio`, media.`url` AS `image_url`
|
|
FROM `sky_phone_picstagram_profiles` p
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = p.`avatar_media_id`
|
|
WHERE p.`id` = ? AND p.`status` = 'active'
|
|
AND (p.`private` = 0 OR EXISTS(
|
|
SELECT 1 FROM `sky_phone_picstagram_sessions` session
|
|
WHERE session.`device_imei` = ? AND session.`profile_id` = p.`id`
|
|
) OR EXISTS(
|
|
SELECT 1 FROM `sky_phone_picstagram_follows` follow
|
|
WHERE follow.`following_id` = p.`id` AND follow.`status` = 'accepted'
|
|
AND follow.`follower_id` = (
|
|
SELECT session.`profile_id` FROM `sky_phone_picstagram_sessions` session
|
|
WHERE session.`device_imei` = ? LIMIT 1
|
|
)
|
|
)) LIMIT 1
|
|
]], { id, device.imei, device.imei })
|
|
if profile then
|
|
return {
|
|
title = profile.display_name,
|
|
subtitle = "@" .. profile.handle,
|
|
copyText = "@" .. profile.handle,
|
|
imageUrl = profile.image_url,
|
|
link = "skyphone://picstagram/profile/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "feather" then
|
|
local profile = first_row([[
|
|
SELECT p.`display_name`, p.`handle`, media.`url` AS `image_url`
|
|
FROM `sky_phone_feather_profiles` p
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = p.`avatar_media_id`
|
|
WHERE p.`id` = ? LIMIT 1
|
|
]], { id })
|
|
if profile then
|
|
return {
|
|
title = profile.display_name,
|
|
subtitle = "@" .. profile.handle,
|
|
copyText = "@" .. profile.handle,
|
|
imageUrl = profile.image_url,
|
|
link = "skyphone://feather/profile/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "fliptok" then
|
|
local profile = first_row("SELECT `display_name`, `handle` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { id })
|
|
if profile then
|
|
return {
|
|
title = profile.display_name,
|
|
subtitle = "@" .. profile.handle,
|
|
copyText = "@" .. profile.handle,
|
|
link = "skyphone://fliptok/profile/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "flare" and device.account_id then
|
|
local profile = first_row([[
|
|
SELECT p.`name`, p.`age`, p.`bio`, media.`url` AS `image_url`
|
|
FROM `sky_phone_flare_profiles` p
|
|
LEFT JOIN `sky_phone_flare_profile_photos` photo ON photo.`profile_id` = p.`id` AND photo.`sort_order` = 1
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = photo.`media_id`
|
|
WHERE p.`id` = ? AND p.`account_id` = ? LIMIT 1
|
|
]], { id, tonumber(device.account_id) })
|
|
if profile then
|
|
local title = ("%s, %s"):format(profile.name, profile.age)
|
|
return {
|
|
title = title,
|
|
copyText = title .. "\n" .. profile.bio,
|
|
imageUrl = profile.image_url,
|
|
link = "skyphone://flare/profile/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "crewlink" and device.account_id then
|
|
local profile = first_row([[
|
|
SELECT p.`username`, g.`name` AS `group_name`
|
|
FROM `sky_phone_crewlink_profiles` p
|
|
LEFT JOIN `sky_phone_crewlink_groups` g ON g.`id` = p.`active_group_id`
|
|
WHERE p.`id` = ? AND p.`account_id` = ? LIMIT 1
|
|
]], { id, tonumber(device.account_id) })
|
|
if profile then
|
|
return {
|
|
title = "@" .. profile.username,
|
|
subtitle = profile.group_name,
|
|
copyText = "@" .. profile.username,
|
|
link = "skyphone://crewlink/profile/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "darkchat" and device.account_id then
|
|
local profile = first_row([[
|
|
SELECT `alias`, `dark_id`, `invite_code` FROM `sky_phone_darkchat_profiles`
|
|
WHERE `id` = ? AND `account_id` = ? LIMIT 1
|
|
]], { id, tonumber(device.account_id) })
|
|
if profile then
|
|
return {
|
|
title = profile.alias,
|
|
subtitle = profile.dark_id,
|
|
copyText = profile.alias .. "\n" .. profile.dark_id .. "\n" .. profile.invite_code,
|
|
link = "skyphone://darkchat/invite/" .. profile.invite_code,
|
|
}
|
|
end
|
|
elseif app_id == "companies" then
|
|
local definition = Config.Companies.Enabled and Config.Companies.Definitions[id] or nil
|
|
if definition and definition.Public then
|
|
local profile = first_row([[
|
|
SELECT profile.`description`, media.`url` AS `image_url`
|
|
FROM `sky_phone_company_profiles` profile
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = profile.`logo_media_id`
|
|
WHERE profile.`company_id` = ? LIMIT 1
|
|
]], { id })
|
|
return {
|
|
title = definition.Name,
|
|
subtitle = definition.ServiceLine.Number,
|
|
copyText = definition.Name .. "\n" .. (profile and profile.description or definition.Description),
|
|
imageUrl = profile and profile.image_url or nil,
|
|
link = "skyphone://companies/profile/" .. id,
|
|
}
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function canonical_crewlink_invite(device, invite_code)
|
|
if not device.account_id
|
|
or type(invite_code) ~= "string"
|
|
or #invite_code ~= 8
|
|
or invite_code:find("[^A-Z0-9]")
|
|
then
|
|
return nil
|
|
end
|
|
local group = first_row([[
|
|
SELECT g.`name`, g.`invite_code`
|
|
FROM `sky_phone_crewlink_profiles` p
|
|
JOIN `sky_phone_crewlink_memberships` m ON m.`profile_id` = p.`id`
|
|
JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id`
|
|
WHERE p.`account_id` = ? AND g.`invite_code` = ?
|
|
AND m.`role` IN ('owner', 'coordinator')
|
|
LIMIT 1
|
|
]], { tonumber(device.account_id), invite_code })
|
|
if not group then
|
|
return nil
|
|
end
|
|
local locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).Nui.Apps.crewlink
|
|
local title = locale.shareInviteTitle:gsub("{group}", function() return group.name end)
|
|
local copy_text = locale.shareInviteBody
|
|
:gsub("{code}", function() return group.invite_code end)
|
|
:gsub("{group}", function() return group.name end)
|
|
return {
|
|
title = title,
|
|
subtitle = group.invite_code,
|
|
copyText = copy_text,
|
|
link = "skyphone://crewlink/invite/" .. group.invite_code,
|
|
}
|
|
end
|
|
|
|
local function canonical_post(device, app_id, id)
|
|
if app_id == "picstagram" then
|
|
local post = first_row([[
|
|
SELECT post.`caption`, profile.`display_name`, profile.`handle`, media.`url` AS `image_url`
|
|
FROM `sky_phone_picstagram_posts` post
|
|
JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id`
|
|
LEFT JOIN `sky_phone_picstagram_post_media` post_media
|
|
ON post_media.`post_id` = post.`id` AND post_media.`position` = 1
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = post_media.`media_id`
|
|
WHERE post.`id` = ? AND post.`status` = 'published' AND profile.`status` = 'active'
|
|
AND (profile.`private` = 0 OR EXISTS(
|
|
SELECT 1 FROM `sky_phone_picstagram_sessions` session
|
|
WHERE session.`device_imei` = ? AND session.`profile_id` = profile.`id`
|
|
) OR EXISTS(
|
|
SELECT 1 FROM `sky_phone_picstagram_follows` follow
|
|
WHERE follow.`following_id` = profile.`id` AND follow.`status` = 'accepted'
|
|
AND follow.`follower_id` = (
|
|
SELECT session.`profile_id` FROM `sky_phone_picstagram_sessions` session
|
|
WHERE session.`device_imei` = ? LIMIT 1
|
|
)
|
|
)) LIMIT 1
|
|
]], { id, device.imei, device.imei })
|
|
if post then
|
|
local title = post.caption ~= "" and post.caption or post.display_name
|
|
return {
|
|
title = title,
|
|
subtitle = "@" .. post.handle,
|
|
copyText = "@" .. post.handle .. ": " .. post.caption,
|
|
imageUrl = post.image_url,
|
|
link = "skyphone://picstagram/post/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "feather" then
|
|
local post = first_row([[
|
|
SELECT post.`body`, profile.`handle`, media.`url` AS `image_url`
|
|
FROM `sky_phone_feather_posts` post
|
|
JOIN `sky_phone_feather_profiles` profile ON profile.`id` = post.`profile_id`
|
|
LEFT JOIN `sky_phone_feather_post_media` post_media
|
|
ON post_media.`post_id` = post.`id` AND post_media.`sort_order` = 0
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = post_media.`media_id`
|
|
WHERE post.`id` = ? AND post.`status` = 'published' LIMIT 1
|
|
]], { id })
|
|
if post then
|
|
return {
|
|
title = post.body,
|
|
subtitle = "@" .. post.handle,
|
|
copyText = "@" .. post.handle .. ": " .. post.body,
|
|
imageUrl = post.image_url,
|
|
link = "skyphone://feather/post/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "fliptok" then
|
|
local post = first_row([[
|
|
SELECT video.`caption`, profile.`display_name`, profile.`handle`, media.`url` AS `image_url`
|
|
FROM `sky_phone_fliptok_videos` video
|
|
JOIN `sky_phone_fliptok_profiles` profile ON profile.`id` = video.`profile_id`
|
|
JOIN `sky_phone_media` media ON media.`id` = video.`media_id`
|
|
WHERE video.`id` = ? AND video.`status` = 'published' AND (
|
|
video.`visibility` = 'public' OR EXISTS(
|
|
SELECT 1 FROM `sky_phone_fliptok_sessions` session
|
|
WHERE session.`device_imei` = ? AND session.`profile_id` = profile.`id`
|
|
) OR (video.`visibility` = 'followers' AND EXISTS(
|
|
SELECT 1 FROM `sky_phone_fliptok_follows` follow
|
|
WHERE follow.`following_id` = profile.`id` AND follow.`follower_id` = (
|
|
SELECT session.`profile_id` FROM `sky_phone_fliptok_sessions` session
|
|
WHERE session.`device_imei` = ? LIMIT 1
|
|
)
|
|
))
|
|
) LIMIT 1
|
|
]], { id, device.imei, device.imei })
|
|
if post then
|
|
local title = post.caption ~= "" and post.caption or post.display_name
|
|
return {
|
|
title = title,
|
|
subtitle = "@" .. post.handle,
|
|
copyText = "@" .. post.handle .. ": " .. post.caption,
|
|
imageUrl = post.image_url,
|
|
link = "skyphone://fliptok/video/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "local-pages" then
|
|
local post = first_row([[
|
|
SELECT post.`title`, post.`body`, SUBSTRING_INDEX(account.`email`, '@', 1) AS `author_name`,
|
|
media.`url` AS `image_url`
|
|
FROM `sky_phone_pages_posts` post
|
|
JOIN `sky_phone_accounts` account ON account.`id` = post.`account_id`
|
|
LEFT JOIN `sky_phone_pages_images` image ON image.`post_id` = post.`id` AND image.`sort_order` = 1
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = image.`media_id`
|
|
WHERE post.`id` = ? LIMIT 1
|
|
]], { id })
|
|
if post then
|
|
return {
|
|
title = post.title,
|
|
subtitle = post.author_name,
|
|
copyText = post.title .. "\n" .. post.body,
|
|
imageUrl = post.image_url,
|
|
link = "skyphone://local-pages/post/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "citymarkt" then
|
|
local post = first_row([[
|
|
SELECT listing.`title`, listing.`description`, listing.`price`, listing.`price_type`, media.`url` AS `image_url`
|
|
FROM `sky_phone_marketplace_listings` listing
|
|
LEFT JOIN `sky_phone_marketplace_images` image
|
|
ON image.`listing_id` = listing.`id` AND image.`sort_order` = 1
|
|
LEFT JOIN `sky_phone_media` media ON media.`id` = image.`media_id`
|
|
WHERE listing.`id` = ? AND listing.`status` IN ('active', 'reserved') LIMIT 1
|
|
]], { id })
|
|
if post then
|
|
local price = post.price_type == "fixed" and tostring(post.price) or post.price_type
|
|
return {
|
|
title = post.title,
|
|
subtitle = price,
|
|
copyText = post.title .. "\n" .. post.description,
|
|
imageUrl = post.image_url,
|
|
link = "skyphone://citymarkt/listing/" .. id,
|
|
}
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function canonical_music(device, data)
|
|
if data.kind == "track" and type(data.meta) == "table" and data.meta.source == "server" then
|
|
for _, track in ipairs(Config.Music.Tracks) do
|
|
if track.Id == data.id then
|
|
return {
|
|
title = track.Title,
|
|
subtitle = track.Artist,
|
|
copyText = track.Title .. " — " .. track.Artist,
|
|
link = "skyphone://music/server/" .. track.Id,
|
|
meta = { source = "server" },
|
|
}
|
|
end
|
|
end
|
|
elseif data.kind == "track" and type(data.meta) == "table" and data.meta.source == "youtube" then
|
|
local condition, params = owner_condition(device, "song")
|
|
local query_params = { data.id }
|
|
append_params(query_params, params)
|
|
local track = first_row(([=[
|
|
SELECT song.`title`, song.`artist`, song.`video_id`
|
|
FROM `sky_phone_music_youtube_songs` song
|
|
WHERE song.`id` = ? AND %s LIMIT 1
|
|
]=]):format(condition), query_params)
|
|
if track then
|
|
return {
|
|
title = track.title,
|
|
subtitle = track.artist,
|
|
copyText = track.title .. " — " .. track.artist,
|
|
imageUrl = "https://i.ytimg.com/vi/" .. track.video_id .. "/hqdefault.jpg",
|
|
link = "skyphone://music/youtube/" .. data.id,
|
|
meta = { source = "youtube" },
|
|
}
|
|
end
|
|
elseif data.kind == "playlist" then
|
|
local condition, params = owner_condition(device, "playlist")
|
|
local query_params = { data.id }
|
|
append_params(query_params, params)
|
|
local playlist = first_row(([=[
|
|
SELECT playlist.`name`, COUNT(item.`id`) AS `song_count`
|
|
FROM `sky_phone_music_playlists` playlist
|
|
LEFT JOIN `sky_phone_music_playlist_items` item ON item.`playlist_id` = playlist.`id`
|
|
WHERE playlist.`id` = ? AND %s
|
|
GROUP BY playlist.`id`, playlist.`name` LIMIT 1
|
|
]=]):format(condition), query_params)
|
|
if playlist then
|
|
return {
|
|
title = playlist.name,
|
|
subtitle = tostring(playlist.song_count) .. " tracks",
|
|
copyText = playlist.name .. " · " .. tostring(playlist.song_count),
|
|
link = "skyphone://music/playlist/" .. data.id,
|
|
}
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function canonical_document(source, device, app_id, id)
|
|
if app_id == "calendar" and device.account_id then
|
|
local event = first_row([[
|
|
SELECT `title`, `note`, `starts_at`, `ends_at` FROM `sky_phone_calendar_events`
|
|
WHERE `id` = ? AND `account_id` = ? LIMIT 1
|
|
]], { id, tonumber(device.account_id) })
|
|
if event then
|
|
return {
|
|
title = event.title,
|
|
subtitle = tostring(event.starts_at),
|
|
copyText = event.title .. "\n" .. event.note,
|
|
link = "skyphone://calendar/event/" .. id,
|
|
meta = { startAt = tostring(event.starts_at), endAt = tostring(event.ends_at) },
|
|
}
|
|
end
|
|
elseif app_id == "mail" and device.account_id then
|
|
local message = first_row([[
|
|
SELECT message.`subject`, message.`body`, sender.`email` AS `sender_email`
|
|
FROM `sky_phone_mail_entries` entry
|
|
JOIN `sky_phone_mail_messages` message ON message.`id` = entry.`message_id`
|
|
LEFT JOIN `sky_phone_accounts` sender ON sender.`id` = message.`sender_account_id`
|
|
WHERE entry.`id` = ? AND entry.`account_id` = ? AND entry.`trashed_at` IS NULL LIMIT 1
|
|
]], { id, tonumber(device.account_id) })
|
|
if message then
|
|
return {
|
|
title = message.subject,
|
|
subtitle = message.sender_email,
|
|
copyText = message.subject .. "\n" .. message.body,
|
|
link = "skyphone://mail/message/" .. id,
|
|
}
|
|
end
|
|
elseif app_id == "house" then
|
|
return SkyPhoneHousing.ResolveShare(source, id)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function canonical_text(device, app_id, id)
|
|
if app_id ~= "darkchat" or not device.account_id then
|
|
return nil
|
|
end
|
|
local message = first_row([[
|
|
SELECT message.`body`, message.`message_type`, sender.`alias` AS `sender_alias`
|
|
FROM `sky_phone_darkchat_messages` message
|
|
JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id`
|
|
JOIN `sky_phone_darkchat_profiles` viewer ON viewer.`id` = member.`profile_id`
|
|
LEFT JOIN `sky_phone_darkchat_profiles` sender ON sender.`id` = message.`sender_profile_id`
|
|
WHERE message.`id` = ? AND viewer.`account_id` = ? AND message.`deleted_for_everyone` = 0
|
|
AND message.`message_type` IN ('text', 'emoji') LIMIT 1
|
|
]], { id, tonumber(device.account_id) })
|
|
if not message then
|
|
return nil
|
|
end
|
|
return {
|
|
title = message.body,
|
|
subtitle = message.sender_alias,
|
|
copyText = message.body,
|
|
}
|
|
end
|
|
|
|
local function display_name(source)
|
|
local first = trim(Bridge.Framework.GetFirstname(source), 80)
|
|
local last = trim(Bridge.Framework.GetLastname(source), 80)
|
|
local name = table.concat({ first or "", last or "" }, " "):match("^%s*(.-)%s*$")
|
|
return name ~= "" and name or GetPlayerName(source) or ("Player %s"):format(source)
|
|
end
|
|
|
|
local function visibility_for(imei)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `visibility` FROM `sky_phone_easyshare_preferences`
|
|
WHERE `device_imei` = ? LIMIT 1
|
|
]], { imei })
|
|
local visibility = rows[1] and rows[1].visibility or Config.EasyShare.DefaultVisibility
|
|
return valid_visibilities[visibility] and visibility or "hidden"
|
|
end
|
|
|
|
local function distance_between(left_source, right_source)
|
|
if GetPlayerRoutingBucket(left_source) ~= GetPlayerRoutingBucket(right_source) then
|
|
return nil
|
|
end
|
|
local left_ped = GetPlayerPed(left_source)
|
|
local right_ped = GetPlayerPed(right_source)
|
|
if left_ped == 0 or right_ped == 0 then
|
|
return nil
|
|
end
|
|
return #(GetEntityCoords(left_ped) - GetEntityCoords(right_ped))
|
|
end
|
|
|
|
local function visible_to(target_device, sender_device, visibility)
|
|
if visibility == "everyone" then
|
|
return true
|
|
end
|
|
if visibility ~= "contacts" or not sender_device.phone_number then
|
|
return false
|
|
end
|
|
local condition, owner_params = owner_condition(target_device)
|
|
local params = { sender_device.phone_number }
|
|
append_params(params, owner_params)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `id` FROM `sky_phone_contacts`
|
|
WHERE `phone_number` = ? AND %s LIMIT 1
|
|
]]):format(condition), params)
|
|
return rows[1] ~= nil
|
|
end
|
|
|
|
local function target_state(sender_source, target_source, sender_device)
|
|
if target_source == sender_source then
|
|
return nil
|
|
end
|
|
local target_device = current_device(target_source)
|
|
if not target_device then
|
|
return nil
|
|
end
|
|
local visibility = visibility_for(target_device.imei)
|
|
if not visible_to(target_device, sender_device, visibility) then
|
|
return nil
|
|
end
|
|
local distance = distance_between(sender_source, target_source)
|
|
if not distance or distance > Config.EasyShare.MaximumDistance then
|
|
return nil
|
|
end
|
|
return { device = target_device, distance = distance }
|
|
end
|
|
|
|
local function nearby_targets(source, device)
|
|
local targets = {}
|
|
for _, target in ipairs(Bridge.Framework.GetPlayers()) do
|
|
local target_source = tonumber(target)
|
|
if target_source then
|
|
local state = target_state(source, target_source, device)
|
|
if state then
|
|
targets[#targets + 1] = {
|
|
id = target_source,
|
|
name = display_name(target_source),
|
|
distance = math.floor(state.distance * 10 + 0.5) / 10,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
table.sort(targets, function(left, right)
|
|
return left.distance < right.distance
|
|
end)
|
|
return targets
|
|
end
|
|
|
|
local function canonical_own_contact(source, device)
|
|
if not device.phone_number then
|
|
return nil
|
|
end
|
|
local name = display_name(source)
|
|
return {
|
|
appId = "phone",
|
|
kind = "contact",
|
|
id = "self",
|
|
title = name,
|
|
subtitle = device.phone_number,
|
|
copyText = ("%s\n%s"):format(name, device.phone_number),
|
|
meta = {
|
|
name = name,
|
|
phoneNumber = device.phone_number,
|
|
},
|
|
}
|
|
end
|
|
|
|
local function sanitize_payload(source, device, data)
|
|
if type(data) ~= "table" or not valid_kinds[data.kind] then
|
|
return nil, "invalid_payload"
|
|
end
|
|
local title = trim(data.title, 160)
|
|
local copy_text = trim(data.copyText, 4000)
|
|
local app_id = trim(data.appId, 48)
|
|
if not title or not copy_text or not app_id or not valid_apps[app_id] then
|
|
return nil, "invalid_payload"
|
|
end
|
|
local payload = {
|
|
appId = app_id,
|
|
kind = data.kind,
|
|
title = title,
|
|
copyText = copy_text,
|
|
}
|
|
if type(data.id) == "string" and #data.id <= 128 then
|
|
payload.id = data.id
|
|
elseif type(data.id) == "number" and data.id > 0 and data.id == math.floor(data.id) then
|
|
payload.id = data.id
|
|
end
|
|
if type(data.subtitle) == "string" and #data.subtitle <= 240 then
|
|
payload.subtitle = data.subtitle
|
|
end
|
|
if type(data.link) == "string" and #data.link <= 500 and data.link:match("^[%w_%-]+://") then
|
|
payload.link = data.link
|
|
end
|
|
|
|
local condition, owner_params = owner_condition(device)
|
|
if data.kind == "contact" and type(payload.id) == "string" then
|
|
if app_id == "phone" and payload.id == "self" then
|
|
local contact = canonical_own_contact(source, device)
|
|
if not contact then
|
|
return nil, "sim_required"
|
|
end
|
|
payload.title = contact.title
|
|
payload.subtitle = contact.subtitle
|
|
payload.copyText = contact.copyText
|
|
payload.meta = contact.meta
|
|
else
|
|
local params = { payload.id }
|
|
append_params(params, owner_params)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `name`, `phone_number`, `organization`, `notes`
|
|
FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1
|
|
]]):format(condition), params)
|
|
local contact = rows[1]
|
|
if not contact then
|
|
return nil, "not_owned"
|
|
end
|
|
payload.title = contact.name
|
|
payload.subtitle = contact.phone_number
|
|
payload.copyText = ("%s\n%s"):format(contact.name, contact.phone_number)
|
|
payload.meta = {
|
|
name = contact.name,
|
|
notes = contact.notes,
|
|
organization = contact.organization,
|
|
phoneNumber = contact.phone_number,
|
|
}
|
|
end
|
|
elseif data.kind == "note" and type(payload.id) == "string" then
|
|
local params = { payload.id }
|
|
append_params(params, owner_params)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `title`, `body` FROM `sky_phone_notes`
|
|
WHERE `id` = ? AND %s LIMIT 1
|
|
]]):format(condition), params)
|
|
local note = rows[1]
|
|
if not note then
|
|
return nil, "not_owned"
|
|
end
|
|
payload.title = note.title ~= "" and note.title or title
|
|
payload.copyText = plain_note_body(note.body)
|
|
payload.meta = { body = note.body, title = note.title }
|
|
elseif data.kind == "media" and app_id == "photos" and type(data.meta) == "table" and type(data.meta.mediaIds) == "table" then
|
|
local media_ids = {}
|
|
local seen_ids = {}
|
|
for _, value in ipairs(data.meta.mediaIds) do
|
|
local media_id = tonumber(value)
|
|
if media_id and media_id > 0 and media_id == math.floor(media_id) and not seen_ids[media_id] then
|
|
seen_ids[media_id] = true
|
|
media_ids[#media_ids + 1] = media_id
|
|
end
|
|
end
|
|
if #media_ids < 1 or #media_ids > 50 then
|
|
return nil, "invalid_payload"
|
|
end
|
|
local placeholders = {}
|
|
local media_params = {}
|
|
for _, media_id in ipairs(media_ids) do
|
|
placeholders[#placeholders + 1] = "?"
|
|
media_params[#media_params + 1] = media_id
|
|
end
|
|
append_params(media_params, owner_params)
|
|
local media_rows = Bridge.Database.Query(([[
|
|
SELECT `id`, `url`, `media_type` AS `mediaType`
|
|
FROM `sky_phone_media`
|
|
WHERE `id` IN (%s) AND %s AND `media_type` IN ('photo', 'video')
|
|
ORDER BY `created_at` ASC, `id` ASC
|
|
]]):format(table.concat(placeholders, ", "), condition), media_params)
|
|
if #media_rows ~= #media_ids then
|
|
return nil, "not_owned"
|
|
end
|
|
local items = {}
|
|
for _, row in ipairs(media_rows) do
|
|
items[#items + 1] = {
|
|
id = tonumber(row.id),
|
|
mediaType = row.mediaType,
|
|
url = row.url,
|
|
}
|
|
end
|
|
payload.imageUrl = items[1].url
|
|
payload.meta = { items = items }
|
|
elseif data.kind == "photo" or data.kind == "video" then
|
|
local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind)
|
|
if not url then
|
|
return nil, media_error or "not_owned"
|
|
end
|
|
local media_params = { tonumber(payload.id) }
|
|
append_params(media_params, owner_params)
|
|
local media_rows = Bridge.Database.Query(([[
|
|
SELECT `remote_id` FROM `sky_phone_media`
|
|
WHERE `id` = ? AND %s LIMIT 1
|
|
]]):format(condition), media_params)
|
|
if not media_rows[1] then
|
|
return nil, "not_owned"
|
|
end
|
|
payload.imageUrl = url
|
|
payload.meta = { remoteId = media_rows[1].remote_id, url = url }
|
|
elseif data.kind == "location" then
|
|
local ped = GetPlayerPed(source)
|
|
if ped == 0 then
|
|
return nil, "location_unavailable"
|
|
end
|
|
local coords = GetEntityCoords(ped)
|
|
payload.meta = { x = coords.x, y = coords.y, z = coords.z }
|
|
else
|
|
local canonical
|
|
if data.kind == "profile" and payload.id then
|
|
canonical = canonical_profile(device, app_id, payload.id)
|
|
elseif data.kind == "post" and payload.id then
|
|
canonical = canonical_post(device, app_id, payload.id)
|
|
elseif (data.kind == "track" or data.kind == "playlist") and app_id == "music" and payload.id then
|
|
canonical = canonical_music(device, data)
|
|
elseif data.kind == "document" and payload.id then
|
|
canonical = canonical_document(source, device, app_id, payload.id)
|
|
elseif data.kind == "text" and payload.id then
|
|
canonical = canonical_text(device, app_id, payload.id)
|
|
elseif data.kind == "link" and payload.id then
|
|
if app_id == "citymarkt" or app_id == "local-pages" then
|
|
canonical = canonical_post(device, app_id, payload.id)
|
|
elseif app_id == "crewlink" then
|
|
canonical = canonical_crewlink_invite(device, payload.id)
|
|
end
|
|
end
|
|
if not canonical then
|
|
return nil, "unsupported_payload"
|
|
end
|
|
payload.title = canonical.title
|
|
payload.copyText = canonical.copyText
|
|
payload.subtitle = canonical.subtitle
|
|
payload.imageUrl = canonical.imageUrl
|
|
payload.link = canonical.link
|
|
payload.meta = canonical.meta
|
|
end
|
|
|
|
local encoded = json.encode(payload)
|
|
if #encoded > Config.EasyShare.PayloadMaxBytes then
|
|
return nil, "payload_too_large"
|
|
end
|
|
return payload, nil, encoded
|
|
end
|
|
|
|
function SkyPhoneEasyShare.SanitizeChatPayload(source, data)
|
|
if not Config.EasyShare.Enabled then
|
|
return nil, "disabled"
|
|
end
|
|
local device, error_response = current_device(source)
|
|
if not device then
|
|
return nil, error_response.error
|
|
end
|
|
return sanitize_payload(source, device, data)
|
|
end
|
|
|
|
local function transfer_for_source(transfer, source)
|
|
local incoming = transfer.recipient_source == source
|
|
return {
|
|
id = transfer.id,
|
|
direction = incoming and "incoming" or "outgoing",
|
|
otherName = incoming and transfer.sender_name or transfer.recipient_name,
|
|
payload = transfer.payload,
|
|
progress = transfer.progress,
|
|
status = transfer.status,
|
|
createdAt = transfer.created_at,
|
|
}
|
|
end
|
|
|
|
local function notify_transfer(transfer)
|
|
TriggerClientEvent("sky_phone:easyshare:changed", transfer.sender_source, {
|
|
transfer = transfer_for_source(transfer, transfer.sender_source),
|
|
})
|
|
TriggerClientEvent("sky_phone:easyshare:changed", transfer.recipient_source, {
|
|
transfer = transfer_for_source(transfer, transfer.recipient_source),
|
|
})
|
|
end
|
|
|
|
local function persist_state(transfer)
|
|
Bridge.Database.Query([[
|
|
UPDATE `sky_phone_easyshare_transfers`
|
|
SET `status` = ?, `progress` = ?, `updated_at` = CURRENT_TIMESTAMP,
|
|
`completed_at` = IF(? IN ('completed','declined','cancelled','expired','failed'), CURRENT_TIMESTAMP, `completed_at`)
|
|
WHERE `id` = ?
|
|
]], { transfer.status, transfer.progress, transfer.status, transfer.id })
|
|
end
|
|
|
|
local function finish_transfer(transfer, status)
|
|
if transfer.status ~= "pending" and transfer.status ~= "transferring" then
|
|
return
|
|
end
|
|
transfer.status = status
|
|
transfer.progress = status == "completed" and 100 or transfer.progress
|
|
persist_state(transfer)
|
|
notify_transfer(transfer)
|
|
active_transfers[transfer.id] = nil
|
|
end
|
|
|
|
local function apply_received_payload(transfer)
|
|
local device = SkyPhone.LoadDevice(transfer.recipient_imei)
|
|
if not device then
|
|
return false
|
|
end
|
|
local account_id = device.account_id and tonumber(device.account_id) or nil
|
|
local meta = transfer.payload.meta or {}
|
|
if transfer.payload.kind == "contact" then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_contacts`
|
|
(`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`)
|
|
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?)
|
|
]], {
|
|
uuid(), uuid(), account_id, account_id and nil or device.imei,
|
|
meta.name, meta.notes or "", meta.organization or "", meta.phoneNumber,
|
|
})
|
|
TriggerClientEvent("sky_phone:contacts:changed", transfer.recipient_source, {})
|
|
elseif transfer.payload.kind == "note" or transfer.payload.kind == "text" or transfer.payload.kind == "document" then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_notes`
|
|
(`id`, `account_id`, `device_imei`, `title`, `body`, `pinned`)
|
|
VALUES (?, ?, ?, ?, ?, 0)
|
|
]], {
|
|
uuid(), account_id, account_id and nil or device.imei,
|
|
meta.title or transfer.payload.title, meta.body or transfer.payload.copyText,
|
|
})
|
|
if account_id then
|
|
SkyPhone.RefreshAccount(account_id)
|
|
else
|
|
SkyPhone.RefreshDevice(device.imei)
|
|
end
|
|
elseif transfer.payload.kind == "location" then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_map_markers`
|
|
(`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`)
|
|
VALUES (?, ?, ?, 'blue', ?, ?, ?)
|
|
]], { uuid(), device.imei, transfer.payload.title:sub(1, 40), meta.x, meta.y, meta.z })
|
|
elseif transfer.payload.kind == "photo" or transfer.payload.kind == "video" then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
]], {
|
|
account_id, account_id and nil or device.imei, meta.url, meta.remoteId, transfer.payload.kind,
|
|
})
|
|
TriggerClientEvent("sky_phone:gallery:changed", transfer.recipient_source, {})
|
|
elseif transfer.payload.kind == "post"
|
|
or transfer.payload.kind == "profile"
|
|
or transfer.payload.kind == "track"
|
|
or transfer.payload.kind == "playlist"
|
|
or transfer.payload.kind == "link"
|
|
then
|
|
return type(transfer.payload.link) == "string"
|
|
else
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function advance_transfer(id)
|
|
local transfer = active_transfers[id]
|
|
if not transfer or transfer.status ~= "transferring" then
|
|
return
|
|
end
|
|
local distance = distance_between(transfer.sender_source, transfer.recipient_source)
|
|
if not distance or distance > Config.EasyShare.MaximumDistance then
|
|
finish_transfer(transfer, "failed")
|
|
return
|
|
end
|
|
transfer.progress = math.min(100, transfer.progress + 10)
|
|
persist_state(transfer)
|
|
notify_transfer(transfer)
|
|
if transfer.progress >= 100 then
|
|
if apply_received_payload(transfer) then
|
|
finish_transfer(transfer, "completed")
|
|
else
|
|
finish_transfer(transfer, "failed")
|
|
end
|
|
return
|
|
end
|
|
SetTimeout(math.max(100, math.floor(Config.EasyShare.TransferDurationMs / 10)), function()
|
|
advance_transfer(id)
|
|
end)
|
|
end
|
|
|
|
local function row_transfer(row, current_imei)
|
|
local decoded = json.decode(row.payload)
|
|
return {
|
|
id = row.id,
|
|
direction = row.recipient_imei == current_imei and "incoming" or "outgoing",
|
|
otherName = row.recipient_imei == current_imei and row.sender_name or row.recipient_name,
|
|
payload = decoded,
|
|
progress = tonumber(row.progress) or 0,
|
|
status = row.status,
|
|
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
|
|
}
|
|
end
|
|
|
|
local function history(device)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`,
|
|
`payload`, `status`, `progress`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
|
|
FROM `sky_phone_easyshare_transfers`
|
|
WHERE `sender_imei` = ? OR `recipient_imei` = ?
|
|
ORDER BY `created_at` DESC LIMIT ?
|
|
]], { device.imei, device.imei, Config.EasyShare.HistoryLimit })
|
|
local result = {}
|
|
for index, row in ipairs(rows) do
|
|
result[index] = row_transfer(row, device.imei)
|
|
end
|
|
return result
|
|
end
|
|
|
|
Bridge.Callbacks.Register("sky_phone:easyshare:bootstrap", function(source)
|
|
if not Config.EasyShare.Enabled then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "easyshare_bootstrap", Config.EasyShare.BootstrapRequestsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local device, error_response = current_device(source)
|
|
if not device then
|
|
return error_response
|
|
end
|
|
local pending = {}
|
|
for _, transfer in pairs(active_transfers) do
|
|
if transfer.sender_source == source or transfer.recipient_source == source then
|
|
pending[#pending + 1] = transfer_for_source(transfer, source)
|
|
end
|
|
end
|
|
return {
|
|
success = true,
|
|
data = {
|
|
history = history(device),
|
|
pending = pending,
|
|
targets = nearby_targets(source, device),
|
|
visibility = visibility_for(device.imei),
|
|
},
|
|
}
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:easyshare:own-contact", function(source)
|
|
if not Config.EasyShare.Enabled then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "easyshare_own_contact", Config.EasyShare.BootstrapRequestsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local device, error_response = current_device(source)
|
|
if not device then
|
|
return error_response
|
|
end
|
|
local payload = canonical_own_contact(source, device)
|
|
if not payload then
|
|
return { success = false, error = "sim_required" }
|
|
end
|
|
return { success = true, data = payload }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:easyshare:set-visibility", function(source, data)
|
|
if not Config.EasyShare.Enabled then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "easyshare_visibility", Config.EasyShare.VisibilityUpdatesPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local device, error_response = current_device(source)
|
|
if not device then
|
|
return error_response
|
|
end
|
|
local visibility = type(data) == "table" and data.visibility or nil
|
|
if not valid_visibilities[visibility] then
|
|
return { success = false, error = "invalid_visibility" }
|
|
end
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_easyshare_preferences` (`device_imei`, `visibility`)
|
|
VALUES (?, ?) ON DUPLICATE KEY UPDATE `visibility` = VALUES(`visibility`)
|
|
]], { device.imei, visibility })
|
|
return { success = true, data = { visibility = visibility } }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:easyshare:request", function(source, data)
|
|
if not Config.EasyShare.Enabled then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "easyshare_request", Config.EasyShare.RequestsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local sender_device, error_response = current_device(source)
|
|
if not sender_device then
|
|
return error_response
|
|
end
|
|
local target_source = type(data) == "table" and tonumber(data.targetId) or nil
|
|
if not target_source or target_source ~= math.floor(target_source) then
|
|
return { success = false, error = "invalid_target" }
|
|
end
|
|
local target = target_state(source, target_source, sender_device)
|
|
if not target then
|
|
return { success = false, error = "target_unavailable" }
|
|
end
|
|
local payload, payload_error, encoded = sanitize_payload(source, sender_device, data.payload)
|
|
if not payload then
|
|
return { success = false, error = payload_error }
|
|
end
|
|
local id = uuid()
|
|
local transfer = {
|
|
id = id,
|
|
sender_source = source,
|
|
recipient_source = target_source,
|
|
sender_imei = sender_device.imei,
|
|
recipient_imei = target.device.imei,
|
|
sender_name = display_name(source),
|
|
recipient_name = display_name(target_source),
|
|
payload = payload,
|
|
progress = 0,
|
|
status = "pending",
|
|
created_at = os.time() * 1000,
|
|
}
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_easyshare_transfers`
|
|
(`id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`, `content_type`, `payload`, `status`, `progress`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0)
|
|
]], { id, sender_device.imei, target.device.imei, transfer.sender_name, transfer.recipient_name, payload.kind, encoded })
|
|
active_transfers[id] = transfer
|
|
notify_transfer(transfer)
|
|
SetTimeout(Config.EasyShare.PendingSeconds * 1000, function()
|
|
local current = active_transfers[id]
|
|
if current and current.status == "pending" then
|
|
finish_transfer(current, "expired")
|
|
end
|
|
end)
|
|
return { success = true, data = transfer_for_source(transfer, source) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:easyshare:respond", function(source, data)
|
|
if not Config.EasyShare.Enabled then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "easyshare_action", Config.EasyShare.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local id = type(data) == "table" and data.id or nil
|
|
local transfer = type(id) == "string" and active_transfers[id] or nil
|
|
if not transfer or transfer.recipient_source ~= source or transfer.status ~= "pending" or type(data.accepted) ~= "boolean" then
|
|
return { success = false, error = "transfer_not_found" }
|
|
end
|
|
if not data.accepted then
|
|
finish_transfer(transfer, "declined")
|
|
return { success = true, data = transfer_for_source(transfer, source) }
|
|
end
|
|
local distance = distance_between(transfer.sender_source, transfer.recipient_source)
|
|
if not distance or distance > Config.EasyShare.MaximumDistance then
|
|
finish_transfer(transfer, "failed")
|
|
return { success = false, error = "too_far" }
|
|
end
|
|
transfer.status = "transferring"
|
|
persist_state(transfer)
|
|
notify_transfer(transfer)
|
|
SetTimeout(math.max(100, math.floor(Config.EasyShare.TransferDurationMs / 10)), function()
|
|
advance_transfer(id)
|
|
end)
|
|
return { success = true, data = transfer_for_source(transfer, source) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:easyshare:cancel", function(source, data)
|
|
if not Config.EasyShare.Enabled then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "easyshare_action", Config.EasyShare.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local id = type(data) == "table" and data.id or nil
|
|
local transfer = type(id) == "string" and active_transfers[id] or nil
|
|
if not transfer or (transfer.sender_source ~= source and transfer.recipient_source ~= source) then
|
|
return { success = false, error = "transfer_not_found" }
|
|
end
|
|
finish_transfer(transfer, "cancelled")
|
|
return { success = true, data = transfer_for_source(transfer, source) }
|
|
end)
|
|
|
|
AddEventHandler("playerDropped", function()
|
|
local dropped_source = source
|
|
for _, transfer in pairs(active_transfers) do
|
|
if transfer.sender_source == dropped_source or transfer.recipient_source == dropped_source then
|
|
finish_transfer(transfer, "failed")
|
|
end
|
|
end
|
|
end)
|
|
end)
|