mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
fix(flare): enforce real profile photos
This commit is contained in:
@@ -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\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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*\{/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user