fix(flare): enforce real profile photos

This commit is contained in:
Dominik
2026-08-17 01:30:41 +02:00
parent ae8134bf98
commit ecfe089051
15 changed files with 680 additions and 150 deletions
@@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const flareServer = readFileSync(
new URL('../../sky_phone/source/server/flare.lua', import.meta.url),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
function sourceBlock(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('Flare server profile photo invariants', () => {
it('requires one to six unique owned photos for every profile save', () => {
const validation = sourceBlock(
flareServer,
'local function validate_profile(source, data)',
'local function load_match',
)
expect(validation).toContain('type(data.photoMediaIds) ~= "table"')
expect(validation).toContain('or #data.photoMediaIds < 1')
expect(validation).toContain('or #data.photoMediaIds > 6')
expect(validation).toContain('or seen_media[media_id]')
expect(validation).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")',
)
expect(validation).toContain('replace_photos = true')
})
it('filters profiles without a valid owned HTTPS photo before limiting suggestions', () => {
const suggestions = sourceBlock(
flareServer,
'local function list_suggestions(account_id, profile)',
'local function list_likes',
)
const exists = suggestions.indexOf('AND EXISTS (')
const order = suggestions.indexOf('ORDER BY target.`updated_at`')
const limit = suggestions.indexOf('LIMIT 30')
expect(suggestions).toContain('type(profile.photo_urls) ~= "table"')
expect(suggestions).toContain('or #profile.photo_urls < 1')
expect(exists).toBeGreaterThanOrEqual(0)
expect(exists).toBeLessThan(order)
expect(order).toBeLessThan(limit)
expect(suggestions).toContain(
'target_media.`account_id` = target.`account_id`',
)
expect(suggestions).toContain("target_media.`media_type` = 'photo'")
expect(suggestions).toContain("target_media.`url` LIKE 'https://%'")
})
it('protects the last valid Flare photo for single and sequential bulk deletes', () => {
const deletion = sourceBlock(
mediaServer,
'local function is_required_flare_profile_photo(media_id)',
'function SkyPhoneMedia.GetDeviceRemoteIds',
)
expect(deletion).toContain('other_photo.`media_id` <> photo.`media_id`')
expect(deletion).toContain(
'other_media.`account_id` = profile.`account_id`',
)
expect(deletion).toContain("other_media.`media_type` = 'photo'")
expect(deletion).toContain("other_media.`url` LIKE 'https://%'")
expect(deletion).toContain(
'row.media_type == "photo" and is_required_flare_profile_photo(media_id)',
)
expect(deletion).toContain('return false, "profile_photo_required"')
expect(deletion).toMatch(
/for _, media_id in ipairs\(media_ids\) do[\s\S]*?delete_owned_media\(src, owner, media_id\)/,
)
})
})
+28
View File
@@ -121,6 +121,34 @@ describe('flare store', () => {
]) ])
}) })
it.each([
{ label: 'no photos', photoMediaIds: [] },
{ label: 'more than six photos', photoMediaIds: [1, 2, 3, 4, 5, 6, 7] },
{ label: 'duplicate photos', photoMediaIds: [42, 42] },
])(
'rejects a profile with $label before calling NUI',
async ({ photoMediaIds }) => {
const draft: FlareProfileDraft = {
age: bootstrap.profile!.age,
avatar: bootstrap.profile!.avatar,
bio: bootstrap.profile!.bio,
gender: bootstrap.profile!.gender,
interestedIn: bootstrap.profile!.interestedIn,
interests: [...bootstrap.profile!.interests],
lookingFor: bootstrap.profile!.lookingFor,
maxAge: bootstrap.profile!.maxAge,
minAge: bootstrap.profile!.minAge,
name: bootstrap.profile!.name,
photoMediaIds,
}
const flare = useFlareStore()
expect(await flare.saveProfile(draft)).toBe(false)
expect(flare.error).toBe('invalid_profile_photos')
expect(mockNuiCall).not.toHaveBeenCalled()
},
)
it('uses a real Super Like and removes the target from both decks', async () => { 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',
+16
View File
@@ -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,
+5 -2
View File
@@ -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.',
+31 -4
View File
@@ -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')
}) })
}) })
+2 -2
View File
@@ -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)
+112 -63
View File
@@ -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*\{/)
})
}) })
+21 -5
View File
@@ -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 {
+83 -31
View File
@@ -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: {
+108 -1
View File
@@ -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')
+3 -3
View File
@@ -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.",
+38 -25
View File
@@ -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
+26 -1
View File
@@ -796,6 +796,28 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
upload_result(src, state.correlation_id, false, error_code) 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"