fix(flare): enforce real profile photos

This commit is contained in:
Dominik
2026-08-17 01:30:41 +02:00
parent ae8134bf98
commit ecfe089051
15 changed files with 680 additions and 150 deletions
@@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const flareServer = readFileSync(
new URL('../../sky_phone/source/server/flare.lua', import.meta.url),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
function sourceBlock(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('Flare server profile photo invariants', () => {
it('requires one to six unique owned photos for every profile save', () => {
const validation = sourceBlock(
flareServer,
'local function validate_profile(source, data)',
'local function load_match',
)
expect(validation).toContain('type(data.photoMediaIds) ~= "table"')
expect(validation).toContain('or #data.photoMediaIds < 1')
expect(validation).toContain('or #data.photoMediaIds > 6')
expect(validation).toContain('or seen_media[media_id]')
expect(validation).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")',
)
expect(validation).toContain('replace_photos = true')
})
it('filters profiles without a valid owned HTTPS photo before limiting suggestions', () => {
const suggestions = sourceBlock(
flareServer,
'local function list_suggestions(account_id, profile)',
'local function list_likes',
)
const exists = suggestions.indexOf('AND EXISTS (')
const order = suggestions.indexOf('ORDER BY target.`updated_at`')
const limit = suggestions.indexOf('LIMIT 30')
expect(suggestions).toContain('type(profile.photo_urls) ~= "table"')
expect(suggestions).toContain('or #profile.photo_urls < 1')
expect(exists).toBeGreaterThanOrEqual(0)
expect(exists).toBeLessThan(order)
expect(order).toBeLessThan(limit)
expect(suggestions).toContain(
'target_media.`account_id` = target.`account_id`',
)
expect(suggestions).toContain("target_media.`media_type` = 'photo'")
expect(suggestions).toContain("target_media.`url` LIKE 'https://%'")
})
it('protects the last valid Flare photo for single and sequential bulk deletes', () => {
const deletion = sourceBlock(
mediaServer,
'local function is_required_flare_profile_photo(media_id)',
'function SkyPhoneMedia.GetDeviceRemoteIds',
)
expect(deletion).toContain('other_photo.`media_id` <> photo.`media_id`')
expect(deletion).toContain(
'other_media.`account_id` = profile.`account_id`',
)
expect(deletion).toContain("other_media.`media_type` = 'photo'")
expect(deletion).toContain("other_media.`url` LIKE 'https://%'")
expect(deletion).toContain(
'row.media_type == "photo" and is_required_flare_profile_photo(media_id)',
)
expect(deletion).toContain('return false, "profile_photo_required"')
expect(deletion).toMatch(
/for _, media_id in ipairs\(media_ids\) do[\s\S]*?delete_owned_media\(src, owner, media_id\)/,
)
})
})
+28
View File
@@ -121,6 +121,34 @@ describe('flare store', () => {
])
})
it.each([
{ label: 'no photos', photoMediaIds: [] },
{ label: 'more than six photos', photoMediaIds: [1, 2, 3, 4, 5, 6, 7] },
{ label: 'duplicate photos', photoMediaIds: [42, 42] },
])(
'rejects a profile with $label before calling NUI',
async ({ photoMediaIds }) => {
const draft: FlareProfileDraft = {
age: bootstrap.profile!.age,
avatar: bootstrap.profile!.avatar,
bio: bootstrap.profile!.bio,
gender: bootstrap.profile!.gender,
interestedIn: bootstrap.profile!.interestedIn,
interests: [...bootstrap.profile!.interests],
lookingFor: bootstrap.profile!.lookingFor,
maxAge: bootstrap.profile!.maxAge,
minAge: bootstrap.profile!.minAge,
name: bootstrap.profile!.name,
photoMediaIds,
}
const flare = useFlareStore()
expect(await flare.saveProfile(draft)).toBe(false)
expect(flare.error).toBe('invalid_profile_photos')
expect(mockNuiCall).not.toHaveBeenCalled()
},
)
it('uses a real Super Like and removes the target from both decks', async () => {
const match: FlareMatch = {
id: 'match-1',
+16
View File
@@ -10,6 +10,18 @@ import type {
} from '@/types/flare'
import { nuiCall } from '@/utils/nui'
function hasValidProfilePhotos(
photoMediaIds: unknown,
): photoMediaIds is number[] {
return (
Array.isArray(photoMediaIds) &&
photoMediaIds.length >= 1 &&
photoMediaIds.length <= 6 &&
new Set(photoMediaIds).size === photoMediaIds.length &&
photoMediaIds.every((mediaId) => Number.isInteger(mediaId) && mediaId > 0)
)
}
export const useFlareStore = defineStore('flare', {
state: () => ({
activeMatchId: '' as string,
@@ -54,6 +66,10 @@ export const useFlareStore = defineStore('flare', {
return response.success
},
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
if (!hasValidProfilePhotos(draft.photoMediaIds)) {
this.error = 'invalid_profile_photos'
return false
}
const response = await nuiCall<FlareBootstrap>(
'flare:save-profile',
draft,
+5 -2
View File
@@ -867,7 +867,8 @@ const defaultLocales: LocaleTree = {
photo: 'Profile photo',
profilePhotos: 'Profile photos',
profilePhotosBody:
'Add up to six photos from Photos or Camera. Your first photo is shown first.',
'Add one to six photos from Photos or Camera. Your first photo is shown first.',
profilePhotoRequired: 'Add at least one profile photo to continue.',
addPhotos: 'Add photos',
choosePhotos: 'Choose from Photos',
primaryPhoto: 'Main',
@@ -1006,7 +1007,7 @@ const defaultLocales: LocaleTree = {
invalid_profile: 'Check your name, age and profile text.',
profile_not_found: 'This Flare account is no longer available.',
invalid_profile_photos:
'Choose or take up to six photos saved in your own Photos library.',
'Choose or take at least one and up to six photos saved in your own Photos library.',
request_failed: 'Flare could not save those changes. Try again.',
invalid_target: 'This profile is no longer available.',
invalid_choice: 'That swipe could not be saved.',
@@ -4060,6 +4061,8 @@ const defaultLocales: LocaleTree = {
import_url_unavailable: 'The linked media could not be reached.',
import_size_unavailable: 'The website did not provide the media size.',
not_found: 'The media item no longer exists.',
profile_photo_required:
'This is the last photo on your Flare profile. Add another profile photo before deleting it.',
operation_in_progress:
'Another media operation is already in progress.',
owner_changed: 'The active phone account changed.',
+31 -4
View File
@@ -13,8 +13,20 @@ import {
} from './media'
const media = [
{ createdAt: 10, favorite: false, id: 1, mediaType: 'photo' as const, url: 'photo' },
{ createdAt: 20, favorite: false, id: 2, mediaType: 'video' as const, url: 'video' },
{
createdAt: 10,
favorite: false,
id: 1,
mediaType: 'photo' as const,
url: 'photo',
},
{
createdAt: 20,
favorite: false,
id: 2,
mediaType: 'video' as const,
url: 'video',
},
]
describe('media utilities', () => {
@@ -27,8 +39,20 @@ describe('media utilities', () => {
it('merges pages without duplicates and keeps newest first', () => {
expect(
mergeMedia(media, [
{ createdAt: 30, favorite: true, id: 1, mediaType: 'photo', url: 'updated' },
{ createdAt: 25, favorite: false, id: 3, mediaType: 'photo', url: 'new' },
{
createdAt: 30,
favorite: true,
id: 1,
mediaType: 'photo',
url: 'updated',
},
{
createdAt: 25,
favorite: false,
id: 3,
mediaType: 'photo',
url: 'new',
},
]).map((entry) => [entry.id, entry.url]),
).toEqual([
[1, 'updated'],
@@ -131,6 +155,9 @@ describe('media utilities', () => {
expect(mediaErrorKey('import_url_not_allowed')).toBe(
'import_url_not_allowed',
)
expect(mediaErrorKey('profile_photo_required')).toBe(
'profile_photo_required',
)
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+2 -2
View File
@@ -44,8 +44,7 @@ export function orderMedia(
return sortOrder === 'oldest'
? orderMediaOldestFirst(media)
: [...media].sort(
(left, right) =>
right.createdAt - left.createdAt || right.id - left.id,
(left, right) => right.createdAt - left.createdAt || right.id - left.id,
)
}
@@ -110,6 +109,7 @@ export function mediaErrorKey(error?: string): string {
'not_found',
'operation_in_progress',
'owner_changed',
'profile_photo_required',
'rate_limited',
'request_failed',
'request_timeout',
@@ -17,7 +17,7 @@ const localeSource = readFileSync(
)
describe('FlareApp profile editing contract', () => {
it('opens the central photo source picker while creating an account', () => {
it('shows direct Gallery and Camera actions while creating an account', () => {
const onboardingStart = source.indexOf(
'<template v-else-if="!flare.profile">',
)
@@ -29,27 +29,68 @@ describe('FlareApp profile editing contract', () => {
expect(onboardingStart).toBeGreaterThan(-1)
expect(onboardingEnd).toBeGreaterThan(onboardingStart)
expect(onboarding).toContain('aria-controls="flare-photo-source-sheet"')
expect(onboarding).toContain('aria-haspopup="dialog"')
expect(onboarding).toContain('@click="openPhotoSourcePicker"')
expect(onboarding).not.toContain("openProfileMediaApp('photos')")
expect(onboarding).not.toContain("openProfileMediaApp('camera')")
expect(onboarding).toContain("openProfileMediaApp('photos')")
expect(onboarding).toContain("openProfileMediaApp('camera')")
expect(onboarding).toContain("phone.t('Apps.flare.choosePhotos')")
expect(onboarding).toContain("phone.t('Apps.flare.takePhoto')")
expect(onboarding).not.toContain('@click="openPhotoSourcePicker"')
})
it('uses the same Gallery and Camera picker for onboarding and editing', () => {
it('uses the central anchored SkyDropdown for photo sources while editing', () => {
const triggerClass = source.indexOf('class="flare-photo-add"')
const triggerStart = source.lastIndexOf('<sky-button', triggerClass)
const triggerEnd = source.indexOf('</sky-button>', triggerClass)
const trigger = source.slice(triggerStart, triggerEnd)
const dropdownStart = source.search(/<SkyDropdown\b/)
const dropdownEnd = source.indexOf('/>', dropdownStart)
const dropdown = source.slice(dropdownStart, dropdownEnd)
const pickerStart = source.indexOf('function openPhotoSourcePicker')
const pickerEnd = source.indexOf(
'function openProfileMediaApp',
pickerStart,
)
const picker = source.slice(pickerStart, pickerEnd)
const mediaAppStart = source.indexOf('function openProfileMediaApp')
const mediaAppEnd = source.indexOf('function removeDraftPhoto')
const mediaApp = source.slice(mediaAppStart, mediaAppEnd)
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(2)
expect(source).toMatch(
/<sky-action-sheet\s+id=["']flare-photo-source-sheet["']/,
expect(triggerClass).toBeGreaterThan(-1)
expect(triggerStart).toBeGreaterThan(-1)
expect(triggerEnd).toBeGreaterThan(triggerStart)
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(1)
expect(trigger).toContain('aria-controls="flare-photo-source-menu"')
expect(trigger).toContain('aria-haspopup="menu"')
expect(trigger).toContain(':aria-expanded="photoSourceOpened"')
expect(trigger).not.toContain('aria-haspopup="dialog"')
expect(dropdownStart).toBeGreaterThan(-1)
expect(dropdownEnd).toBeGreaterThan(dropdownStart)
expect(dropdown).toContain('id="flare-photo-source-menu"')
expect(dropdown).toContain(':items="photoSourceItems"')
expect(dropdown).toMatch(/:label="phone\.t\('Apps\.flare\.addPhotos'\)"/)
expect(dropdown).toContain(':opened="photoSourceOpened"')
expect(dropdown).toContain(':target="photoSourceTarget"')
expect(dropdown).toContain('@backdropclick="closePhotoSourcePicker"')
expect(dropdown).toContain('@escape="closePhotoSourcePicker"')
expect(dropdown).toContain('@positionerror="closePhotoSourcePicker"')
expect(dropdown).toContain('@select="selectPhotoSource"')
expect(source).toContain(
"{ id: 'photos', label: phone.t('Apps.flare.choosePhotos') }",
)
expect(source).toContain(
'<sky-action-button bold @click="openProfileMediaApp(\'photos\')">',
"{ id: 'camera', label: phone.t('Apps.flare.takePhoto') }",
)
expect(source).toContain(
'<sky-action-button @click="openProfileMediaApp(\'camera\')">',
expect(pickerStart).toBeGreaterThan(-1)
expect(pickerEnd).toBeGreaterThan(pickerStart)
expect(picker).toContain('event.currentTarget instanceof HTMLElement')
expect(picker).toContain('photoSourceTarget.value = event.currentTarget')
expect(picker).toContain("if (id !== 'photos' && id !== 'camera') return")
expect(picker).toContain('openProfileMediaApp(id)')
expect(source).not.toContain('flare-photo-source-sheet')
expect(source).not.toMatch(
/<sky-action-sheet\b[\s\S]*?openProfileMediaApp\(['"](?:photos|camera)['"]\)[\s\S]*?<\/sky-action-sheet>/i,
)
expect(mediaAppStart).toBeGreaterThan(-1)
expect(mediaAppEnd).toBeGreaterThan(mediaAppStart)
@@ -60,6 +101,51 @@ describe('FlareApp profile editing contract', () => {
expect(mediaApp).toContain("query: { mediaAttachment: 'photo' }")
})
it('requires one profile photo before creating or saving a profile', () => {
const saveStart = source.indexOf('async function saveProfile()')
const saveEnd = source.indexOf('\n\nfunction selectTab', saveStart)
const saveProfile = source.slice(saveStart, saveEnd)
expect(source).toContain(
'const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)',
)
expect(saveProfile).toContain('!hasRequiredProfilePhoto.value')
expect(
source.match(/:disabled="profileSaving \|\| !hasRequiredProfilePhoto"/g),
).toHaveLength(2)
expect(source).toContain('if (draftPhotos.value.length <= 1) return')
expect(source).toContain(
'if (flare.profile && draftPhotos.value.length === 0)',
)
expect(
source.match(
/<sky-link\s+v-if="draftPhotos\.length > 1"[\s\S]*?class="flare-photo-remove"/g,
),
).toHaveLength(2)
expect(source.match(/Apps\.flare\.profilePhotoRequired/g)).toHaveLength(2)
expect(serverSource).toMatch(
/#data\.photoMediaIds\s*<\s*1[\s\S]*?#data\.photoMediaIds\s*>\s*6/,
)
expect(localeSource).toContain(
'Add one to six photos from Photos or Camera.',
)
})
it('uses eligible in-game profile photos for Explorer covers', () => {
const explorerStart = source.indexOf("activeTab === 'explore'")
const explorerEnd = source.indexOf("activeTab === 'likes'", explorerStart)
const explorer = source.slice(explorerStart, explorerEnd)
expect(source).not.toContain('profiles-source.png')
expect(source).toContain('const exploreTiles = computed(')
expect(source).toContain('profile.photoUrls.length > 0')
expect(source).toContain("coverUrl: profile?.photoUrls[0] ?? ''")
expect(explorer).toContain(
':style="tile.coverUrl ? photoStyle(tile.coverUrl) : undefined"',
)
expect(explorer).not.toContain('avatarStyle(tile.avatar)')
})
it('opens the relationship goal editor from the profile summary card', () => {
const cardClass = source.indexOf('class="flare-profile-card"')
const cardStart = source.lastIndexOf('<sky-card', cardClass)
+112 -63
View File
@@ -1,9 +1,5 @@
<script setup lang="ts">
import {
SkyActionButton,
SkyActionGroup,
SkyActionSheet,
SkyActionsLabel,
SkyBadge,
SkyBlock,
SkyBlockTitle,
@@ -11,6 +7,7 @@ import {
SkyCard,
SkyDialog,
SkyDialogButton,
SkyDropdown,
SkyIcon,
SkyLink,
SkyList,
@@ -72,7 +69,6 @@ import {
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import profilesSprite from '@/assets/img/flare/profiles-source.png'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import SharedContentCard from '@/components/SharedContentCard.vue'
@@ -137,6 +133,7 @@ const profileEditing = ref(false)
const profileSettings = ref(false)
const profileSaving = ref(false)
const photoSourceOpened = ref(false)
const photoSourceTarget = ref<HTMLElement | null>(null)
const discoverySaving = ref(false)
const signOutDialogOpened = ref(false)
const deleteAccountDialogOpened = ref(false)
@@ -152,6 +149,10 @@ const gifResults = ref<GifSearchResult[]>([])
const gifLoading = ref(false)
const gifError = ref<string | null>(null)
const gifHasMore = ref(true)
const photoSourceItems = computed(() => [
{ id: 'photos', label: phone.t('Apps.flare.choosePhotos') },
{ id: 'camera', label: phone.t('Apps.flare.takePhoto') },
])
function shareProfile(): void {
const profile = flare.profile
@@ -207,6 +208,7 @@ const activeChoiceTitle = computed(() =>
choiceFieldLabel(activeChoiceField.value),
)
const activeChoiceValue = computed(() => profileDraft[activeChoiceField.value])
const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)
const filteredSuggestions = computed(() =>
activeExploreMode.value === 'all'
@@ -250,21 +252,35 @@ const cardStyle = computed(() => ({
transform: `translateX(${cardOffset.value}px) rotate(${cardOffset.value / 22}deg)`,
transition: dragging.value ? 'none' : undefined,
}))
const exploreTiles = [
{ avatar: 0, key: 'forYou', mode: 'all', tone: 'coral' },
{ avatar: 1, key: 'longTerm', mode: 'longTerm', tone: 'violet' },
{ avatar: 4, key: 'newFriends', mode: 'friends', tone: 'blue' },
{ avatar: 3, key: 'dateNight', mode: 'dates', tone: 'amber' },
const exploreTileDefinitions = [
{ key: 'forYou', mode: 'all', tone: 'coral' },
{ key: 'longTerm', mode: 'longTerm', tone: 'violet' },
{ key: 'newFriends', mode: 'friends', tone: 'blue' },
{ key: 'dateNight', mode: 'dates', tone: 'amber' },
] as const
const exploreTiles = computed(() => {
const profiles = flare.suggestions.filter(
(profile) => profile.photoUrls.length > 0,
)
return exploreTileDefinitions.map((tile) => {
const profile =
tile.mode === 'all'
? profiles[0]
: (profiles.find((candidate) => candidate.lookingFor === tile.mode) ??
profiles[0])
return {
...tile,
coverUrl: profile?.photoUrls[0] ?? '',
}
})
})
function avatarStyle(avatar: number): Record<string, string> {
const safeAvatar = Math.max(0, Math.min(5, Math.floor(avatar)))
const column = safeAvatar % 3
const row = Math.floor(safeAvatar / 3)
return {
backgroundImage: `url(${profilesSprite})`,
backgroundPosition: `${column * 50}% ${row * 100}%`,
backgroundSize: '300% 200%',
backgroundImage: `linear-gradient(${135 + safeAvatar * 12}deg, var(--flare), var(--flare-warm) 52%, var(--flare-ink))`,
backgroundPosition: 'center',
backgroundSize: 'cover',
}
}
@@ -347,8 +363,10 @@ function resetProfileDraft(): void {
ownPhotoIndex.value = 0
}
function openPhotoSourcePicker(): void {
function openPhotoSourcePicker(event: MouseEvent): void {
if (draftPhotos.value.length >= 6) return
if (!(event.currentTarget instanceof HTMLElement)) return
photoSourceTarget.value = event.currentTarget
photoSourceOpened.value = true
}
@@ -356,6 +374,11 @@ function closePhotoSourcePicker(): void {
photoSourceOpened.value = false
}
function selectPhotoSource(id: string): void {
if (id !== 'photos' && id !== 'camera') return
openProfileMediaApp(id)
}
function openProfileMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - draftPhotos.value.length
if (remaining < 1) return
@@ -378,6 +401,7 @@ function openProfileMediaApp(app: 'camera' | 'photos'): void {
}
function removeDraftPhoto(id: number): void {
if (draftPhotos.value.length <= 1) return
draftPhotos.value = draftPhotos.value.filter((photo) => photo.id !== id)
profileDraft.photoMediaIds = draftPhotos.value.map((photo) => photo.id)
}
@@ -585,7 +609,7 @@ async function refreshFlareState(): Promise<void> {
}
async function saveProfile(): Promise<void> {
if (profileSaving.value) return
if (profileSaving.value || !hasRequiredProfilePhoto.value) return
profileSaving.value = true
const creatingProfile = !flare.profile
try {
@@ -913,6 +937,10 @@ onMounted(async () => {
profileEditing.value = selection.context.editing
} else {
syncDraft()
if (flare.profile && draftPhotos.value.length === 0) {
activeTab.value = 'profile'
profileEditing.value = true
}
if (route.query.profileEdit === '1' && flare.profile) {
activeTab.value = 'profile'
profileEditing.value = true
@@ -1012,6 +1040,7 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.flare.primaryPhoto') }}
</span>
<sky-link
v-if="draftPhotos.length > 1"
component="button"
icon-only
class="flare-photo-remove"
@@ -1025,19 +1054,27 @@ onBeforeUnmount(() => {
<X />
</sky-link>
</div>
<sky-button
v-if="draftPhotos.length < 6"
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
aria-controls="flare-photo-source-sheet"
aria-haspopup="dialog"
@click="openPhotoSourcePicker"
>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</div>
<div
v-if="draftPhotos.length < 6"
class="flare-onboarding-photo-actions"
>
<sky-button outline rounded @click="openProfileMediaApp('photos')">
<Images :size="19" />
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
</sky-button>
<sky-button outline rounded @click="openProfileMediaApp('camera')">
<Camera :size="19" />
<span>{{ phone.t('Apps.flare.takePhoto') }}</span>
</sky-button>
</div>
<p
v-if="!hasRequiredProfilePhoto"
class="flare-photo-required"
role="status"
>
{{ phone.t('Apps.flare.profilePhotoRequired') }}
</p>
</sky-card>
<sky-list inset strong>
<sky-field
@@ -1104,7 +1141,7 @@ onBeforeUnmount(() => {
<sky-button
large
rounded
:disabled="profileSaving"
:disabled="profileSaving || !hasRequiredProfilePhoto"
:aria-busy="profileSaving"
@click="saveProfile"
>
@@ -1332,7 +1369,7 @@ onBeforeUnmount(() => {
"
component="button"
class="flare-navbar-done"
:disabled="profileSaving"
:disabled="profileSaving || !hasRequiredProfilePhoto"
:aria-busy="profileSaving"
@click="saveProfile"
>
@@ -1512,6 +1549,8 @@ onBeforeUnmount(() => {
<sky-card
v-for="tile in exploreTiles"
:key="tile.key"
component="button"
type="button"
:content-wrap="false"
class="flare-explore-card"
:class="`flare-explore-card--${tile.tone}`"
@@ -1519,7 +1558,8 @@ onBeforeUnmount(() => {
>
<div
class="flare-explore-card__photo"
:style="avatarStyle(tile.avatar)"
:class="{ 'is-fallback': !tile.coverUrl }"
:style="tile.coverUrl ? photoStyle(tile.coverUrl) : undefined"
/>
<div class="flare-explore-card__shade" />
<strong>{{
@@ -1819,6 +1859,7 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.flare.primaryPhoto') }}
</span>
<sky-link
v-if="draftPhotos.length > 1"
component="button"
icon-only
class="flare-photo-remove"
@@ -1837,14 +1878,22 @@ onBeforeUnmount(() => {
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
aria-controls="flare-photo-source-sheet"
aria-haspopup="dialog"
aria-controls="flare-photo-source-menu"
aria-haspopup="menu"
:aria-expanded="photoSourceOpened"
@click="openPhotoSourcePicker"
>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</sky-button>
</div>
<p
v-if="!hasRequiredProfilePhoto"
class="flare-photo-required"
role="status"
>
{{ phone.t('Apps.flare.profilePhotoRequired') }}
</p>
</sky-card>
<sky-list inset strong>
<sky-field
@@ -2028,36 +2077,17 @@ onBeforeUnmount(() => {
</div>
</div>
<sky-action-sheet
id="flare-photo-source-sheet"
:aria-label="phone.t('Apps.flare.addPhotos')"
<SkyDropdown
id="flare-photo-source-menu"
:items="photoSourceItems"
:label="phone.t('Apps.flare.addPhotos')"
:opened="photoSourceOpened"
:target="photoSourceTarget"
@backdropclick="closePhotoSourcePicker"
@escape="closePhotoSourcePicker"
>
<sky-action-group>
<sky-actions-label>
{{ phone.t('Apps.flare.addPhotos') }}
</sky-actions-label>
<sky-action-button bold @click="openProfileMediaApp('photos')">
<span class="flare-photo-source-action">
<Images :size="20" />
{{ phone.t('Apps.flare.choosePhotos') }}
</span>
</sky-action-button>
<sky-action-button @click="openProfileMediaApp('camera')">
<span class="flare-photo-source-action">
<Camera :size="20" />
{{ phone.t('Apps.flare.takePhoto') }}
</span>
</sky-action-button>
</sky-action-group>
<sky-action-group>
<sky-action-button @click="closePhotoSourcePicker">
{{ phone.t('Common.cancel') }}
</sky-action-button>
</sky-action-group>
</sky-action-sheet>
@positionerror="closePhotoSourcePicker"
@select="selectPhotoSource"
/>
<div class="flare-choice-sheet">
<sky-sheet
@@ -2596,6 +2626,9 @@ onBeforeUnmount(() => {
background-repeat: no-repeat;
filter: saturate(0.82);
}
.flare-explore-card__photo.is-fallback {
background: linear-gradient(145deg, var(--flare), var(--flare-warm));
}
.flare-explore-card__shade {
background: linear-gradient(15deg, rgb(0 0 0 / 72%), transparent 70%);
mix-blend-mode: multiply;
@@ -3158,11 +3191,27 @@ onBeforeUnmount(() => {
width: 25px;
height: 25px;
}
.flare-photo-source-action {
display: inline-flex;
align-items: center;
.flare-onboarding-photo-actions {
margin-top: var(--sky-space-3);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
}
.flare-onboarding-photo-actions :deep(.sky-button) {
min-width: 0;
padding-inline: var(--sky-space-2);
}
.flare-onboarding-photo-actions :deep(.sky-button span) {
overflow: hidden;
text-overflow: ellipsis;
}
.flare-photo-required {
margin: var(--sky-space-3) 0 0;
color: var(--flare);
font-size: var(--sky-font-caption);
font-weight: 650;
line-height: 1.35;
}
.flare-error {
margin: 8px 22px 0;
color: #ff3b30;
@@ -170,4 +170,28 @@ describe('GalleryApp import action', () => {
expect(source).toContain('id: 13 + index')
expect(source).not.toContain('picsum.photos')
})
it('routes single-image deletion through the browser API when apiPort is set', () => {
const deleteSelectedStart = source.indexOf(
'async function deleteSelected()',
)
const deleteSelected = source.slice(
deleteSelectedStart,
source.indexOf('function onMessage', deleteSelectedStart),
)
expect(deleteSelected).toContain(
'if (isDevelopment && !developmentApiEnabled)',
)
expect(deleteSelected).toContain(
"const response = await nuiCall('gallery:delete'",
)
expect(deleteSelected).toContain(
'if (isDevelopment && developmentApiEnabled)',
)
expect(deleteSelected).toContain("type: 'media:deleteResult'")
expect(deleteSelected).toContain('error: response.error')
expect(deleteSelected).toContain('success: response.success')
expect(deleteSelected).not.toMatch(/if \(isDevelopment\)\s*\{/)
})
})
+21 -5
View File
@@ -967,12 +967,13 @@ async function initializeVideo(event: Event): Promise<void> {
async function deleteSelected(): Promise<void> {
if (!selected.value || deleting.value) return
const selectedId = selected.value.id
deleting.value = true
deleteDialogOpened.value = false
pendingDeleteCorrelation = `${Date.now()}-${crypto.randomUUID()}`
if (isDevelopment) {
if (isDevelopment && !developmentApiEnabled) {
const developmentIndex = developmentMedia.findIndex(
(entry) => entry.id === selected.value?.id,
(entry) => entry.id === selectedId,
)
if (developmentIndex >= 0) developmentMedia.splice(developmentIndex, 1)
window.setTimeout(() => {
@@ -981,7 +982,7 @@ async function deleteSelected(): Promise<void> {
data: {
data: {
correlationId: pendingDeleteCorrelation,
id: selected.value?.id,
id: selectedId,
success: true,
},
type: 'media:deleteResult',
@@ -991,10 +992,25 @@ async function deleteSelected(): Promise<void> {
}, 500)
return
}
await nuiCall('gallery:delete', {
const response = await nuiCall('gallery:delete', {
correlationId: pendingDeleteCorrelation,
id: selected.value.id,
id: selectedId,
})
if (isDevelopment && developmentApiEnabled) {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId: pendingDeleteCorrelation,
error: response.error,
id: selectedId,
success: response.success,
},
type: 'media:deleteResult',
},
}),
)
}
}
function onMessage(event: MessageEvent): void {
+83 -31
View File
@@ -2878,6 +2878,19 @@ mockMedia.push(
url: mockGalleryImage(title, sky, landscape, accent),
})),
)
function mockPhotoUrls(ids) {
return ids.map((id) => {
const photo = mockMedia.find(
(item) => item.id === id && item.mediaType === 'photo',
)
if (!photo) {
throw new Error(`Missing mock gallery photo ${id}`)
}
return photo.url
})
}
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
const weazelNewsMaxImages = 6
let weazelNewsSequence = 8
@@ -3452,8 +3465,8 @@ let flareProfile = {
interests: ['Night drives', 'Music', 'Coffee'],
lookingFor: 'dates',
discoverable: true,
photoMediaIds: [],
photoUrls: [],
photoMediaIds: [1, 3],
photoUrls: mockPhotoUrls([1, 3]),
}
let flareSuggestions = [
{
@@ -3465,7 +3478,7 @@ let flareSuggestions = [
avatar: 0,
interests: ['Beach days', 'Food spots', 'Art'],
lookingFor: 'longTerm',
photoUrls: [],
photoUrls: mockPhotoUrls([3, 7]),
},
{
id: 12,
@@ -3476,7 +3489,7 @@ let flareSuggestions = [
avatar: 1,
interests: ['Architecture', 'Karaoke', 'Travel'],
lookingFor: 'dates',
photoUrls: [],
photoUrls: mockPhotoUrls([4, 11]),
},
{
id: 13,
@@ -3487,7 +3500,7 @@ let flareSuggestions = [
avatar: 2,
interests: ['Coffee', 'Photography', 'Dogs'],
lookingFor: 'friends',
photoUrls: [],
photoUrls: mockPhotoUrls([5, 9]),
},
{
id: 14,
@@ -3498,7 +3511,7 @@ let flareSuggestions = [
avatar: 3,
interests: ['Cars', 'Road trips', 'Vinyl'],
lookingFor: 'longTerm',
photoUrls: [],
photoUrls: mockPhotoUrls([8, 13]),
},
{
id: 15,
@@ -3509,7 +3522,7 @@ let flareSuggestions = [
avatar: 4,
interests: ['Sailing', 'Fitness', 'Brunch'],
lookingFor: 'dates',
photoUrls: [],
photoUrls: mockPhotoUrls([12, 14]),
},
]
const flareSuggestionFixtures = flareSuggestions.map((profile) => ({
@@ -3529,7 +3542,7 @@ const flareMatches = [
avatar: 5,
interests: ['Live music', 'Cooking'],
lookingFor: 'dates',
photoUrls: [],
photoUrls: mockPhotoUrls([15, 16]),
},
lastMessage: 'Friday night jazz',
lastMessageAt: isoTime(-18 * 60 * 1000),
@@ -3600,6 +3613,36 @@ function freshFlareSuggestions() {
}))
}
function flarePhotoRemovalWouldEmptyProfile(mediaIds) {
if (!flareProfile || !Array.isArray(flareProfile.photoMediaIds)) {
return false
}
const currentIds = flareProfile.photoMediaIds
const removedIds = new Set(mediaIds)
return (
currentIds.length > 0 &&
currentIds.some((id) => removedIds.has(id)) &&
currentIds.every((id) => removedIds.has(id))
)
}
function removeFlareProfilePhotos(mediaIds) {
if (!flareProfile || !Array.isArray(flareProfile.photoMediaIds)) return
const removedIds = new Set(mediaIds)
const photoMediaIds = []
const photoUrls = []
flareProfile.photoMediaIds.forEach((id, index) => {
if (removedIds.has(id)) return
photoMediaIds.push(id)
photoUrls.push(flareProfile.photoUrls[index])
})
flareProfile = {
...flareProfile,
photoMediaIds,
photoUrls,
}
}
const companyCategories = [
{ id: 'public_services', name: 'Public Services' },
{ id: 'medical', name: 'Medical' },
@@ -6437,28 +6480,26 @@ app.post('/api/:endpoint', (request, response) => {
}
if (endpoint === 'flare:save-profile') {
const requestedPhotoIds = request.body.photoMediaIds
let photoUpdate = {}
if (requestedPhotoIds !== undefined) {
const validIds =
Array.isArray(requestedPhotoIds) &&
requestedPhotoIds.length <= 6 &&
new Set(requestedPhotoIds).size === requestedPhotoIds.length &&
requestedPhotoIds.every((id) => Number.isInteger(id) && id > 0)
const photos = validIds
? requestedPhotoIds.map((id) =>
mockMedia.find(
(item) => item.id === id && item.mediaType === 'photo',
),
)
: []
if (!validIds || photos.some((photo) => !photo)) {
response.json({ success: false, error: 'invalid_profile_photos' })
return
}
photoUpdate = {
photoMediaIds: [...requestedPhotoIds],
photoUrls: photos.map((photo) => photo.url),
}
const validIds =
Array.isArray(requestedPhotoIds) &&
requestedPhotoIds.length >= 1 &&
requestedPhotoIds.length <= 6 &&
new Set(requestedPhotoIds).size === requestedPhotoIds.length &&
requestedPhotoIds.every((id) => Number.isInteger(id) && id > 0)
const photos = validIds
? requestedPhotoIds.map((id) =>
mockMedia.find(
(item) => item.id === id && item.mediaType === 'photo',
),
)
: []
if (!validIds || photos.some((photo) => !photo)) {
response.json({ success: false, error: 'invalid_profile_photos' })
return
}
const photoUpdate = {
photoMediaIds: [...requestedPhotoIds],
photoUrls: photos.map((photo) => photo.url),
}
flareProfile = {
...flareProfile,
@@ -9109,7 +9150,13 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'gallery:delete') {
mockMedia = mockMedia.filter((item) => item.id !== Number(request.body.id))
const mediaId = Number(request.body.id)
if (flarePhotoRemovalWouldEmptyProfile([mediaId])) {
response.json({ success: false, error: 'profile_photo_required' })
return
}
mockMedia = mockMedia.filter((item) => item.id !== mediaId)
removeFlareProfilePhotos([mediaId])
response.json({ success: true })
return
}
@@ -9133,7 +9180,12 @@ app.post('/api/:endpoint', (request, response) => {
const deletedIds = mockMedia
.filter((item) => ids.includes(item.id))
.map((item) => item.id)
if (flarePhotoRemovalWouldEmptyProfile(deletedIds)) {
response.json({ success: false, error: 'profile_photo_required' })
return
}
mockMedia = mockMedia.filter((item) => !deletedIds.includes(item.id))
removeFlareProfilePhotos(deletedIds)
response.json({
success: true,
data: {
+108 -1
View File
@@ -852,10 +852,92 @@ async function verifyStatefulActions(baseUrl) {
true,
)
assert(flareBeforeDelete.profile, 'flare:bootstrap did not include a profile')
assert(
flareBeforeDelete.profile.photoMediaIds.length >= 1 &&
flareBeforeDelete.profile.photoMediaIds.length <= 6,
'flare:bootstrap profile did not include one to six gallery photos',
)
assert.equal(
flareBeforeDelete.profile.photoMediaIds.length,
flareBeforeDelete.profile.photoUrls.length,
'flare:bootstrap profile photo IDs and URLs were out of sync',
)
const galleryPhotoUrls = new Set(
gallery
.filter((item) => item.mediaType === 'photo')
.map((item) => item.url),
)
assert(
flareBeforeDelete.profile.photoUrls.every((url) =>
galleryPhotoUrls.has(url),
),
'flare:bootstrap profile used photos outside the phone gallery',
)
assert(
flareBeforeDelete.suggestions.every(
(profile) =>
profile.photoUrls.length >= 1 &&
profile.photoUrls.every((url) => galleryPhotoUrls.has(url)),
),
'Flare suggestions used photos outside the phone gallery',
)
assert(
flareBeforeDelete.matches.length > 0,
'flare:bootstrap did not include a deletable match',
)
assert(
flareBeforeDelete.matches.every(
(match) =>
match.profile.photoUrls.length >= 1 &&
match.profile.photoUrls.every((url) => galleryPhotoUrls.has(url)),
),
'Flare matches used photos outside the phone gallery',
)
const [removedProfilePhotoId, retainedProfilePhotoId] =
flareBeforeDelete.profile.photoMediaIds
await expectSuccess(baseUrl, 'gallery:delete', {
id: removedProfilePhotoId,
})
const flareAfterGalleryDelete = await expectSuccess(
baseUrl,
'flare:bootstrap',
{},
true,
)
assert.deepEqual(
flareAfterGalleryDelete.profile.photoMediaIds,
[retainedProfilePhotoId],
'gallery:delete did not remove the deleted photo from the Flare profile',
)
assert.deepEqual(
flareAfterGalleryDelete.profile.photoUrls,
[gallery.find((item) => item.id === retainedProfilePhotoId)?.url],
'gallery:delete left Flare photo IDs and URLs out of sync',
)
const rejectedLastPhotoDelete = await post(baseUrl, 'gallery:delete', {
id: retainedProfilePhotoId,
})
assert.deepEqual(rejectedLastPhotoDelete, {
error: 'profile_photo_required',
success: false,
})
const nonProfilePhotoId = gallery.find(
(item) =>
item.mediaType === 'photo' &&
!flareBeforeDelete.profile.photoMediaIds.includes(item.id),
)?.id
const rejectedBulkLastPhotoDelete = await post(
baseUrl,
'gallery:delete-many',
{
correlationId: 'flare-last-photo-protection',
ids: [retainedProfilePhotoId, nonProfilePhotoId],
},
)
assert.deepEqual(rejectedBulkLastPhotoDelete, {
error: 'profile_photo_required',
success: false,
})
const swipedProfile = flareBeforeDelete.suggestions[0]
await expectSuccess(baseUrl, 'flare:swipe', {
choice: 'pass',
@@ -889,12 +971,32 @@ async function verifyStatefulActions(baseUrl) {
error: 'profile_not_found',
success: false,
})
const rejectedEmptyFlare = await post(baseUrl, 'flare:save-profile', {
...flareBeforeDelete.profile,
photoMediaIds: [],
photoUrls: undefined,
})
assert.deepEqual(rejectedEmptyFlare, {
error: 'invalid_profile_photos',
success: false,
})
const flareStillDeleted = await expectSuccess(
baseUrl,
'flare:bootstrap',
{},
true,
)
assert.equal(
flareStillDeleted.profile,
null,
'an invalid empty Flare profile was persisted',
)
const recreatedFlare = await expectSuccess(
baseUrl,
'flare:save-profile',
{
...flareBeforeDelete.profile,
photoMediaIds: [],
photoMediaIds: [retainedProfilePhotoId],
photoUrls: undefined,
},
true,
@@ -905,6 +1007,11 @@ async function verifyStatefulActions(baseUrl) {
),
'recreated Flare profile retained a deleted swipe filter',
)
assert.deepEqual(
recreatedFlare.profile.photoMediaIds,
[retainedProfilePhotoId],
'recreated Flare profile did not retain its valid gallery photo',
)
await expectSuccess(baseUrl, 'account:logout')
const signedOutFlareBootstrap = await post(baseUrl, 'flare:bootstrap')
+3 -3
View File
@@ -419,7 +419,7 @@ Locales["en"] = {
flare = {
name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private Sky Cloud account.",
createProfile = "Create your profile", welcome = "Find your spark", welcomeBody = "Build a profile and meet people across Los Santos.",
photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Add up to six photos from Photos or Camera. Your first photo is shown first.", addPhotos = "Add photos", choosePhotos = "Choose from Photos", primaryPhoto = "Main", removePhoto = "Remove photo {number}",
photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Add one to six photos from Photos or Camera. Your first photo is shown first.", profilePhotoRequired = "Add at least one profile photo to continue.", addPhotos = "Add photos", choosePhotos = "Choose from Photos", primaryPhoto = "Main", removePhoto = "Remove photo {number}",
profilePhotoFallback = "Flare profile avatar", profilePhotoNumber = "Profile photo {number} of {count}", viewProfilePhoto = "Show profile photo {number} of {count}",
yourName = "Your name", namePlaceholder = "What should people call you?", age = "Age", bio = "About you", bioPlaceholder = "A short line that starts a conversation...",
gender = "I am", showMe = "Show me", woman = "Woman", man = "Man", nonbinary = "Non-binary", women = "Women", men = "Men", nonbinaryPeople = "Non-binary people", everyone = "Everyone",
@@ -444,7 +444,7 @@ Locales["en"] = {
messagePlaceholder = "Write a message", itsAMatch = "It's a spark!", matchBody = "You and {name} liked each other.", sayHello = "Say hello", keepSwiping = "Keep exploring", newMatchNotification = "You and {sender} found a spark!", newMessageNotification = "New message from {sender}",
lookingFor = { longTerm = "Long-term connection", dates = "Good dates", friends = "New friends" },
errors = {
invalid_profile = "Check your name, age and profile text.", profile_not_found = "This Flare account is no longer available.", invalid_profile_photos = "Choose or take up to six photos saved in your own Photos library.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.",
invalid_profile = "Check your name, age and profile text.", profile_not_found = "This Flare account is no longer available.", invalid_profile_photos = "Choose or take at least one and up to six photos saved in your own Photos library.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.",
nothing_to_rewind = "There is no recent swipe to rewind.", cannot_rewind_match = "A swipe that created a match cannot be rewound.",
match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.",
},
@@ -1810,7 +1810,7 @@ Locales["en"] = {
import_source_unavailable = "This website is temporarily unavailable.",
invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.",
import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.",
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed.",
not_found = "The media item no longer exists.", profile_photo_required = "This is the last photo on your Flare profile. Add another profile photo before deleting it.", owner_changed = "The active phone account changed.",
operation_in_progress = "Another media operation is already in progress.",
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Photos request failed.",
request_timeout = "The media service timed out.", unsupported = "This media format is not supported.",
+38 -25
View File
@@ -155,7 +155,11 @@ local function load_profile(account_id)
end
local function list_suggestions(account_id, profile)
if not profile or not is_enabled(profile.discoverable) then
if not profile
or not is_enabled(profile.discoverable)
or type(profile.photo_urls) ~= "table"
or #profile.photo_urls < 1
then
return {}
end
local rows = Bridge.Database.Query([[
@@ -178,6 +182,16 @@ local function list_suggestions(account_id, profile)
AND mine.`age` BETWEEN target.`min_age` AND target.`max_age`
AND (mine.`interested_in` = 'everyone' OR mine.`interested_in` = target.`gender`)
AND (target.`interested_in` = 'everyone' OR target.`interested_in` = mine.`gender`)
AND EXISTS (
SELECT 1
FROM `sky_phone_flare_profile_photos` target_photo
JOIN `sky_phone_media` target_media
ON target_media.`id` = target_photo.`media_id`
AND target_media.`account_id` = target.`account_id`
AND target_media.`media_type` = 'photo'
WHERE target_photo.`profile_id` = target.`id`
AND target_media.`url` LIKE 'https://%'
)
ORDER BY target.`updated_at` DESC, target.`id`
LIMIT 30
]], { account_id })
@@ -313,32 +327,31 @@ local function validate_profile(source, data)
end
end
end
local photo_media_ids = nil
local replace_photos = data.photoMediaIds ~= nil
if replace_photos then
if type(data.photoMediaIds) ~= "table" or #data.photoMediaIds > 6 then
if type(data.photoMediaIds) ~= "table"
or #data.photoMediaIds < 1
or #data.photoMediaIds > 6
then
return nil, "invalid_profile_photos"
end
local photo_media_ids = {}
local seen_media = {}
for key in pairs(data.photoMediaIds) do
if type(key) ~= "number" or key < 1 or key > #data.photoMediaIds
or key ~= math.floor(key)
then
return nil, "invalid_profile_photos"
end
photo_media_ids = {}
local seen_media = {}
for key in pairs(data.photoMediaIds) do
if type(key) ~= "number" or key < 1 or key > #data.photoMediaIds
or key ~= math.floor(key)
then
return nil, "invalid_profile_photos"
end
end
for _, value in ipairs(data.photoMediaIds) do
local media_id = tonumber(value)
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
or seen_media[media_id]
or not SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")
then
return nil, "invalid_profile_photos"
end
seen_media[media_id] = true
photo_media_ids[#photo_media_ids + 1] = media_id
end
for _, value in ipairs(data.photoMediaIds) do
local media_id = tonumber(value)
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
or seen_media[media_id]
or not SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")
then
return nil, "invalid_profile_photos"
end
seen_media[media_id] = true
photo_media_ids[#photo_media_ids + 1] = media_id
end
return {
name = name,
@@ -352,7 +365,7 @@ local function validate_profile(source, data)
interests = json.encode(clean_interests),
looking_for = data.lookingFor,
photo_media_ids = photo_media_ids,
replace_photos = replace_photos,
replace_photos = true,
}, nil
end
+26 -1
View File
@@ -796,6 +796,28 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
upload_result(src, state.correlation_id, false, error_code)
end)
local function is_required_flare_profile_photo(media_id)
local rows = Bridge.Database.Query([[
SELECT photo.`profile_id`
FROM `sky_phone_flare_profile_photos` photo
JOIN `sky_phone_flare_profiles` profile ON profile.`id` = photo.`profile_id`
WHERE photo.`media_id` = ?
AND NOT EXISTS (
SELECT 1
FROM `sky_phone_flare_profile_photos` other_photo
JOIN `sky_phone_media` other_media
ON other_media.`id` = other_photo.`media_id`
AND other_media.`account_id` = profile.`account_id`
AND other_media.`media_type` = 'photo'
WHERE other_photo.`profile_id` = photo.`profile_id`
AND other_photo.`media_id` <> photo.`media_id`
AND other_media.`url` LIKE 'https://%'
)
LIMIT 1
]], { media_id })
return rows[1] ~= nil
end
local function delete_owned_media(src, owner, media_id)
local condition, params = owner_condition(owner)
local query_params = { media_id }
@@ -803,13 +825,16 @@ local function delete_owned_media(src, owner, media_id)
query_params[#query_params + 1] = value
end
local rows = Bridge.Database.Query(([[
SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media`
SELECT `id`, `remote_id`, `origin`, `media_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') LIMIT 1
]]):format(condition), query_params)
local row = rows[1]
if not row then
return false, "not_found"
end
if row.media_type == "photo" and is_required_flare_profile_photo(media_id) then
return false, "profile_photo_required"
end
local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id)
if pending_deletes[delete_key] then
return false, "operation_in_progress"