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:
Dominik
2026-08-16 20:33:25 +02:00
parent d260f44b96
commit 3b7f0b8858
10 changed files with 576 additions and 34 deletions
+29
View File
@@ -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',
+17
View File
@@ -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
+17
View File
@@ -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`')
})
})
+285 -28
View File
@@ -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;
+44 -5
View File
@@ -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
}
+73
View File
@@ -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() {
+4 -1
View File
@@ -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.",
},
+1
View File
@@ -272,6 +272,7 @@ local server_callbacks = {
"darkchat:report",
"darkchat:clear",
"flare:bootstrap",
"flare:delete-profile",
"flare:save-profile",
"flare:set-discovery",
"flare:swipe",
+36
View File
@@ -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" }