ENH - modernize SkyRide profile and interface

This commit is contained in:
Leon.Schmidt
2026-08-13 17:36:17 +02:00
parent f5403254e1
commit 5fca29eb90
21 changed files with 806 additions and 722 deletions
+22 -3
View File
@@ -565,7 +565,8 @@ const defaultLocales: LocaleTree = {
camera: 'Camera',
backToLogin: 'Back to CrewLink login',
authErrors: {
no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.',
no_ifruit_account:
'Sign in to your Sky Cloud account in Settings first.',
invalid_username: 'Use 320 letters, numbers, dots, or underscores.',
profile_not_found: 'No CrewLink profile exists for this iFruit email.',
profile_exists: 'This iFruit email already has a CrewLink profile.',
@@ -1190,7 +1191,8 @@ const defaultLocales: LocaleTree = {
profileStep: 'Step 2 of 2',
accountConnected: 'Sky Cloud account connected',
authErrors: {
no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.',
no_ifruit_account:
'Sign in to your Sky Cloud account in Settings first.',
invalid_handle: 'Use 330 letters, numbers or underscores.',
invalid_username: 'That username does not match your Feather profile.',
profile_not_found: 'No Feather profile exists for this iFruit email.',
@@ -2269,6 +2271,18 @@ const defaultLocales: LocaleTree = {
acceptance: 'Acceptance',
rating: 'Rating',
notAvailable: 'N/A',
profile: 'Your profile',
editProfile: 'Edit Profile',
editProfileBody: 'Change your public name and profile photo',
profilePhoto: 'Profile photo',
gallery: 'Gallery',
camera: 'Camera',
profileDetails: 'Profile details',
profileName: 'Name',
profileNamePlaceholder: 'Your SkyRide name',
removeProfilePhoto: 'Remove profile photo',
saveProfile: 'Save Profile',
profileSaved: 'Your SkyRide profile was updated.',
memberSince: 'SkyRide member since {date}',
rideComplete: 'Ride Complete',
rateRide: 'Rate your ride',
@@ -2309,6 +2323,10 @@ const defaultLocales: LocaleTree = {
invalid_rating: 'Choose a rating from one to five stars.',
invalid_tip: 'Choose a valid tip amount.',
invalid_comment: 'The rating comment is not valid.',
invalid_profile: 'Choose a valid profile name and photo.',
invalid_profile_name:
'Your profile name must contain 2 to 50 characters.',
invalid_profile_image: 'Choose a profile photo from your own gallery.',
tip_failed: 'The tip could not be processed.',
insufficient_funds:
'Your selected payment method has insufficient funds.',
@@ -2957,7 +2975,8 @@ const defaultLocales: LocaleTree = {
'Review the listing and publish it when you are ready.',
cityMarktPhotosHint: 'The CityMarkt listing photos will be included.',
authErrors: {
no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.',
no_ifruit_account:
'Sign in to your Sky Cloud account in Settings first.',
invalid_username:
'Use 324 lowercase letters, numbers, dots or underscores.',
profile_not_found:
+28
View File
@@ -42,6 +42,7 @@ const bootstrap: SkyRideBootstrap = {
pendingRating: null,
profile: {
acceptanceRate: 94,
avatarMediaId: null,
avatarUrl: null,
cancelledRides: 2,
completedRides: 128,
@@ -112,6 +113,33 @@ describe('SkyRide store', () => {
})
})
it('updates the editable profile with an owned media id', async () => {
const updatedProfile = {
...bootstrap.profile,
avatarMediaId: 17,
avatarUrl: 'https://example.test/avatar.webp',
name: 'Jordan Sky',
}
mockNuiCall.mockResolvedValueOnce({
data: { profile: updatedProfile },
success: true,
})
const skyride = useSkyRideStore()
skyride.profile = bootstrap.profile
const response = await skyride.updateProfile({
avatarMediaId: 17,
name: 'Jordan Sky',
})
expect(response.success).toBe(true)
expect(skyride.profile).toEqual(updatedProfile)
expect(mockNuiCall).toHaveBeenCalledWith('skyride:update-profile', {
avatarMediaId: 17,
name: 'Jordan Sky',
})
})
it('books with the opaque quote id and never sends a client price', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { activeRide: ride },
+18
View File
@@ -9,6 +9,7 @@ import type {
SkyRideQuote,
SkyRideQuoteOption,
SkyRideRide,
SkyRideProfileInput,
SkyRideStateUpdate,
} from '@/types/skyride'
import { nuiCall, type NuiResponse } from '@/utils/nui'
@@ -73,6 +74,23 @@ export const useSkyRideStore = defineStore('skyride', {
this.error = ''
return true
},
async updateProfile(
profile: SkyRideProfileInput,
): Promise<NuiResponse<SkyRideStateUpdate>> {
this.isActionPending = true
const response = await nuiCall<SkyRideStateUpdate>(
'skyride:update-profile',
profile,
)
this.isActionPending = false
if (response.success) {
if (response.data) this.applyUpdate(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async createQuote(
pickup: SkyRideLocation,
destination: SkyRideLocation,
+6
View File
@@ -100,6 +100,7 @@ export type SkyRideRide = {
export type SkyRideProfile = {
acceptanceRate: number | null
avatarMediaId: number | null
avatarUrl: string | null
cancelledRides: number
completedRides: number
@@ -112,6 +113,11 @@ export type SkyRideProfile = {
rating: number
}
export type SkyRideProfileInput = {
avatarMediaId: number
name: string
}
export type SkyRideBootstrap = {
activeRide: SkyRideRide | null
availableRequests: SkyRideRide[]
+6 -1
View File
@@ -10,6 +10,7 @@ const props = withDefaults(
backLabel?: string
showBack?: boolean
showBackText?: boolean
subtitle?: string
title: string
variant?: 'compact' | 'large'
}>(),
@@ -18,6 +19,7 @@ const props = withDefaults(
backLabel: '',
showBack: false,
showBackText: false,
subtitle: '',
variant: 'compact',
},
)
@@ -49,7 +51,10 @@ const accessibleBackLabel = computed(() => props.backLabel || props.title)
</slot>
</div>
<h1 class="sky-navbar__title">{{ title }}</h1>
<div class="sky-navbar__heading">
<h1 class="sky-navbar__title">{{ title }}</h1>
<span v-if="subtitle" class="sky-navbar__subtitle">{{ subtitle }}</span>
</div>
<div class="sky-navbar__right">
<slot name="right" />
+24
View File
@@ -0,0 +1,24 @@
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyTabBar from '@/ui/SkyTabBar.vue'
describe('SkyTabBar', () => {
it('keeps the docked tab bar as the default', async () => {
const html = await renderToString(
createSSRApp(SkyTabBar, { label: 'Navigation' }),
)
expect(html).toContain('class="sky-tabbar"')
expect(html).not.toContain('sky-tabbar--floating')
})
it('exposes the shared floating capsule variant', async () => {
const html = await renderToString(
createSSRApp(SkyTabBar, { floating: true, label: 'Navigation' }),
)
expect(html).toContain('sky-tabbar sky-tabbar--floating')
})
})
+15 -4
View File
@@ -1,13 +1,24 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{
label: string
}>()
withDefaults(
defineProps<{
floating?: boolean
label: string
}>(),
{
floating: false,
},
)
</script>
<template>
<nav v-bind="$attrs" class="sky-tabbar" :aria-label="label">
<nav
v-bind="$attrs"
class="sky-tabbar"
:class="{ 'sky-tabbar--floating': floating }"
:aria-label="label"
>
<div class="sky-tabbar__inner">
<div class="sky-tabbar__pane">
<slot />
+45
View File
@@ -218,6 +218,36 @@
padding: var(--sky-space-4, 16px);
}
.sky-button--small {
padding-inline: 12px;
font-size: 13px;
}
.sky-card__header,
.sky-card__footer {
min-width: 0;
padding: var(--sky-space-3, 12px) var(--sky-space-4, 16px);
}
.sky-card__header--divider {
border-bottom: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
}
.sky-card__footer--divider {
border-top: 1px solid var(--sky-hairline, rgba(15, 23, 42, 0.1));
}
.sky-block-header {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.sky-block-header--inset {
margin-inline: var(--sky-page-gutter, 14px);
}
.sky-status-card {
min-width: 0;
padding: 14px;
@@ -1082,6 +1112,21 @@ label.sky-list-item__row {
color: var(--sky-app-accent, #3b82f6);
}
.sky-tabbar--floating .sky-tab-button {
min-height: 54px;
gap: 2px;
padding-block: 5px;
border-radius: 999px;
}
.sky-tabbar--floating .sky-tab-button--active {
background: var(--sky-surface-muted, #e6e9ee);
}
.sky-tabbar--floating .sky-tab-button:active:not(:disabled) {
background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
}
.sky-tab-button:active:not(:disabled) {
background: var(--sky-app-accent-soft, rgba(59, 130, 246, 0.14));
}
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'header' | 'section'
inset?: boolean
}>(),
{
component: 'header',
inset: false,
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-block-header"
:class="{ 'sky-block-header--inset': inset }"
>
<slot />
</component>
</template>
+3
View File
@@ -13,6 +13,7 @@ const props = withDefaults(
large?: boolean
outline?: boolean
rounded?: boolean
small?: boolean
type?: 'button' | 'reset' | 'submit'
variant?: 'danger' | 'plain' | 'primary' | 'secondary'
}>(),
@@ -25,6 +26,7 @@ const props = withDefaults(
large: false,
outline: false,
rounded: false,
small: false,
type: 'button',
variant: 'primary',
},
@@ -73,6 +75,7 @@ function handleClick(event: MouseEvent): void {
'sky-button--large': large,
'sky-button--outline': outline,
'sky-button--rounded': rounded,
'sky-button--small': small,
},
]"
@click="handleClick"
+29 -1
View File
@@ -1,23 +1,51 @@
<script setup lang="ts">
import { useSlots } from 'vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'article' | 'div' | 'section'
contentWrap?: boolean
contentWrapPadding?: string
footerDivider?: boolean
headerDivider?: boolean
}>(),
{
component: 'div',
contentWrap: true,
contentWrapPadding: '',
footerDivider: false,
headerDivider: false,
},
)
const slots = useSlots()
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-card">
<div v-if="contentWrap" class="sky-card__content">
<div
v-if="slots.header"
class="sky-card__header"
:class="{ 'sky-card__header--divider': headerDivider }"
>
<slot name="header" />
</div>
<div
v-if="contentWrap"
class="sky-card__content"
:class="contentWrapPadding"
>
<slot />
</div>
<slot v-else />
<div
v-if="slots.footer"
class="sky-card__footer"
:class="{ 'sky-card__footer--divider': footerDivider }"
>
<slot name="footer" />
</div>
</component>
</template>
+1
View File
@@ -1,5 +1,6 @@
export { default as SkyBadge } from './SkyBadge.vue'
export { default as SkyBlock } from './SkyBlock.vue'
export { default as SkyBlockHeader } from './SkyBlockHeader.vue'
export { default as SkyBlockTitle } from './SkyBlockTitle.vue'
export { default as SkyButton } from './SkyButton.vue'
export { default as SkyCard } from './SkyCard.vue'
+47 -2
View File
@@ -53,8 +53,15 @@
justify-content: flex-end;
}
.sky-navbar__title {
.sky-navbar__heading {
min-width: 0;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.sky-navbar__title {
margin: 0;
padding: 0 var(--sky-space-1);
overflow: hidden;
@@ -67,6 +74,17 @@
white-space: nowrap;
}
.sky-navbar__subtitle {
max-width: 100%;
overflow: hidden;
color: var(--sky-muted);
font-size: 11px;
font-weight: 600;
line-height: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sky-navbar__back {
box-sizing: border-box;
max-width: 100%;
@@ -135,8 +153,12 @@
display: none;
}
.sky-navbar--large .sky-navbar__title {
.sky-navbar--large .sky-navbar__heading {
grid-area: title;
align-items: flex-start;
}
.sky-navbar--large .sky-navbar__title {
padding: 0;
font-size: var(--sky-font-large-title);
font-weight: 700;
@@ -403,6 +425,29 @@
color: var(--sky-text);
}
.sky-tabbar--floating {
right: var(--sky-space-4);
bottom: calc(var(--sky-safe-area-bottom) + 10px);
left: var(--sky-space-4);
min-height: 62px;
padding: 0;
border: 1px solid var(--sky-hairline);
border-radius: 999px;
background: var(--sky-surface);
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.34),
inset 0 1px rgba(255, 255, 255, 0.04);
}
.sky-tabbar--floating .sky-tabbar__inner {
min-height: 60px;
padding: 3px;
}
.sky-tabbar--floating .sky-tabbar__pane {
min-height: 60px;
}
.sky-tabbar__inner,
.sky-tabbar__pane {
width: 100%;
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -888,6 +888,7 @@ let mockGarageValet = null
const skyRideProfile = {
acceptanceRate: 96,
avatarMediaId: 1,
avatarUrl:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=240&q=80',
cancelledRides: 3,
@@ -6641,6 +6642,33 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: { items: skyRideHistory } })
return
}
if (endpoint === 'skyride:update-profile') {
const name = String(request.body.name ?? '').trim()
const avatarMediaId = Number(request.body.avatarMediaId)
const avatar = avatarMediaId
? mockMedia.find(
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
)
: null
if (
name.length < 2 ||
name.length > 50 ||
!Number.isInteger(avatarMediaId) ||
avatarMediaId < 0 ||
(avatarMediaId > 0 && !avatar)
) {
response.json({ success: false, error: 'invalid_profile' })
return
}
skyRideProfile.name = name
skyRideProfile.avatarMediaId = avatarMediaId || null
skyRideProfile.avatarUrl = avatar?.url ?? null
response.json({
success: true,
data: skyRideUpdate(['profile']),
})
return
}
if (endpoint === 'skyride:quote') {
const pickup = request.body.pickup
const destination = request.body.destination
@@ -7679,8 +7707,7 @@ app.post('/api/:endpoint', (request, response) => {
}
}
const bootstrapDeviceData =
testScenario === 'easyshare-full' &&
!deviceData.apps?.payload?.homeLayout
testScenario === 'easyshare-full' && !deviceData.apps?.payload?.homeLayout
? {
...deviceData,
apps: {
+2
View File
@@ -489,6 +489,8 @@ Config.Calendar = {
Config.SkyRide = {
PaymentAccount = "bank",
Currency = "$",
ProfileNameMinLength = 2,
ProfileNameMaxLength = 50,
DistanceUnit = "kilometer", -- kilometer or mile
QuoteLifetimeSeconds = 120,
HistoryLimit = 50,
+6
View File
@@ -1084,6 +1084,10 @@ Locales["en"] = {
safetyBody = "Help, trip sharing, and emergency information", driverMode = "Driver Mode",
driverModeBody = "Accept ride requests and track your earnings", completedRides = "Completed",
cancelledRides = "Cancelled", acceptance = "Acceptance", rating = "Rating", notAvailable = "N/A",
profile = "Your profile", editProfile = "Edit Profile", editProfileBody = "Change your public name and profile photo",
profilePhoto = "Profile photo", gallery = "Gallery", camera = "Camera", profileDetails = "Profile details",
profileName = "Name", profileNamePlaceholder = "Your SkyRide name", removeProfilePhoto = "Remove profile photo",
saveProfile = "Save Profile", profileSaved = "Your SkyRide profile was updated.",
memberSince = "SkyRide member since {date}", rideComplete = "Ride Complete", rateRide = "Rate your ride",
rateRideBody = "How was your trip? Your feedback helps every rider.", ratingValue = "{rating} stars",
tip = "Add a tip", noTip = "No tip", comment = "Comment", commentPlaceholder = "Share an optional note...",
@@ -1103,6 +1107,8 @@ Locales["en"] = {
destination_too_far = "Move closer to the destination to complete the ride.", payout_failed = "The driver payout could not be completed.",
refund_failed = "The ride refund could not be completed.", invalid_cancel_reason = "Choose a valid cancellation reason.",
invalid_tip = "Choose a valid tip amount.", invalid_comment = "The rating comment is not valid.",
invalid_profile = "Choose a valid profile name and photo.", invalid_profile_name = "Your profile name must contain 2 to 50 characters.",
invalid_profile_image = "Choose a profile photo from your own gallery.",
tip_failed = "The tip could not be processed.", insufficient_funds = "Your selected payment method has insufficient funds.",
rate_limited = "Please wait a moment before trying again.", request_failed = "SkyRide could not complete the request.",
device_not_open = "Open the phone again to use SkyRide.", device_not_owned = "This phone does not belong to this character.",
+4
View File
@@ -1,5 +1,6 @@
local server_callbacks = {
"skyride:bootstrap",
"skyride:update-profile",
"skyride:quote",
"skyride:request",
"skyride:set-driver-status",
@@ -79,6 +80,9 @@ local function normalize_state(data)
if data.profile.avatarUrl == false then
data.profile.avatarUrl = json.null
end
if data.profile.avatarMediaId == false then
data.profile.avatarMediaId = json.null
end
if data.profile.earningsToday == false then
data.profile.earningsToday = json.null
end
+8
View File
@@ -1851,12 +1851,20 @@ local schema = {
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "display_name", type = "VARCHAR(50) NULL" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_skyride_owner", columns = "(`owner_identifier`)" },
},
indexes = {
{ name = "idx_sky_phone_skyride_profile_avatar", columns = "(`avatar_media_id`)" },
},
foreignKeys = {
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
+78 -11
View File
@@ -29,7 +29,11 @@ end
local ride_select = [[
SELECT r.*,
passenger.`owner_identifier` AS `passenger_identifier`,
COALESCE(NULLIF(passenger.`display_name`, ''), r.`passenger_name`) AS `passenger_profile_name`,
passenger_avatar.`url` AS `passenger_avatar_url`,
driver.`owner_identifier` AS `driver_identifier`,
COALESCE(NULLIF(driver.`display_name`, ''), r.`driver_name`) AS `driver_profile_name`,
driver_avatar.`url` AS `driver_avatar_url`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`,
UNIX_TIMESTAMP(r.`accepted_at`) AS `accepted_at_unix`,
@@ -40,8 +44,12 @@ local ride_select = [[
FROM `sky_phone_skyride_rides` r
INNER JOIN `sky_phone_skyride_profiles` passenger
ON passenger.`id` = r.`passenger_profile_id`
LEFT JOIN `sky_phone_media` passenger_avatar
ON passenger_avatar.`id` = passenger.`avatar_media_id`
LEFT JOIN `sky_phone_skyride_profiles` driver
ON driver.`id` = r.`driver_profile_id`
LEFT JOIN `sky_phone_media` driver_avatar
ON driver_avatar.`id` = driver.`avatar_media_id`
]]
local function affected_rows(result)
@@ -164,9 +172,12 @@ local function require_profile(source)
SELECT UUID(), ?
]], { identifier })
local rows = Bridge.Database.Query([[
SELECT `id`, `owner_identifier`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_skyride_profiles`
WHERE `owner_identifier` = ?
SELECT profile.`id`, profile.`owner_identifier`, profile.`display_name`,
profile.`avatar_media_id`, avatar.`url` AS `avatar_url`,
UNIX_TIMESTAMP(profile.`created_at`) AS `created_at_unix`
FROM `sky_phone_skyride_profiles` profile
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
WHERE profile.`owner_identifier` = ?
LIMIT 1
]], { identifier })
if not rows[1] then
@@ -235,7 +246,8 @@ local function profile_snapshot(source, profile)
local stats = rows[1] or {}
return {
acceptanceRate = false,
avatarUrl = false,
avatarMediaId = profile.avatar_media_id and tonumber(profile.avatar_media_id) or false,
avatarUrl = profile.avatar_url or false,
cancelledRides = tonumber(stats.cancelled_rides) or 0,
completedRides = tonumber(stats.completed_rides) or 0,
currency = Config.SkyRide.Currency,
@@ -243,7 +255,7 @@ local function profile_snapshot(source, profile)
earningsToday = driver_eligible(source) and (tonumber(stats.earnings_today) or 0) or false,
id = profile.id,
memberSince = tonumber(profile.created_at_unix) or os.time(),
name = player_name(source),
name = profile.display_name or player_name(source),
rating = math.floor(((tonumber(stats.rating) or 5.0) * 100) + 0.5) / 100,
}
end
@@ -338,9 +350,9 @@ local function online_source_for_identifier(identifier)
return nil
end
local function person_dto(id, name, metrics)
local function person_dto(id, name, avatar_url, metrics)
return {
avatarUrl = false,
avatarUrl = avatar_url or false,
id = id,
name = name or "",
rating = metrics and metrics.rating or 5.0,
@@ -356,12 +368,18 @@ local function ride_dtos(rows)
local status = row.status == "completing" and "in_progress" or row.status
local passenger = person_dto(
row.passenger_profile_id,
row.passenger_name,
row.passenger_profile_name,
row.passenger_avatar_url,
metrics[row.passenger_profile_id]
)
local driver = false
if row.driver_profile_id then
driver = person_dto(row.driver_profile_id, row.driver_name, metrics[row.driver_profile_id])
driver = person_dto(
row.driver_profile_id,
row.driver_profile_name,
row.driver_avatar_url,
metrics[row.driver_profile_id]
)
driver.vehicle = {
color = row.driver_vehicle_color or "",
model = row.driver_vehicle_model or "",
@@ -819,6 +837,55 @@ Bridge.Callbacks.Register("sky_phone:skyride:bootstrap", function(source)
}
end)
Bridge.Callbacks.Register("sky_phone:skyride:update-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local name = type(data) == "table" and type(data.name) == "string"
and data.name:match("^%s*(.-)%s*$") or nil
local name_length = name and utf8.len(name) or nil
local avatar_media_id = type(data) == "table" and tonumber(data.avatarMediaId) or nil
if not name_length
or name_length < Config.SkyRide.ProfileNameMinLength
or name_length > Config.SkyRide.ProfileNameMaxLength
then
return { success = false, error = "invalid_profile_name" }
end
if not avatar_media_id or avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
return { success = false, error = "invalid_profile_image" }
end
if avatar_media_id > 0
and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
then
return { success = false, error = "invalid_profile_image" }
end
local result, lock_error = run_locked("profile:" .. profile.id, function()
local update = Bridge.Database.Query([[
UPDATE `sky_phone_skyride_profiles`
SET `display_name` = ?, `avatar_media_id` = NULLIF(?, 0)
WHERE `id` = ? AND `owner_identifier` = ?
]], { name, avatar_media_id, profile.id, profile.owner_identifier })
if affected_rows(update) > 1 then
error(("[sky_phone] SkyRide profile update affected multiple rows for '%s'."):format(profile.id))
end
local updated_profile = require_profile(source)
local ride = active_ride(profile.id)
if ride then
push_participants(ride, { activeRide = true }, source)
end
push_available_requests(source)
return {
success = true,
data = state_snapshot(source, updated_profile, { profile = true }),
}
end)
return result or lock_error
end)
Bridge.Callbacks.Register("sky_phone:skyride:history", function(source)
if not SkyPhone.AllowOperation(source, "skyride_read", Config.SkyRide.ReadsPerMinute, 60) then
return { success = false, error = "rate_limited" }
@@ -995,7 +1062,7 @@ Bridge.Callbacks.Register("sky_phone:skyride:request", function(source, data)
]], {
ride_id,
profile.id,
player_name(source),
profile.display_name or player_name(source),
quote.option.serviceClass,
quote.pickup.label,
quote.pickup.coords.x,
@@ -1232,7 +1299,7 @@ Bridge.Callbacks.Register("sky_phone:skyride:accept", function(source, data)
WHERE `id` = ? AND `status` = 'searching' AND `driver_profile_id` IS NULL
]], {
profile.id,
player_name(source),
profile.display_name or player_name(source),
vehicle.model,
vehicle.color,
vehicle.plate,
+5 -1
View File
@@ -795,9 +795,13 @@ CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_moderation_audit` (
CREATE TABLE IF NOT EXISTS `sky_phone_skyride_profiles` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`display_name` VARCHAR(50) NULL,
`avatar_media_id` BIGINT UNSIGNED NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_skyride_owner` (`owner_identifier`)
UNIQUE KEY `uniq_sky_phone_skyride_owner` (`owner_identifier`),
KEY `idx_sky_phone_skyride_profile_avatar` (`avatar_media_id`),
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_skyride_rides` (