mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - add Flare account controls and photo overview
Use the centered SkyNavbar surface back action, expose every owned profile photo as a selectable thumbnail, and add confirmed Sky Cloud sign-out plus server-authoritative Flare account deletion. Keep client, server, localization, stateful mocks, and regression coverage aligned.
This commit is contained in:
@@ -158,6 +158,35 @@ describe('flare store', () => {
|
||||
expect(flare.error).toBe('invalid_discovery')
|
||||
})
|
||||
|
||||
it('clears Flare state after the server deletes the owned profile', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
const flare = useFlareStore()
|
||||
flare.applyBootstrap(bootstrap)
|
||||
flare.activeMatchId = 'match-to-delete'
|
||||
|
||||
expect(await flare.deleteProfile()).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('flare:delete-profile')
|
||||
expect(flare.profile).toBeNull()
|
||||
expect(flare.activeMatchId).toBe('')
|
||||
expect(flare.likes).toEqual([])
|
||||
expect(flare.matches).toEqual([])
|
||||
expect(flare.suggestions).toEqual([])
|
||||
expect(flare.error).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the Flare profile when account deletion is rejected', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'request_failed',
|
||||
success: false,
|
||||
})
|
||||
const flare = useFlareStore()
|
||||
flare.applyBootstrap(bootstrap)
|
||||
|
||||
expect(await flare.deleteProfile()).toBe(false)
|
||||
expect(flare.profile).toEqual(bootstrap.profile)
|
||||
expect(flare.error).toBe('request_failed')
|
||||
})
|
||||
|
||||
it('sends media messages and keeps numeric database timestamps intact', async () => {
|
||||
const match: FlareMatch = {
|
||||
id: 'match-media',
|
||||
|
||||
@@ -47,6 +47,12 @@ export const useFlareStore = defineStore('flare', {
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async deleteProfile(): Promise<boolean> {
|
||||
const response = await nuiCall('flare:delete-profile')
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) this.reset()
|
||||
return response.success
|
||||
},
|
||||
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
|
||||
const response = await nuiCall<FlareBootstrap>(
|
||||
'flare:save-profile',
|
||||
@@ -132,6 +138,17 @@ export const useFlareStore = defineStore('flare', {
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
reset(error = ''): void {
|
||||
this.activeMatchId = ''
|
||||
this.error = error
|
||||
this.loading = false
|
||||
this.likes = []
|
||||
this.matches = []
|
||||
this.messages = []
|
||||
this.profile = null
|
||||
this.sending = false
|
||||
this.suggestions = []
|
||||
},
|
||||
applyBootstrap(data: FlareBootstrap): void {
|
||||
this.profile = data.profile
|
||||
this.likes = data.likes
|
||||
|
||||
@@ -862,6 +862,9 @@ const defaultLocales: LocaleTree = {
|
||||
choosePhotos: 'Choose from Photos',
|
||||
primaryPhoto: 'Main',
|
||||
removePhoto: 'Remove photo {number}',
|
||||
profilePhotoFallback: 'Flare profile avatar',
|
||||
profilePhotoNumber: 'Profile photo {number} of {count}',
|
||||
viewProfilePhoto: 'Show profile photo {number} of {count}',
|
||||
yourName: 'Your name',
|
||||
namePlaceholder: 'What should people call you?',
|
||||
age: 'Age',
|
||||
@@ -917,6 +920,19 @@ const defaultLocales: LocaleTree = {
|
||||
editProfile: 'Edit profile',
|
||||
editRelationshipGoal: 'Edit relationship goal',
|
||||
profileGoalBody: 'Shown on your discovery card.',
|
||||
accountActions: 'Account',
|
||||
signOut: 'Sign Out',
|
||||
signOutTitle: 'Sign out of Sky Cloud?',
|
||||
signOutBody:
|
||||
'This signs the whole phone out of Sky Cloud. Your Flare profile, matches and chats stay stored.',
|
||||
signingOut: 'Signing Out...',
|
||||
signOutHint:
|
||||
'Signing out affects every app that uses Sky Cloud on this phone.',
|
||||
deleteAccount: 'Delete Flare Account',
|
||||
deleteAccountTitle: 'Delete your Flare account?',
|
||||
deleteAccountBody:
|
||||
'Your Flare profile, swipes, matches and conversations will be permanently deleted. Your Sky Cloud account and Photos library stay available.',
|
||||
deletingAccount: 'Deleting...',
|
||||
relationshipGoal: 'Relationship goal',
|
||||
interests: 'Interests',
|
||||
interestsPlaceholder: 'Music, travel, coffee',
|
||||
@@ -978,6 +994,7 @@ const defaultLocales: LocaleTree = {
|
||||
},
|
||||
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.',
|
||||
|
||||
@@ -3,6 +3,18 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./FlareApp.vue', import.meta.url), 'utf8')
|
||||
const clientSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/flare.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const localeSource = readFileSync(
|
||||
new URL('../../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FlareApp profile editing contract', () => {
|
||||
it('opens the central photo source picker while creating an account', () => {
|
||||
@@ -66,4 +78,62 @@ describe('FlareApp profile editing contract', () => {
|
||||
/openProfileGoalEditor\(\)[\s\S]*?profileEditing\.value = true[\s\S]*?openChoice\('lookingFor', null\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the central surfaced SkyNavbar back action for profile screens', () => {
|
||||
const mainNavbarStart =
|
||||
source.match(/<template v-else>\s*<sky-navbar/)?.index ?? -1
|
||||
const mainNavbarEnd = source.indexOf('</sky-navbar>', mainNavbarStart)
|
||||
const navbar = source.slice(mainNavbarStart, mainNavbarEnd)
|
||||
|
||||
expect(mainNavbarStart).toBeGreaterThan(-1)
|
||||
expect(navbar).toContain(':show-back=')
|
||||
expect(navbar).toContain('back-appearance="surface"')
|
||||
expect(navbar).toContain(':back-label="phone.t(\'Common.back\')"')
|
||||
expect(navbar).toContain('@back="closeProfileScreen"')
|
||||
expect(navbar).not.toContain('<template #left>')
|
||||
})
|
||||
|
||||
it('shows every own profile photo as a selectable thumbnail', () => {
|
||||
expect(source).toContain('class="flare-profile-photo-strip"')
|
||||
expect(source).toContain('v-for="(photo, index) in draftPhotos"')
|
||||
expect(source).toContain(
|
||||
':aria-pressed="index === normalizedOwnPhotoIndex"',
|
||||
)
|
||||
expect(source).toContain('@click="selectOwnPhoto(index)"')
|
||||
expect(source).toContain(':style="ownPhotoStyle()"')
|
||||
})
|
||||
|
||||
it('exposes confirmed sign-out and destructive Flare account deletion', () => {
|
||||
expect(source).toContain('<sky-settings-group')
|
||||
expect(source).toContain('@activate="signOutDialogOpened = true"')
|
||||
expect(source).toContain('@activate="deleteAccountDialogOpened = true"')
|
||||
expect(source).toContain(':opened="deleteAccountDialogOpened"')
|
||||
expect(source).toContain('role="alertdialog"')
|
||||
expect(source).toContain('@escape="closeDeleteAccountDialog"')
|
||||
expect(source).toContain('@click="deleteFlareAccount"')
|
||||
expect(source).toContain('const account = useAccountStore()')
|
||||
expect(source).toContain('const success = await account.logout()')
|
||||
expect(source).toContain('appAuth.clear()')
|
||||
expect(source).toContain("flare.reset('not_authenticated')")
|
||||
expect(localeSource).toContain(
|
||||
'This signs the whole phone out of Sky Cloud.',
|
||||
)
|
||||
expect(localeSource).toContain(
|
||||
'Signing out affects every app that uses Sky Cloud on this phone.',
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes all account-owned Flare data in one server transaction', () => {
|
||||
expect(clientSource).toContain('"flare:delete-profile"')
|
||||
expect(serverSource).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:flare:delete-profile"',
|
||||
)
|
||||
expect(serverSource).toContain(
|
||||
'SkyPhone.AllowOperation(source, "flare_profile_delete", 3, 60)',
|
||||
)
|
||||
expect(serverSource).toContain('Bridge.Database.Transaction({')
|
||||
expect(serverSource).toContain('DELETE FROM `sky_phone_flare_matches`')
|
||||
expect(serverSource).toContain('DELETE FROM `sky_phone_flare_swipes`')
|
||||
expect(serverSource).toContain('DELETE FROM `sky_phone_flare_profiles`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
SkyNavbar,
|
||||
SkyNavbarBackLink,
|
||||
SkyAppPage,
|
||||
SkySettingsGroup,
|
||||
SkySettingsRow,
|
||||
SkySpinner,
|
||||
SkySheet,
|
||||
SkyTabBar,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
Heart,
|
||||
ImagePlay,
|
||||
Images,
|
||||
LogOut,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
Share2,
|
||||
Star,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
UserRound,
|
||||
Video,
|
||||
X,
|
||||
@@ -72,6 +76,8 @@ import profilesSprite from '@/assets/img/flare/profiles-source.png'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||
import SharedContentCard from '@/components/SharedContentCard.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
@@ -107,6 +113,8 @@ type FlareMediaContext = {
|
||||
type FlareChatMediaContext = { matchId: string }
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const flare = useFlareStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
@@ -130,6 +138,9 @@ const profileSettings = ref(false)
|
||||
const profileSaving = ref(false)
|
||||
const photoSourceOpened = ref(false)
|
||||
const discoverySaving = ref(false)
|
||||
const signOutDialogOpened = ref(false)
|
||||
const deleteAccountDialogOpened = ref(false)
|
||||
const accountActionPending = ref(false)
|
||||
const unmatchDialog = ref(false)
|
||||
const activeExploreMode = ref<ExploreMode>('all')
|
||||
const actionToast = ref('')
|
||||
@@ -158,24 +169,30 @@ function shareProfile(): void {
|
||||
}
|
||||
const gifNextOffset = ref(0)
|
||||
const draftPhotos = ref<FlareDraftPhoto[]>([])
|
||||
const ownPhotoIndex = ref(0)
|
||||
const activeChoiceField = ref<FlareChoiceField>('gender')
|
||||
const choiceOpened = ref(false)
|
||||
const choiceSheetContent = ref<HTMLElement | null>(null)
|
||||
let activeChoiceTrigger: HTMLElement | null = null
|
||||
let gifSearchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const profileDraft = reactive<FlareProfileDraft>({
|
||||
age: 25,
|
||||
avatar: 0,
|
||||
bio: '',
|
||||
gender: 'woman',
|
||||
interestedIn: 'everyone',
|
||||
interests: [],
|
||||
lookingFor: 'longTerm',
|
||||
maxAge: 45,
|
||||
minAge: 21,
|
||||
name: '',
|
||||
photoMediaIds: [],
|
||||
})
|
||||
|
||||
function createDefaultProfileDraft(): FlareProfileDraft {
|
||||
return {
|
||||
age: 25,
|
||||
avatar: 0,
|
||||
bio: '',
|
||||
gender: 'woman',
|
||||
interestedIn: 'everyone',
|
||||
interests: [],
|
||||
lookingFor: 'longTerm',
|
||||
maxAge: 45,
|
||||
minAge: 21,
|
||||
name: '',
|
||||
photoMediaIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const profileDraft = reactive<FlareProfileDraft>(createDefaultProfileDraft())
|
||||
const bioInputStyle: CSSProperties = {
|
||||
height: '116px',
|
||||
lineHeight: '1.4',
|
||||
@@ -217,6 +234,9 @@ const currentPhotoCount = computed(() =>
|
||||
const normalizedPhotoIndex = computed(
|
||||
() => currentPhotoIndex.value % currentPhotoCount.value,
|
||||
)
|
||||
const normalizedOwnPhotoIndex = computed(
|
||||
() => ownPhotoIndex.value % Math.max(1, draftPhotos.value.length),
|
||||
)
|
||||
const newMatches = computed(() =>
|
||||
flare.matches.filter((match) => !match.lastMessage && !match.lastMessageType),
|
||||
)
|
||||
@@ -266,13 +286,29 @@ function profilePhotoStyle(
|
||||
return selectedPhoto ? photoStyle(selectedPhoto) : avatarStyle(profile.avatar)
|
||||
}
|
||||
|
||||
function ownPhotoStyle(): Record<string, string> {
|
||||
const primaryPhoto = draftPhotos.value[0]
|
||||
return primaryPhoto
|
||||
? photoStyle(primaryPhoto.url)
|
||||
function ownPhotoStyle(
|
||||
photoIndex = normalizedOwnPhotoIndex.value,
|
||||
): Record<string, string> {
|
||||
const selectedPhoto = draftPhotos.value[photoIndex]
|
||||
return selectedPhoto
|
||||
? photoStyle(selectedPhoto.url)
|
||||
: avatarStyle(profileDraft.avatar)
|
||||
}
|
||||
|
||||
function ownPhotoLabel(photoIndex = normalizedOwnPhotoIndex.value): string {
|
||||
if (!draftPhotos.value.length) {
|
||||
return phone.t('Apps.flare.profilePhotoFallback')
|
||||
}
|
||||
return phone.t('Apps.flare.profilePhotoNumber', {
|
||||
count: String(draftPhotos.value.length),
|
||||
number: String(photoIndex + 1),
|
||||
})
|
||||
}
|
||||
|
||||
function selectOwnPhoto(photoIndex: number): void {
|
||||
ownPhotoIndex.value = photoIndex
|
||||
}
|
||||
|
||||
function syncDraft(): void {
|
||||
if (!flare.profile) return
|
||||
Object.assign(profileDraft, {
|
||||
@@ -292,6 +328,9 @@ function syncDraft(): void {
|
||||
const url = flare.profile?.photoUrls[index]
|
||||
return url ? [{ id, url }] : []
|
||||
})
|
||||
if (ownPhotoIndex.value >= draftPhotos.value.length) {
|
||||
ownPhotoIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDraft(): FlareProfileDraft {
|
||||
@@ -302,6 +341,12 @@ function cloneDraft(): FlareProfileDraft {
|
||||
}
|
||||
}
|
||||
|
||||
function resetProfileDraft(): void {
|
||||
Object.assign(profileDraft, createDefaultProfileDraft())
|
||||
draftPhotos.value = []
|
||||
ownPhotoIndex.value = 0
|
||||
}
|
||||
|
||||
function openPhotoSourcePicker(): void {
|
||||
if (draftPhotos.value.length >= 6) return
|
||||
photoSourceOpened.value = true
|
||||
@@ -591,6 +636,46 @@ async function setDiscovery(enabled: boolean): Promise<void> {
|
||||
discoverySaving.value = false
|
||||
}
|
||||
|
||||
function closeSignOutDialog(): void {
|
||||
if (!accountActionPending.value) signOutDialogOpened.value = false
|
||||
}
|
||||
|
||||
function closeDeleteAccountDialog(): void {
|
||||
if (!accountActionPending.value) deleteAccountDialogOpened.value = false
|
||||
}
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
if (accountActionPending.value) return
|
||||
accountActionPending.value = true
|
||||
const success = await account.logout()
|
||||
accountActionPending.value = false
|
||||
if (!success) {
|
||||
showActionError('request_failed')
|
||||
return
|
||||
}
|
||||
appAuth.clear()
|
||||
signOutDialogOpened.value = false
|
||||
profileSettings.value = false
|
||||
profileEditing.value = false
|
||||
resetProfileDraft()
|
||||
flare.reset('not_authenticated')
|
||||
}
|
||||
|
||||
async function deleteFlareAccount(): Promise<void> {
|
||||
if (accountActionPending.value) return
|
||||
accountActionPending.value = true
|
||||
const success = await flare.deleteProfile()
|
||||
accountActionPending.value = false
|
||||
if (!success) {
|
||||
showActionError()
|
||||
return
|
||||
}
|
||||
deleteAccountDialogOpened.value = false
|
||||
profileSettings.value = false
|
||||
profileEditing.value = false
|
||||
resetProfileDraft()
|
||||
}
|
||||
|
||||
async function confirmUnmatch(): Promise<void> {
|
||||
const matchId = activeMatch.value?.id
|
||||
if (!matchId) return
|
||||
@@ -1219,16 +1304,13 @@ onBeforeUnmount(() => {
|
||||
: activeTab
|
||||
"
|
||||
class="flare-navbar"
|
||||
:show-back="
|
||||
activeTab === 'profile' && (profileEditing || profileSettings)
|
||||
"
|
||||
back-appearance="surface"
|
||||
:back-label="phone.t('Common.back')"
|
||||
@back="closeProfileScreen"
|
||||
>
|
||||
<template
|
||||
v-if="activeTab === 'profile' && (profileEditing || profileSettings)"
|
||||
#left
|
||||
>
|
||||
<sky-navbar-back-link
|
||||
:text="phone.t('Common.back')"
|
||||
@click="closeProfileScreen"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span v-if="activeTab === 'discover'" class="flare-title">
|
||||
<Flame fill="currentColor" /> Flare
|
||||
@@ -1598,6 +1680,35 @@ onBeforeUnmount(() => {
|
||||
@input="updateNumber('maxAge', $event)"
|
||||
/>
|
||||
</sky-list>
|
||||
|
||||
<sky-settings-group
|
||||
class="flare-account-actions"
|
||||
:title="phone.t('Apps.flare.accountActions')"
|
||||
:footer="phone.t('Apps.flare.signOutHint')"
|
||||
>
|
||||
<sky-settings-row
|
||||
kind="action"
|
||||
:pending="accountActionPending"
|
||||
:title="phone.t('Apps.flare.signOut')"
|
||||
@activate="signOutDialogOpened = true"
|
||||
>
|
||||
<template #leading>
|
||||
<LogOut :size="20" aria-hidden="true" />
|
||||
</template>
|
||||
</sky-settings-row>
|
||||
<sky-settings-row
|
||||
kind="action"
|
||||
tone="danger"
|
||||
:pending="accountActionPending"
|
||||
:title="phone.t('Apps.flare.deleteAccount')"
|
||||
@activate="deleteAccountDialogOpened = true"
|
||||
>
|
||||
<template #leading>
|
||||
<Trash2 :size="20" aria-hidden="true" />
|
||||
</template>
|
||||
</sky-settings-row>
|
||||
</sky-settings-group>
|
||||
|
||||
<p v-if="flare.error" class="flare-error">
|
||||
{{ phone.t(`Apps.flare.errors.${flare.error}`) }}
|
||||
</p>
|
||||
@@ -1608,9 +1719,36 @@ onBeforeUnmount(() => {
|
||||
class="flare-scroll-view flare-profile-overview"
|
||||
>
|
||||
<div class="flare-profile-portrait">
|
||||
<i :style="ownPhotoStyle()" />
|
||||
<i
|
||||
:style="ownPhotoStyle()"
|
||||
role="img"
|
||||
:aria-label="ownPhotoLabel()"
|
||||
/>
|
||||
<span><Flame :size="16" fill="currentColor" /></span>
|
||||
</div>
|
||||
<div
|
||||
v-if="draftPhotos.length > 1"
|
||||
class="flare-profile-photo-strip"
|
||||
role="group"
|
||||
:aria-label="phone.t('Apps.flare.profilePhotos')"
|
||||
>
|
||||
<button
|
||||
v-for="(photo, index) in draftPhotos"
|
||||
:key="photo.id"
|
||||
type="button"
|
||||
:class="{ active: index === normalizedOwnPhotoIndex }"
|
||||
:aria-label="
|
||||
phone.t('Apps.flare.viewProfilePhoto', {
|
||||
count: String(draftPhotos.length),
|
||||
number: String(index + 1),
|
||||
})
|
||||
"
|
||||
:aria-pressed="index === normalizedOwnPhotoIndex"
|
||||
@click="selectOwnPhoto(index)"
|
||||
>
|
||||
<i :style="photoStyle(photo.url)" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<h1>{{ profileDraft.name }}, {{ profileDraft.age }}</h1>
|
||||
<p>{{ profileDraft.bio }}</p>
|
||||
<button
|
||||
@@ -1988,7 +2126,69 @@ onBeforeUnmount(() => {
|
||||
</sky-sheet>
|
||||
</div>
|
||||
|
||||
<sky-dialog :opened="unmatchDialog" @backdropclick="unmatchDialog = false">
|
||||
<sky-dialog
|
||||
:opened="signOutDialogOpened"
|
||||
:title="phone.t('Apps.flare.signOutTitle')"
|
||||
:content="phone.t('Apps.flare.signOutBody')"
|
||||
@backdropclick="closeSignOutDialog"
|
||||
@escape="closeSignOutDialog"
|
||||
>
|
||||
<template #buttons>
|
||||
<sky-dialog-button
|
||||
:disabled="accountActionPending"
|
||||
@click="closeSignOutDialog"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</sky-dialog-button>
|
||||
<sky-dialog-button
|
||||
strong
|
||||
:disabled="accountActionPending"
|
||||
@click="signOut"
|
||||
>
|
||||
{{
|
||||
accountActionPending
|
||||
? phone.t('Apps.flare.signingOut')
|
||||
: phone.t('Apps.flare.signOut')
|
||||
}}
|
||||
</sky-dialog-button>
|
||||
</template>
|
||||
</sky-dialog>
|
||||
|
||||
<sky-dialog
|
||||
:opened="deleteAccountDialogOpened"
|
||||
role="alertdialog"
|
||||
:title="phone.t('Apps.flare.deleteAccountTitle')"
|
||||
:content="phone.t('Apps.flare.deleteAccountBody')"
|
||||
@backdropclick="closeDeleteAccountDialog"
|
||||
@escape="closeDeleteAccountDialog"
|
||||
>
|
||||
<template #buttons>
|
||||
<sky-dialog-button
|
||||
:disabled="accountActionPending"
|
||||
@click="closeDeleteAccountDialog"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</sky-dialog-button>
|
||||
<sky-dialog-button
|
||||
strong
|
||||
class="flare-dialog-button--danger"
|
||||
:disabled="accountActionPending"
|
||||
@click="deleteFlareAccount"
|
||||
>
|
||||
{{
|
||||
accountActionPending
|
||||
? phone.t('Apps.flare.deletingAccount')
|
||||
: phone.t('Common.delete')
|
||||
}}
|
||||
</sky-dialog-button>
|
||||
</template>
|
||||
</sky-dialog>
|
||||
|
||||
<sky-dialog
|
||||
:opened="unmatchDialog"
|
||||
@backdropclick="unmatchDialog = false"
|
||||
@escape="unmatchDialog = false"
|
||||
>
|
||||
<template #title>{{ phone.t('Apps.flare.unmatchTitle') }}</template>
|
||||
<p>
|
||||
{{
|
||||
@@ -2615,6 +2815,47 @@ onBeforeUnmount(() => {
|
||||
color: #fff;
|
||||
background: var(--flare);
|
||||
}
|
||||
.flare-profile-photo-strip {
|
||||
max-width: 100%;
|
||||
margin: -2px auto 14px;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.flare-profile-photo-strip > button {
|
||||
width: 44px;
|
||||
height: 52px;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 13px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
.flare-profile-photo-strip > button > i {
|
||||
width: 36px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
border-radius: 9px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.flare-profile-photo-strip > button.active {
|
||||
border-color: var(--flare);
|
||||
}
|
||||
.flare-profile-photo-strip > button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.flare-profile-photo-strip > button:focus-visible {
|
||||
outline: 2px solid var(--flare);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.flare-profile-overview > h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
@@ -2948,6 +3189,22 @@ onBeforeUnmount(() => {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.flare-account-actions {
|
||||
margin: 0 var(--sky-page-gutter) var(--sky-space-6);
|
||||
}
|
||||
.flare-account-actions :deep(.sky-settings-group__title),
|
||||
.flare-account-actions :deep(.sky-settings-group__footer) {
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
.flare-account-actions
|
||||
:deep(.sky-settings-row--danger .sky-settings-row__leading) {
|
||||
color: var(--sky-danger);
|
||||
}
|
||||
.flare-dialog-button--danger:not(:disabled) {
|
||||
background: var(--sky-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.flare-choice-sheet :deep(.sky-sheet__panel) {
|
||||
z-index: 70;
|
||||
|
||||
@@ -3423,6 +3423,11 @@ let flareSuggestions = [
|
||||
photoUrls: [],
|
||||
},
|
||||
]
|
||||
const flareSuggestionFixtures = flareSuggestions.map((profile) => ({
|
||||
...profile,
|
||||
interests: [...profile.interests],
|
||||
photoUrls: [...profile.photoUrls],
|
||||
}))
|
||||
const flareMatches = [
|
||||
{
|
||||
id: 'flare-match-demo-0000-0000-0000000001',
|
||||
@@ -3488,14 +3493,24 @@ const flareMessages = {
|
||||
}
|
||||
|
||||
function flareBootstrap() {
|
||||
const hasProfile = Boolean(flareProfile)
|
||||
return {
|
||||
profile: flareProfile,
|
||||
suggestions: flareProfile.discoverable ? flareSuggestions : [],
|
||||
likes: flareLikes,
|
||||
matches: flareMatches,
|
||||
suggestions:
|
||||
hasProfile && flareProfile.discoverable ? flareSuggestions : [],
|
||||
likes: hasProfile ? flareLikes : [],
|
||||
matches: hasProfile ? flareMatches : [],
|
||||
}
|
||||
}
|
||||
|
||||
function freshFlareSuggestions() {
|
||||
return flareSuggestionFixtures.map((profile) => ({
|
||||
...profile,
|
||||
interests: [...profile.interests],
|
||||
photoUrls: [...profile.photoUrls],
|
||||
}))
|
||||
}
|
||||
|
||||
const companyCategories = [
|
||||
{ id: 'public_services', name: 'Public Services' },
|
||||
{ id: 'medical', name: 'Medical' },
|
||||
@@ -6265,10 +6280,30 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
.map(billingInvoice),
|
||||
}
|
||||
}
|
||||
if (endpoint.startsWith('flare:') && !authenticated) {
|
||||
response.json({ success: false, error: 'not_authenticated' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:bootstrap') {
|
||||
response.json({ success: true, data: flareBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:delete-profile') {
|
||||
if (!flareProfile) {
|
||||
response.json({ success: false, error: 'profile_not_found' })
|
||||
return
|
||||
}
|
||||
flareProfile = null
|
||||
flareSuggestions = freshFlareSuggestions()
|
||||
flareLikes = []
|
||||
flareLastSwipe = null
|
||||
flareMatches.splice(0, flareMatches.length)
|
||||
for (const matchId of Object.keys(flareMessages)) {
|
||||
delete flareMessages[matchId]
|
||||
}
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:save-profile') {
|
||||
const requestedPhotoIds = request.body.photoMediaIds
|
||||
let photoUpdate = {}
|
||||
@@ -6297,13 +6332,17 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
flareProfile = {
|
||||
...flareProfile,
|
||||
...request.body,
|
||||
discoverable: flareProfile.discoverable,
|
||||
discoverable: flareProfile?.discoverable ?? true,
|
||||
...photoUpdate,
|
||||
}
|
||||
response.json({ success: true, data: flareBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:set-discovery') {
|
||||
if (!flareProfile) {
|
||||
response.json({ success: false, error: 'invalid_profile' })
|
||||
return
|
||||
}
|
||||
if (typeof request.body.enabled !== 'boolean') {
|
||||
response.json({ success: false, error: 'invalid_discovery' })
|
||||
return
|
||||
@@ -6313,7 +6352,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:swipe') {
|
||||
if (!flareProfile.discoverable) {
|
||||
if (!flareProfile?.discoverable) {
|
||||
response.json({ success: false, error: 'discovery_disabled' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -681,6 +681,79 @@ async function verifyStatefulActions(baseUrl) {
|
||||
remainingTrash.items.some((message) => message.id === 4),
|
||||
'mail:delete-many removed non-selected trash mail',
|
||||
)
|
||||
|
||||
const flareBeforeDelete = await expectSuccess(
|
||||
baseUrl,
|
||||
'flare:bootstrap',
|
||||
{},
|
||||
true,
|
||||
)
|
||||
assert(flareBeforeDelete.profile, 'flare:bootstrap did not include a profile')
|
||||
assert(
|
||||
flareBeforeDelete.matches.length > 0,
|
||||
'flare:bootstrap did not include a deletable match',
|
||||
)
|
||||
const swipedProfile = flareBeforeDelete.suggestions[0]
|
||||
await expectSuccess(baseUrl, 'flare:swipe', {
|
||||
choice: 'pass',
|
||||
targetId: swipedProfile.id,
|
||||
})
|
||||
const flareAfterSwipe = await expectSuccess(
|
||||
baseUrl,
|
||||
'flare:bootstrap',
|
||||
{},
|
||||
true,
|
||||
)
|
||||
assert(
|
||||
!flareAfterSwipe.suggestions.some(
|
||||
(profile) => profile.id === swipedProfile.id,
|
||||
),
|
||||
'flare:swipe did not filter the swiped profile',
|
||||
)
|
||||
await expectSuccess(baseUrl, 'flare:delete-profile')
|
||||
const flareAfterDelete = await expectSuccess(
|
||||
baseUrl,
|
||||
'flare:bootstrap',
|
||||
{},
|
||||
true,
|
||||
)
|
||||
assert.equal(flareAfterDelete.profile, null)
|
||||
assert.deepEqual(flareAfterDelete.suggestions, [])
|
||||
assert.deepEqual(flareAfterDelete.likes, [])
|
||||
assert.deepEqual(flareAfterDelete.matches, [])
|
||||
const repeatedFlareDelete = await post(baseUrl, 'flare:delete-profile')
|
||||
assert.deepEqual(repeatedFlareDelete, {
|
||||
error: 'profile_not_found',
|
||||
success: false,
|
||||
})
|
||||
const recreatedFlare = await expectSuccess(
|
||||
baseUrl,
|
||||
'flare:save-profile',
|
||||
{
|
||||
...flareBeforeDelete.profile,
|
||||
photoMediaIds: [],
|
||||
photoUrls: undefined,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert(
|
||||
recreatedFlare.suggestions.some(
|
||||
(profile) => profile.id === swipedProfile.id,
|
||||
),
|
||||
'recreated Flare profile retained a deleted swipe filter',
|
||||
)
|
||||
|
||||
await expectSuccess(baseUrl, 'account:logout')
|
||||
const signedOutFlareBootstrap = await post(baseUrl, 'flare:bootstrap')
|
||||
assert.deepEqual(signedOutFlareBootstrap, {
|
||||
error: 'not_authenticated',
|
||||
success: false,
|
||||
})
|
||||
const signedOutFlareDelete = await post(baseUrl, 'flare:delete-profile')
|
||||
assert.deepEqual(signedOutFlareDelete, {
|
||||
error: 'not_authenticated',
|
||||
success: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -417,6 +417,7 @@ Locales["en"] = {
|
||||
name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private Sky Cloud account.",
|
||||
createProfile = "Create your profile", welcome = "Find your spark", welcomeBody = "Build a profile and meet people across Los Santos.",
|
||||
photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Add up to six photos from Photos or Camera. Your first photo is shown first.", addPhotos = "Add photos", choosePhotos = "Choose from Photos", primaryPhoto = "Main", removePhoto = "Remove photo {number}",
|
||||
profilePhotoFallback = "Flare profile avatar", profilePhotoNumber = "Profile photo {number} of {count}", viewProfilePhoto = "Show profile photo {number} of {count}",
|
||||
yourName = "Your name", namePlaceholder = "What should people call you?", age = "Age", bio = "About you", bioPlaceholder = "A short line that starts a conversation...",
|
||||
gender = "I am", showMe = "Show me", woman = "Woman", man = "Man", nonbinary = "Non-binary", women = "Women", men = "Men", nonbinaryPeople = "Non-binary people", everyone = "Everyone",
|
||||
minimumAge = "Minimum age", maximumAge = "Maximum age", start = "Start exploring", saveProfile = "Save profile", editProfileBody = "Keep your profile fresh and unmistakably you.",
|
||||
@@ -427,6 +428,8 @@ Locales["en"] = {
|
||||
exploreModes = { forYou = "For You", dateNight = "Date Night", freeTonight = "Free Tonight", longTerm = "Long-term thing", newFriends = "New Friends", weekend = "Weekend plans" },
|
||||
likesTitle = "Likes You", likesBody = "See who already likes you, then make your choice.", matched = "Matched", newMatches = "New Matches", messages = "Messages",
|
||||
settings = "Settings", editProfile = "Edit profile", editRelationshipGoal = "Edit relationship goal", profileGoalBody = "Shown on your discovery card.", like = "Like", pass = "Pass",
|
||||
accountActions = "Account", signOut = "Sign Out", signOutTitle = "Sign out of Sky Cloud?", signOutBody = "This signs the whole phone out of Sky Cloud. Your Flare profile, matches and chats stay stored.", signingOut = "Signing Out...", signOutHint = "Signing out affects every app that uses Sky Cloud on this phone.",
|
||||
deleteAccount = "Delete Flare Account", deleteAccountTitle = "Delete your Flare account?", deleteAccountBody = "Your Flare profile, swipes, matches and conversations will be permanently deleted. Your Sky Cloud account and Photos library stay available.", deletingAccount = "Deleting...",
|
||||
relationshipGoal = "Relationship goal", interests = "Interests", interestsPlaceholder = "Music, travel, coffee", interestsHint = "Separate up to five interests with commas.",
|
||||
discoverySettings = "Discovery", discoveryPreferences = "Who you want to meet", showProfile = "Show me on Flare", showProfileBody = "Let new people discover your profile.",
|
||||
discoveryPrivacyNote = "Turning Discovery off hides you from new people. Your existing matches and chats stay available.",
|
||||
@@ -438,7 +441,7 @@ Locales["en"] = {
|
||||
messagePlaceholder = "Write a message", itsAMatch = "It's a spark!", matchBody = "You and {name} liked each other.", sayHello = "Say hello", keepSwiping = "Keep exploring", newMatchNotification = "You and {sender} found a spark!", newMessageNotification = "New message from {sender}",
|
||||
lookingFor = { longTerm = "Long-term connection", dates = "Good dates", friends = "New friends" },
|
||||
errors = {
|
||||
invalid_profile = "Check your name, age and profile text.", 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 up to six photos saved in your own Photos library.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.",
|
||||
nothing_to_rewind = "There is no recent swipe to rewind.", cannot_rewind_match = "A swipe that created a match cannot be rewound.",
|
||||
match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.",
|
||||
},
|
||||
|
||||
@@ -272,6 +272,7 @@ local server_callbacks = {
|
||||
"darkchat:report",
|
||||
"darkchat:clear",
|
||||
"flare:bootstrap",
|
||||
"flare:delete-profile",
|
||||
"flare:save-profile",
|
||||
"flare:set-discovery",
|
||||
"flare:swipe",
|
||||
|
||||
@@ -467,6 +467,42 @@ Bridge.Callbacks.Register("sky_phone:flare:bootstrap", function(source)
|
||||
return { success = true, data = bootstrap(account.id) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:flare:delete-profile", function(source)
|
||||
if not SkyPhone.AllowOperation(source, "flare_profile_delete", 3, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
if not load_profile(account.id) then
|
||||
return { success = false, error = "profile_not_found" }
|
||||
end
|
||||
if not Bridge.Database.Transaction({
|
||||
{
|
||||
query = [[
|
||||
DELETE FROM `sky_phone_flare_matches`
|
||||
WHERE `account_a_id` = ? OR `account_b_id` = ?
|
||||
]],
|
||||
params = { account.id, account.id },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
DELETE FROM `sky_phone_flare_swipes`
|
||||
WHERE `swiper_account_id` = ? OR `target_account_id` = ?
|
||||
]],
|
||||
params = { account.id, account.id },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_flare_profiles` WHERE `account_id` = ?",
|
||||
params = { account.id },
|
||||
},
|
||||
}) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:flare:save-profile", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "flare_profile", 12, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
|
||||
Reference in New Issue
Block a user