mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +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 () => {
|
it('uses a real Super Like and removes the target from both decks', async () => {
|
||||||
const match: FlareMatch = {
|
const match: FlareMatch = {
|
||||||
id: 'match-1',
|
id: 'match-1',
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ import type {
|
|||||||
} from '@/types/flare'
|
} from '@/types/flare'
|
||||||
import { nuiCall } from '@/utils/nui'
|
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', {
|
export const useFlareStore = defineStore('flare', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
activeMatchId: '' as string,
|
activeMatchId: '' as string,
|
||||||
@@ -54,6 +66,10 @@ export const useFlareStore = defineStore('flare', {
|
|||||||
return response.success
|
return response.success
|
||||||
},
|
},
|
||||||
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
|
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
|
||||||
|
if (!hasValidProfilePhotos(draft.photoMediaIds)) {
|
||||||
|
this.error = 'invalid_profile_photos'
|
||||||
|
return false
|
||||||
|
}
|
||||||
const response = await nuiCall<FlareBootstrap>(
|
const response = await nuiCall<FlareBootstrap>(
|
||||||
'flare:save-profile',
|
'flare:save-profile',
|
||||||
draft,
|
draft,
|
||||||
|
|||||||
@@ -867,7 +867,8 @@ const defaultLocales: LocaleTree = {
|
|||||||
photo: 'Profile photo',
|
photo: 'Profile photo',
|
||||||
profilePhotos: 'Profile photos',
|
profilePhotos: 'Profile photos',
|
||||||
profilePhotosBody:
|
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',
|
addPhotos: 'Add photos',
|
||||||
choosePhotos: 'Choose from Photos',
|
choosePhotos: 'Choose from Photos',
|
||||||
primaryPhoto: 'Main',
|
primaryPhoto: 'Main',
|
||||||
@@ -1006,7 +1007,7 @@ const defaultLocales: LocaleTree = {
|
|||||||
invalid_profile: 'Check your name, age and profile text.',
|
invalid_profile: 'Check your name, age and profile text.',
|
||||||
profile_not_found: 'This Flare account is no longer available.',
|
profile_not_found: 'This Flare account is no longer available.',
|
||||||
invalid_profile_photos:
|
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.',
|
request_failed: 'Flare could not save those changes. Try again.',
|
||||||
invalid_target: 'This profile is no longer available.',
|
invalid_target: 'This profile is no longer available.',
|
||||||
invalid_choice: 'That swipe could not be saved.',
|
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_url_unavailable: 'The linked media could not be reached.',
|
||||||
import_size_unavailable: 'The website did not provide the media size.',
|
import_size_unavailable: 'The website did not provide the media size.',
|
||||||
not_found: 'The media item no longer exists.',
|
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:
|
operation_in_progress:
|
||||||
'Another media operation is already in progress.',
|
'Another media operation is already in progress.',
|
||||||
owner_changed: 'The active phone account changed.',
|
owner_changed: 'The active phone account changed.',
|
||||||
|
|||||||
@@ -13,8 +13,20 @@ import {
|
|||||||
} from './media'
|
} from './media'
|
||||||
|
|
||||||
const 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', () => {
|
describe('media utilities', () => {
|
||||||
@@ -27,8 +39,20 @@ describe('media utilities', () => {
|
|||||||
it('merges pages without duplicates and keeps newest first', () => {
|
it('merges pages without duplicates and keeps newest first', () => {
|
||||||
expect(
|
expect(
|
||||||
mergeMedia(media, [
|
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]),
|
]).map((entry) => [entry.id, entry.url]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
[1, 'updated'],
|
[1, 'updated'],
|
||||||
@@ -131,6 +155,9 @@ describe('media utilities', () => {
|
|||||||
expect(mediaErrorKey('import_url_not_allowed')).toBe(
|
expect(mediaErrorKey('import_url_not_allowed')).toBe(
|
||||||
'import_url_not_allowed',
|
'import_url_not_allowed',
|
||||||
)
|
)
|
||||||
|
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||||
|
'profile_photo_required',
|
||||||
|
)
|
||||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ export function orderMedia(
|
|||||||
return sortOrder === 'oldest'
|
return sortOrder === 'oldest'
|
||||||
? orderMediaOldestFirst(media)
|
? orderMediaOldestFirst(media)
|
||||||
: [...media].sort(
|
: [...media].sort(
|
||||||
(left, right) =>
|
(left, right) => right.createdAt - left.createdAt || right.id - left.id,
|
||||||
right.createdAt - left.createdAt || right.id - left.id,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +109,7 @@ export function mediaErrorKey(error?: string): string {
|
|||||||
'not_found',
|
'not_found',
|
||||||
'operation_in_progress',
|
'operation_in_progress',
|
||||||
'owner_changed',
|
'owner_changed',
|
||||||
|
'profile_photo_required',
|
||||||
'rate_limited',
|
'rate_limited',
|
||||||
'request_failed',
|
'request_failed',
|
||||||
'request_timeout',
|
'request_timeout',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const localeSource = readFileSync(
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe('FlareApp profile editing contract', () => {
|
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(
|
const onboardingStart = source.indexOf(
|
||||||
'<template v-else-if="!flare.profile">',
|
'<template v-else-if="!flare.profile">',
|
||||||
)
|
)
|
||||||
@@ -29,27 +29,68 @@ describe('FlareApp profile editing contract', () => {
|
|||||||
|
|
||||||
expect(onboardingStart).toBeGreaterThan(-1)
|
expect(onboardingStart).toBeGreaterThan(-1)
|
||||||
expect(onboardingEnd).toBeGreaterThan(onboardingStart)
|
expect(onboardingEnd).toBeGreaterThan(onboardingStart)
|
||||||
expect(onboarding).toContain('aria-controls="flare-photo-source-sheet"')
|
expect(onboarding).toContain("openProfileMediaApp('photos')")
|
||||||
expect(onboarding).toContain('aria-haspopup="dialog"')
|
expect(onboarding).toContain("openProfileMediaApp('camera')")
|
||||||
expect(onboarding).toContain('@click="openPhotoSourcePicker"')
|
expect(onboarding).toContain("phone.t('Apps.flare.choosePhotos')")
|
||||||
expect(onboarding).not.toContain("openProfileMediaApp('photos')")
|
expect(onboarding).toContain("phone.t('Apps.flare.takePhoto')")
|
||||||
expect(onboarding).not.toContain("openProfileMediaApp('camera')")
|
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 mediaAppStart = source.indexOf('function openProfileMediaApp')
|
||||||
const mediaAppEnd = source.indexOf('function removeDraftPhoto')
|
const mediaAppEnd = source.indexOf('function removeDraftPhoto')
|
||||||
const mediaApp = source.slice(mediaAppStart, mediaAppEnd)
|
const mediaApp = source.slice(mediaAppStart, mediaAppEnd)
|
||||||
|
|
||||||
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(2)
|
expect(triggerClass).toBeGreaterThan(-1)
|
||||||
expect(source).toMatch(
|
expect(triggerStart).toBeGreaterThan(-1)
|
||||||
/<sky-action-sheet\s+id=["']flare-photo-source-sheet["']/,
|
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(
|
expect(source).toContain(
|
||||||
'<sky-action-button bold @click="openProfileMediaApp(\'photos\')">',
|
"{ id: 'camera', label: phone.t('Apps.flare.takePhoto') }",
|
||||||
)
|
)
|
||||||
expect(source).toContain(
|
expect(pickerStart).toBeGreaterThan(-1)
|
||||||
'<sky-action-button @click="openProfileMediaApp(\'camera\')">',
|
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(mediaAppStart).toBeGreaterThan(-1)
|
||||||
expect(mediaAppEnd).toBeGreaterThan(mediaAppStart)
|
expect(mediaAppEnd).toBeGreaterThan(mediaAppStart)
|
||||||
@@ -60,6 +101,51 @@ describe('FlareApp profile editing contract', () => {
|
|||||||
expect(mediaApp).toContain("query: { mediaAttachment: 'photo' }")
|
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', () => {
|
it('opens the relationship goal editor from the profile summary card', () => {
|
||||||
const cardClass = source.indexOf('class="flare-profile-card"')
|
const cardClass = source.indexOf('class="flare-profile-card"')
|
||||||
const cardStart = source.lastIndexOf('<sky-card', cardClass)
|
const cardStart = source.lastIndexOf('<sky-card', cardClass)
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
SkyActionButton,
|
|
||||||
SkyActionGroup,
|
|
||||||
SkyActionSheet,
|
|
||||||
SkyActionsLabel,
|
|
||||||
SkyBadge,
|
SkyBadge,
|
||||||
SkyBlock,
|
SkyBlock,
|
||||||
SkyBlockTitle,
|
SkyBlockTitle,
|
||||||
@@ -11,6 +7,7 @@ import {
|
|||||||
SkyCard,
|
SkyCard,
|
||||||
SkyDialog,
|
SkyDialog,
|
||||||
SkyDialogButton,
|
SkyDialogButton,
|
||||||
|
SkyDropdown,
|
||||||
SkyIcon,
|
SkyIcon,
|
||||||
SkyLink,
|
SkyLink,
|
||||||
SkyList,
|
SkyList,
|
||||||
@@ -72,7 +69,6 @@ import {
|
|||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import profilesSprite from '@/assets/img/flare/profiles-source.png'
|
|
||||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||||
import SharedContentCard from '@/components/SharedContentCard.vue'
|
import SharedContentCard from '@/components/SharedContentCard.vue'
|
||||||
@@ -137,6 +133,7 @@ const profileEditing = ref(false)
|
|||||||
const profileSettings = ref(false)
|
const profileSettings = ref(false)
|
||||||
const profileSaving = ref(false)
|
const profileSaving = ref(false)
|
||||||
const photoSourceOpened = ref(false)
|
const photoSourceOpened = ref(false)
|
||||||
|
const photoSourceTarget = ref<HTMLElement | null>(null)
|
||||||
const discoverySaving = ref(false)
|
const discoverySaving = ref(false)
|
||||||
const signOutDialogOpened = ref(false)
|
const signOutDialogOpened = ref(false)
|
||||||
const deleteAccountDialogOpened = ref(false)
|
const deleteAccountDialogOpened = ref(false)
|
||||||
@@ -152,6 +149,10 @@ const gifResults = ref<GifSearchResult[]>([])
|
|||||||
const gifLoading = ref(false)
|
const gifLoading = ref(false)
|
||||||
const gifError = ref<string | null>(null)
|
const gifError = ref<string | null>(null)
|
||||||
const gifHasMore = ref(true)
|
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 {
|
function shareProfile(): void {
|
||||||
const profile = flare.profile
|
const profile = flare.profile
|
||||||
@@ -207,6 +208,7 @@ const activeChoiceTitle = computed(() =>
|
|||||||
choiceFieldLabel(activeChoiceField.value),
|
choiceFieldLabel(activeChoiceField.value),
|
||||||
)
|
)
|
||||||
const activeChoiceValue = computed(() => profileDraft[activeChoiceField.value])
|
const activeChoiceValue = computed(() => profileDraft[activeChoiceField.value])
|
||||||
|
const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)
|
||||||
|
|
||||||
const filteredSuggestions = computed(() =>
|
const filteredSuggestions = computed(() =>
|
||||||
activeExploreMode.value === 'all'
|
activeExploreMode.value === 'all'
|
||||||
@@ -250,21 +252,35 @@ const cardStyle = computed(() => ({
|
|||||||
transform: `translateX(${cardOffset.value}px) rotate(${cardOffset.value / 22}deg)`,
|
transform: `translateX(${cardOffset.value}px) rotate(${cardOffset.value / 22}deg)`,
|
||||||
transition: dragging.value ? 'none' : undefined,
|
transition: dragging.value ? 'none' : undefined,
|
||||||
}))
|
}))
|
||||||
const exploreTiles = [
|
const exploreTileDefinitions = [
|
||||||
{ avatar: 0, key: 'forYou', mode: 'all', tone: 'coral' },
|
{ key: 'forYou', mode: 'all', tone: 'coral' },
|
||||||
{ avatar: 1, key: 'longTerm', mode: 'longTerm', tone: 'violet' },
|
{ key: 'longTerm', mode: 'longTerm', tone: 'violet' },
|
||||||
{ avatar: 4, key: 'newFriends', mode: 'friends', tone: 'blue' },
|
{ key: 'newFriends', mode: 'friends', tone: 'blue' },
|
||||||
{ avatar: 3, key: 'dateNight', mode: 'dates', tone: 'amber' },
|
{ key: 'dateNight', mode: 'dates', tone: 'amber' },
|
||||||
] as const
|
] 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> {
|
function avatarStyle(avatar: number): Record<string, string> {
|
||||||
const safeAvatar = Math.max(0, Math.min(5, Math.floor(avatar)))
|
const safeAvatar = Math.max(0, Math.min(5, Math.floor(avatar)))
|
||||||
const column = safeAvatar % 3
|
|
||||||
const row = Math.floor(safeAvatar / 3)
|
|
||||||
return {
|
return {
|
||||||
backgroundImage: `url(${profilesSprite})`,
|
backgroundImage: `linear-gradient(${135 + safeAvatar * 12}deg, var(--flare), var(--flare-warm) 52%, var(--flare-ink))`,
|
||||||
backgroundPosition: `${column * 50}% ${row * 100}%`,
|
backgroundPosition: 'center',
|
||||||
backgroundSize: '300% 200%',
|
backgroundSize: 'cover',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +363,10 @@ function resetProfileDraft(): void {
|
|||||||
ownPhotoIndex.value = 0
|
ownPhotoIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPhotoSourcePicker(): void {
|
function openPhotoSourcePicker(event: MouseEvent): void {
|
||||||
if (draftPhotos.value.length >= 6) return
|
if (draftPhotos.value.length >= 6) return
|
||||||
|
if (!(event.currentTarget instanceof HTMLElement)) return
|
||||||
|
photoSourceTarget.value = event.currentTarget
|
||||||
photoSourceOpened.value = true
|
photoSourceOpened.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +374,11 @@ function closePhotoSourcePicker(): void {
|
|||||||
photoSourceOpened.value = false
|
photoSourceOpened.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPhotoSource(id: string): void {
|
||||||
|
if (id !== 'photos' && id !== 'camera') return
|
||||||
|
openProfileMediaApp(id)
|
||||||
|
}
|
||||||
|
|
||||||
function openProfileMediaApp(app: 'camera' | 'photos'): void {
|
function openProfileMediaApp(app: 'camera' | 'photos'): void {
|
||||||
const remaining = 6 - draftPhotos.value.length
|
const remaining = 6 - draftPhotos.value.length
|
||||||
if (remaining < 1) return
|
if (remaining < 1) return
|
||||||
@@ -378,6 +401,7 @@ function openProfileMediaApp(app: 'camera' | 'photos'): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeDraftPhoto(id: number): void {
|
function removeDraftPhoto(id: number): void {
|
||||||
|
if (draftPhotos.value.length <= 1) return
|
||||||
draftPhotos.value = draftPhotos.value.filter((photo) => photo.id !== id)
|
draftPhotos.value = draftPhotos.value.filter((photo) => photo.id !== id)
|
||||||
profileDraft.photoMediaIds = draftPhotos.value.map((photo) => photo.id)
|
profileDraft.photoMediaIds = draftPhotos.value.map((photo) => photo.id)
|
||||||
}
|
}
|
||||||
@@ -585,7 +609,7 @@ async function refreshFlareState(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile(): Promise<void> {
|
async function saveProfile(): Promise<void> {
|
||||||
if (profileSaving.value) return
|
if (profileSaving.value || !hasRequiredProfilePhoto.value) return
|
||||||
profileSaving.value = true
|
profileSaving.value = true
|
||||||
const creatingProfile = !flare.profile
|
const creatingProfile = !flare.profile
|
||||||
try {
|
try {
|
||||||
@@ -913,6 +937,10 @@ onMounted(async () => {
|
|||||||
profileEditing.value = selection.context.editing
|
profileEditing.value = selection.context.editing
|
||||||
} else {
|
} else {
|
||||||
syncDraft()
|
syncDraft()
|
||||||
|
if (flare.profile && draftPhotos.value.length === 0) {
|
||||||
|
activeTab.value = 'profile'
|
||||||
|
profileEditing.value = true
|
||||||
|
}
|
||||||
if (route.query.profileEdit === '1' && flare.profile) {
|
if (route.query.profileEdit === '1' && flare.profile) {
|
||||||
activeTab.value = 'profile'
|
activeTab.value = 'profile'
|
||||||
profileEditing.value = true
|
profileEditing.value = true
|
||||||
@@ -1012,6 +1040,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ phone.t('Apps.flare.primaryPhoto') }}
|
{{ phone.t('Apps.flare.primaryPhoto') }}
|
||||||
</span>
|
</span>
|
||||||
<sky-link
|
<sky-link
|
||||||
|
v-if="draftPhotos.length > 1"
|
||||||
component="button"
|
component="button"
|
||||||
icon-only
|
icon-only
|
||||||
class="flare-photo-remove"
|
class="flare-photo-remove"
|
||||||
@@ -1025,19 +1054,27 @@ onBeforeUnmount(() => {
|
|||||||
<X />
|
<X />
|
||||||
</sky-link>
|
</sky-link>
|
||||||
</div>
|
</div>
|
||||||
<sky-button
|
</div>
|
||||||
v-if="draftPhotos.length < 6"
|
<div
|
||||||
clear
|
v-if="draftPhotos.length < 6"
|
||||||
class="flare-photo-add"
|
class="flare-onboarding-photo-actions"
|
||||||
:class="{ 'is-empty': !draftPhotos.length }"
|
>
|
||||||
aria-controls="flare-photo-source-sheet"
|
<sky-button outline rounded @click="openProfileMediaApp('photos')">
|
||||||
aria-haspopup="dialog"
|
<Images :size="19" />
|
||||||
@click="openPhotoSourcePicker"
|
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
|
||||||
>
|
</sky-button>
|
||||||
<Plus />
|
<sky-button outline rounded @click="openProfileMediaApp('camera')">
|
||||||
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
|
<Camera :size="19" />
|
||||||
|
<span>{{ phone.t('Apps.flare.takePhoto') }}</span>
|
||||||
</sky-button>
|
</sky-button>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="!hasRequiredProfilePhoto"
|
||||||
|
class="flare-photo-required"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ phone.t('Apps.flare.profilePhotoRequired') }}
|
||||||
|
</p>
|
||||||
</sky-card>
|
</sky-card>
|
||||||
<sky-list inset strong>
|
<sky-list inset strong>
|
||||||
<sky-field
|
<sky-field
|
||||||
@@ -1104,7 +1141,7 @@ onBeforeUnmount(() => {
|
|||||||
<sky-button
|
<sky-button
|
||||||
large
|
large
|
||||||
rounded
|
rounded
|
||||||
:disabled="profileSaving"
|
:disabled="profileSaving || !hasRequiredProfilePhoto"
|
||||||
:aria-busy="profileSaving"
|
:aria-busy="profileSaving"
|
||||||
@click="saveProfile"
|
@click="saveProfile"
|
||||||
>
|
>
|
||||||
@@ -1332,7 +1369,7 @@ onBeforeUnmount(() => {
|
|||||||
"
|
"
|
||||||
component="button"
|
component="button"
|
||||||
class="flare-navbar-done"
|
class="flare-navbar-done"
|
||||||
:disabled="profileSaving"
|
:disabled="profileSaving || !hasRequiredProfilePhoto"
|
||||||
:aria-busy="profileSaving"
|
:aria-busy="profileSaving"
|
||||||
@click="saveProfile"
|
@click="saveProfile"
|
||||||
>
|
>
|
||||||
@@ -1512,6 +1549,8 @@ onBeforeUnmount(() => {
|
|||||||
<sky-card
|
<sky-card
|
||||||
v-for="tile in exploreTiles"
|
v-for="tile in exploreTiles"
|
||||||
:key="tile.key"
|
:key="tile.key"
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
:content-wrap="false"
|
:content-wrap="false"
|
||||||
class="flare-explore-card"
|
class="flare-explore-card"
|
||||||
:class="`flare-explore-card--${tile.tone}`"
|
:class="`flare-explore-card--${tile.tone}`"
|
||||||
@@ -1519,7 +1558,8 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flare-explore-card__photo"
|
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" />
|
<div class="flare-explore-card__shade" />
|
||||||
<strong>{{
|
<strong>{{
|
||||||
@@ -1819,6 +1859,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ phone.t('Apps.flare.primaryPhoto') }}
|
{{ phone.t('Apps.flare.primaryPhoto') }}
|
||||||
</span>
|
</span>
|
||||||
<sky-link
|
<sky-link
|
||||||
|
v-if="draftPhotos.length > 1"
|
||||||
component="button"
|
component="button"
|
||||||
icon-only
|
icon-only
|
||||||
class="flare-photo-remove"
|
class="flare-photo-remove"
|
||||||
@@ -1837,14 +1878,22 @@ onBeforeUnmount(() => {
|
|||||||
clear
|
clear
|
||||||
class="flare-photo-add"
|
class="flare-photo-add"
|
||||||
:class="{ 'is-empty': !draftPhotos.length }"
|
:class="{ 'is-empty': !draftPhotos.length }"
|
||||||
aria-controls="flare-photo-source-sheet"
|
aria-controls="flare-photo-source-menu"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="photoSourceOpened"
|
||||||
@click="openPhotoSourcePicker"
|
@click="openPhotoSourcePicker"
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
|
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
|
||||||
</sky-button>
|
</sky-button>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="!hasRequiredProfilePhoto"
|
||||||
|
class="flare-photo-required"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ phone.t('Apps.flare.profilePhotoRequired') }}
|
||||||
|
</p>
|
||||||
</sky-card>
|
</sky-card>
|
||||||
<sky-list inset strong>
|
<sky-list inset strong>
|
||||||
<sky-field
|
<sky-field
|
||||||
@@ -2028,36 +2077,17 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<sky-action-sheet
|
<SkyDropdown
|
||||||
id="flare-photo-source-sheet"
|
id="flare-photo-source-menu"
|
||||||
:aria-label="phone.t('Apps.flare.addPhotos')"
|
:items="photoSourceItems"
|
||||||
|
:label="phone.t('Apps.flare.addPhotos')"
|
||||||
:opened="photoSourceOpened"
|
:opened="photoSourceOpened"
|
||||||
|
:target="photoSourceTarget"
|
||||||
@backdropclick="closePhotoSourcePicker"
|
@backdropclick="closePhotoSourcePicker"
|
||||||
@escape="closePhotoSourcePicker"
|
@escape="closePhotoSourcePicker"
|
||||||
>
|
@positionerror="closePhotoSourcePicker"
|
||||||
<sky-action-group>
|
@select="selectPhotoSource"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="flare-choice-sheet">
|
<div class="flare-choice-sheet">
|
||||||
<sky-sheet
|
<sky-sheet
|
||||||
@@ -2596,6 +2626,9 @@ onBeforeUnmount(() => {
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
filter: saturate(0.82);
|
filter: saturate(0.82);
|
||||||
}
|
}
|
||||||
|
.flare-explore-card__photo.is-fallback {
|
||||||
|
background: linear-gradient(145deg, var(--flare), var(--flare-warm));
|
||||||
|
}
|
||||||
.flare-explore-card__shade {
|
.flare-explore-card__shade {
|
||||||
background: linear-gradient(15deg, rgb(0 0 0 / 72%), transparent 70%);
|
background: linear-gradient(15deg, rgb(0 0 0 / 72%), transparent 70%);
|
||||||
mix-blend-mode: multiply;
|
mix-blend-mode: multiply;
|
||||||
@@ -3158,11 +3191,27 @@ onBeforeUnmount(() => {
|
|||||||
width: 25px;
|
width: 25px;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
}
|
}
|
||||||
.flare-photo-source-action {
|
.flare-onboarding-photo-actions {
|
||||||
display: inline-flex;
|
margin-top: var(--sky-space-3);
|
||||||
align-items: center;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--sky-space-2);
|
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 {
|
.flare-error {
|
||||||
margin: 8px 22px 0;
|
margin: 8px 22px 0;
|
||||||
color: #ff3b30;
|
color: #ff3b30;
|
||||||
|
|||||||
@@ -170,4 +170,28 @@ describe('GalleryApp import action', () => {
|
|||||||
expect(source).toContain('id: 13 + index')
|
expect(source).toContain('id: 13 + index')
|
||||||
expect(source).not.toContain('picsum.photos')
|
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> {
|
async function deleteSelected(): Promise<void> {
|
||||||
if (!selected.value || deleting.value) return
|
if (!selected.value || deleting.value) return
|
||||||
|
const selectedId = selected.value.id
|
||||||
deleting.value = true
|
deleting.value = true
|
||||||
deleteDialogOpened.value = false
|
deleteDialogOpened.value = false
|
||||||
pendingDeleteCorrelation = `${Date.now()}-${crypto.randomUUID()}`
|
pendingDeleteCorrelation = `${Date.now()}-${crypto.randomUUID()}`
|
||||||
if (isDevelopment) {
|
if (isDevelopment && !developmentApiEnabled) {
|
||||||
const developmentIndex = developmentMedia.findIndex(
|
const developmentIndex = developmentMedia.findIndex(
|
||||||
(entry) => entry.id === selected.value?.id,
|
(entry) => entry.id === selectedId,
|
||||||
)
|
)
|
||||||
if (developmentIndex >= 0) developmentMedia.splice(developmentIndex, 1)
|
if (developmentIndex >= 0) developmentMedia.splice(developmentIndex, 1)
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -981,7 +982,7 @@ async function deleteSelected(): Promise<void> {
|
|||||||
data: {
|
data: {
|
||||||
data: {
|
data: {
|
||||||
correlationId: pendingDeleteCorrelation,
|
correlationId: pendingDeleteCorrelation,
|
||||||
id: selected.value?.id,
|
id: selectedId,
|
||||||
success: true,
|
success: true,
|
||||||
},
|
},
|
||||||
type: 'media:deleteResult',
|
type: 'media:deleteResult',
|
||||||
@@ -991,10 +992,25 @@ async function deleteSelected(): Promise<void> {
|
|||||||
}, 500)
|
}, 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await nuiCall('gallery:delete', {
|
const response = await nuiCall('gallery:delete', {
|
||||||
correlationId: pendingDeleteCorrelation,
|
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 {
|
function onMessage(event: MessageEvent): void {
|
||||||
|
|||||||
@@ -2878,6 +2878,19 @@ mockMedia.push(
|
|||||||
url: mockGalleryImage(title, sky, landscape, accent),
|
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 weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
|
||||||
const weazelNewsMaxImages = 6
|
const weazelNewsMaxImages = 6
|
||||||
let weazelNewsSequence = 8
|
let weazelNewsSequence = 8
|
||||||
@@ -3452,8 +3465,8 @@ let flareProfile = {
|
|||||||
interests: ['Night drives', 'Music', 'Coffee'],
|
interests: ['Night drives', 'Music', 'Coffee'],
|
||||||
lookingFor: 'dates',
|
lookingFor: 'dates',
|
||||||
discoverable: true,
|
discoverable: true,
|
||||||
photoMediaIds: [],
|
photoMediaIds: [1, 3],
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([1, 3]),
|
||||||
}
|
}
|
||||||
let flareSuggestions = [
|
let flareSuggestions = [
|
||||||
{
|
{
|
||||||
@@ -3465,7 +3478,7 @@ let flareSuggestions = [
|
|||||||
avatar: 0,
|
avatar: 0,
|
||||||
interests: ['Beach days', 'Food spots', 'Art'],
|
interests: ['Beach days', 'Food spots', 'Art'],
|
||||||
lookingFor: 'longTerm',
|
lookingFor: 'longTerm',
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([3, 7]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
@@ -3476,7 +3489,7 @@ let flareSuggestions = [
|
|||||||
avatar: 1,
|
avatar: 1,
|
||||||
interests: ['Architecture', 'Karaoke', 'Travel'],
|
interests: ['Architecture', 'Karaoke', 'Travel'],
|
||||||
lookingFor: 'dates',
|
lookingFor: 'dates',
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([4, 11]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 13,
|
id: 13,
|
||||||
@@ -3487,7 +3500,7 @@ let flareSuggestions = [
|
|||||||
avatar: 2,
|
avatar: 2,
|
||||||
interests: ['Coffee', 'Photography', 'Dogs'],
|
interests: ['Coffee', 'Photography', 'Dogs'],
|
||||||
lookingFor: 'friends',
|
lookingFor: 'friends',
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([5, 9]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 14,
|
id: 14,
|
||||||
@@ -3498,7 +3511,7 @@ let flareSuggestions = [
|
|||||||
avatar: 3,
|
avatar: 3,
|
||||||
interests: ['Cars', 'Road trips', 'Vinyl'],
|
interests: ['Cars', 'Road trips', 'Vinyl'],
|
||||||
lookingFor: 'longTerm',
|
lookingFor: 'longTerm',
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([8, 13]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 15,
|
id: 15,
|
||||||
@@ -3509,7 +3522,7 @@ let flareSuggestions = [
|
|||||||
avatar: 4,
|
avatar: 4,
|
||||||
interests: ['Sailing', 'Fitness', 'Brunch'],
|
interests: ['Sailing', 'Fitness', 'Brunch'],
|
||||||
lookingFor: 'dates',
|
lookingFor: 'dates',
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([12, 14]),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const flareSuggestionFixtures = flareSuggestions.map((profile) => ({
|
const flareSuggestionFixtures = flareSuggestions.map((profile) => ({
|
||||||
@@ -3529,7 +3542,7 @@ const flareMatches = [
|
|||||||
avatar: 5,
|
avatar: 5,
|
||||||
interests: ['Live music', 'Cooking'],
|
interests: ['Live music', 'Cooking'],
|
||||||
lookingFor: 'dates',
|
lookingFor: 'dates',
|
||||||
photoUrls: [],
|
photoUrls: mockPhotoUrls([15, 16]),
|
||||||
},
|
},
|
||||||
lastMessage: 'Friday night jazz',
|
lastMessage: 'Friday night jazz',
|
||||||
lastMessageAt: isoTime(-18 * 60 * 1000),
|
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 = [
|
const companyCategories = [
|
||||||
{ id: 'public_services', name: 'Public Services' },
|
{ id: 'public_services', name: 'Public Services' },
|
||||||
{ id: 'medical', name: 'Medical' },
|
{ id: 'medical', name: 'Medical' },
|
||||||
@@ -6437,28 +6480,26 @@ app.post('/api/:endpoint', (request, response) => {
|
|||||||
}
|
}
|
||||||
if (endpoint === 'flare:save-profile') {
|
if (endpoint === 'flare:save-profile') {
|
||||||
const requestedPhotoIds = request.body.photoMediaIds
|
const requestedPhotoIds = request.body.photoMediaIds
|
||||||
let photoUpdate = {}
|
const validIds =
|
||||||
if (requestedPhotoIds !== undefined) {
|
Array.isArray(requestedPhotoIds) &&
|
||||||
const validIds =
|
requestedPhotoIds.length >= 1 &&
|
||||||
Array.isArray(requestedPhotoIds) &&
|
requestedPhotoIds.length <= 6 &&
|
||||||
requestedPhotoIds.length <= 6 &&
|
new Set(requestedPhotoIds).size === requestedPhotoIds.length &&
|
||||||
new Set(requestedPhotoIds).size === requestedPhotoIds.length &&
|
requestedPhotoIds.every((id) => Number.isInteger(id) && id > 0)
|
||||||
requestedPhotoIds.every((id) => Number.isInteger(id) && id > 0)
|
const photos = validIds
|
||||||
const photos = validIds
|
? requestedPhotoIds.map((id) =>
|
||||||
? requestedPhotoIds.map((id) =>
|
mockMedia.find(
|
||||||
mockMedia.find(
|
(item) => item.id === id && item.mediaType === 'photo',
|
||||||
(item) => item.id === id && item.mediaType === 'photo',
|
),
|
||||||
),
|
)
|
||||||
)
|
: []
|
||||||
: []
|
if (!validIds || photos.some((photo) => !photo)) {
|
||||||
if (!validIds || photos.some((photo) => !photo)) {
|
response.json({ success: false, error: 'invalid_profile_photos' })
|
||||||
response.json({ success: false, error: 'invalid_profile_photos' })
|
return
|
||||||
return
|
}
|
||||||
}
|
const photoUpdate = {
|
||||||
photoUpdate = {
|
photoMediaIds: [...requestedPhotoIds],
|
||||||
photoMediaIds: [...requestedPhotoIds],
|
photoUrls: photos.map((photo) => photo.url),
|
||||||
photoUrls: photos.map((photo) => photo.url),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
flareProfile = {
|
flareProfile = {
|
||||||
...flareProfile,
|
...flareProfile,
|
||||||
@@ -9109,7 +9150,13 @@ app.post('/api/:endpoint', (request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (endpoint === 'gallery:delete') {
|
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 })
|
response.json({ success: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -9133,7 +9180,12 @@ app.post('/api/:endpoint', (request, response) => {
|
|||||||
const deletedIds = mockMedia
|
const deletedIds = mockMedia
|
||||||
.filter((item) => ids.includes(item.id))
|
.filter((item) => ids.includes(item.id))
|
||||||
.map((item) => 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))
|
mockMedia = mockMedia.filter((item) => !deletedIds.includes(item.id))
|
||||||
|
removeFlareProfilePhotos(deletedIds)
|
||||||
response.json({
|
response.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -852,10 +852,92 @@ async function verifyStatefulActions(baseUrl) {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
assert(flareBeforeDelete.profile, 'flare:bootstrap did not include a profile')
|
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(
|
assert(
|
||||||
flareBeforeDelete.matches.length > 0,
|
flareBeforeDelete.matches.length > 0,
|
||||||
'flare:bootstrap did not include a deletable match',
|
'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]
|
const swipedProfile = flareBeforeDelete.suggestions[0]
|
||||||
await expectSuccess(baseUrl, 'flare:swipe', {
|
await expectSuccess(baseUrl, 'flare:swipe', {
|
||||||
choice: 'pass',
|
choice: 'pass',
|
||||||
@@ -889,12 +971,32 @@ async function verifyStatefulActions(baseUrl) {
|
|||||||
error: 'profile_not_found',
|
error: 'profile_not_found',
|
||||||
success: false,
|
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(
|
const recreatedFlare = await expectSuccess(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
'flare:save-profile',
|
'flare:save-profile',
|
||||||
{
|
{
|
||||||
...flareBeforeDelete.profile,
|
...flareBeforeDelete.profile,
|
||||||
photoMediaIds: [],
|
photoMediaIds: [retainedProfilePhotoId],
|
||||||
photoUrls: undefined,
|
photoUrls: undefined,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
@@ -905,6 +1007,11 @@ async function verifyStatefulActions(baseUrl) {
|
|||||||
),
|
),
|
||||||
'recreated Flare profile retained a deleted swipe filter',
|
'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')
|
await expectSuccess(baseUrl, 'account:logout')
|
||||||
const signedOutFlareBootstrap = await post(baseUrl, 'flare:bootstrap')
|
const signedOutFlareBootstrap = await post(baseUrl, 'flare:bootstrap')
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ Locales["en"] = {
|
|||||||
flare = {
|
flare = {
|
||||||
name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private Sky Cloud account.",
|
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.",
|
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}",
|
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...",
|
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",
|
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}",
|
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" },
|
lookingFor = { longTerm = "Long-term connection", dates = "Good dates", friends = "New friends" },
|
||||||
errors = {
|
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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
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
|
end
|
||||||
|
|
||||||
local function list_suggestions(account_id, profile)
|
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 {}
|
return {}
|
||||||
end
|
end
|
||||||
local rows = Bridge.Database.Query([[
|
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.`age` BETWEEN target.`min_age` AND target.`max_age`
|
||||||
AND (mine.`interested_in` = 'everyone' OR mine.`interested_in` = target.`gender`)
|
AND (mine.`interested_in` = 'everyone' OR mine.`interested_in` = target.`gender`)
|
||||||
AND (target.`interested_in` = 'everyone' OR target.`interested_in` = mine.`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`
|
ORDER BY target.`updated_at` DESC, target.`id`
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
]], { account_id })
|
]], { account_id })
|
||||||
@@ -313,32 +327,31 @@ local function validate_profile(source, data)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local photo_media_ids = nil
|
if type(data.photoMediaIds) ~= "table"
|
||||||
local replace_photos = data.photoMediaIds ~= nil
|
or #data.photoMediaIds < 1
|
||||||
if replace_photos then
|
or #data.photoMediaIds > 6
|
||||||
if type(data.photoMediaIds) ~= "table" or #data.photoMediaIds > 6 then
|
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"
|
return nil, "invalid_profile_photos"
|
||||||
end
|
end
|
||||||
photo_media_ids = {}
|
end
|
||||||
local seen_media = {}
|
for _, value in ipairs(data.photoMediaIds) do
|
||||||
for key in pairs(data.photoMediaIds) do
|
local media_id = tonumber(value)
|
||||||
if type(key) ~= "number" or key < 1 or key > #data.photoMediaIds
|
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
|
||||||
or key ~= math.floor(key)
|
or seen_media[media_id]
|
||||||
then
|
or not SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")
|
||||||
return nil, "invalid_profile_photos"
|
then
|
||||||
end
|
return nil, "invalid_profile_photos"
|
||||||
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
|
end
|
||||||
|
seen_media[media_id] = true
|
||||||
|
photo_media_ids[#photo_media_ids + 1] = media_id
|
||||||
end
|
end
|
||||||
return {
|
return {
|
||||||
name = name,
|
name = name,
|
||||||
@@ -352,7 +365,7 @@ local function validate_profile(source, data)
|
|||||||
interests = json.encode(clean_interests),
|
interests = json.encode(clean_interests),
|
||||||
looking_for = data.lookingFor,
|
looking_for = data.lookingFor,
|
||||||
photo_media_ids = photo_media_ids,
|
photo_media_ids = photo_media_ids,
|
||||||
replace_photos = replace_photos,
|
replace_photos = true,
|
||||||
}, nil
|
}, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -796,6 +796,28 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
|||||||
upload_result(src, state.correlation_id, false, error_code)
|
upload_result(src, state.correlation_id, false, error_code)
|
||||||
end)
|
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 function delete_owned_media(src, owner, media_id)
|
||||||
local condition, params = owner_condition(owner)
|
local condition, params = owner_condition(owner)
|
||||||
local query_params = { media_id }
|
local query_params = { media_id }
|
||||||
@@ -803,13 +825,16 @@ local function delete_owned_media(src, owner, media_id)
|
|||||||
query_params[#query_params + 1] = value
|
query_params[#query_params + 1] = value
|
||||||
end
|
end
|
||||||
local rows = Bridge.Database.Query(([[
|
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
|
WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') LIMIT 1
|
||||||
]]):format(condition), query_params)
|
]]):format(condition), query_params)
|
||||||
local row = rows[1]
|
local row = rows[1]
|
||||||
if not row then
|
if not row then
|
||||||
return false, "not_found"
|
return false, "not_found"
|
||||||
end
|
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)
|
local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id)
|
||||||
if pending_deletes[delete_key] then
|
if pending_deletes[delete_key] then
|
||||||
return false, "operation_in_progress"
|
return false, "operation_in_progress"
|
||||||
|
|||||||
Reference in New Issue
Block a user