mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - implement Flare dating app
This commit is contained in:
@@ -29,6 +29,7 @@ import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
@@ -58,6 +59,7 @@ type AppMessage = {
|
||||
| MarketplaceEventData
|
||||
| MessagesEventData
|
||||
| DarkChatEventData
|
||||
| FlareEventData
|
||||
| PhoneCall
|
||||
| PhoneNotificationInput
|
||||
| PhoneOpenPayload
|
||||
@@ -95,6 +97,15 @@ type DarkChatEventData = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
type FlareEventData = {
|
||||
body?: string
|
||||
device?: PhoneNotificationDevicePayload
|
||||
matchId?: string
|
||||
sender?: string
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type MarketplaceEventData = {
|
||||
counts?: MarketplaceCounts
|
||||
device?: PhoneNotificationDevicePayload
|
||||
@@ -128,6 +139,7 @@ const banking = useBankingStore()
|
||||
const mail = useMailStore()
|
||||
const messages = useMessagesStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const flare = useFlareStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const appStore = useAppStoreStore()
|
||||
@@ -381,6 +393,44 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
}
|
||||
notifications.show(notification)
|
||||
}
|
||||
} else if (
|
||||
(event.data?.type === 'flare:new-match' ||
|
||||
event.data?.type === 'flare:new-message') &&
|
||||
event.data.data
|
||||
) {
|
||||
const data = event.data.data as FlareEventData
|
||||
void flare.bootstrap()
|
||||
if (
|
||||
data.matchId &&
|
||||
event.data.type === 'flare:new-message' &&
|
||||
flare.activeMatchId === data.matchId
|
||||
) {
|
||||
void flare.loadThread(data.matchId)
|
||||
}
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'flare',
|
||||
subtitle: data.sender,
|
||||
text:
|
||||
data.text ??
|
||||
phone.t(
|
||||
event.data.type === 'flare:new-match'
|
||||
? 'Apps.flare.newMatchNotification'
|
||||
: 'Apps.flare.newMessageNotification',
|
||||
{ sender: data.sender ?? '' },
|
||||
),
|
||||
title: data.title ?? phone.t('Apps.flare.name'),
|
||||
}
|
||||
if (
|
||||
data.device &&
|
||||
(!phone.isOpen || data.device.imei !== phone.device?.imei)
|
||||
) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
} else if (event.data?.type === 'calls:changed') {
|
||||
void calls.loadRecents()
|
||||
} else if (event.data?.type === 'banking:changed') {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="18" y1="112" x2="108" y2="10" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff2d78"/>
|
||||
<stop offset="1" stop-color="#ff6847"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="url(#g)"/>
|
||||
<path fill="#fff" d="M67 18c3 19-13 25-13 41 0 8 5 13 10 16-1-13 8-19 17-29 1 15 19 22 19 41 0 17-15 29-36 29S28 103 28 84c0-20 13-31 26-43 6-6 10-13 13-23Z"/>
|
||||
<path fill="#ff4e63" d="M66 105c-11 0-19-7-19-17 0-9 7-14 13-20 0 8 7 10 7 17 4-4 7-8 8-13 6 6 10 11 10 18 0 9-8 15-19 15Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -118,7 +118,7 @@ describe('app registry', () => {
|
||||
PHONE_APPS.filter((app) => app.category === 'social').map(
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail'])
|
||||
).toEqual(['flare', 'local-pages', 'phone', 'darkchat', 'banking', 'mail'])
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Wind,
|
||||
Tag,
|
||||
MapPinHouse,
|
||||
Flame,
|
||||
} from 'lucide-vue-next'
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
|
||||
@@ -52,6 +53,7 @@ import bankingIcon from '@/assets/img/app-icons/banking.svg'
|
||||
import garageIcon from '@/assets/img/app-icons/garage.svg'
|
||||
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
|
||||
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
|
||||
import flareIcon from '@/assets/img/app-icons/flare.svg'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
@@ -59,6 +61,20 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/FlareApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 24,
|
||||
icon: markRaw(Flame),
|
||||
iconClass: 'app-icon--flare',
|
||||
iconImage: flareIcon,
|
||||
id: 'flare',
|
||||
labelKey: 'Apps.flare.name',
|
||||
route: '/apps/flare',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import type {
|
||||
FlareBootstrap,
|
||||
FlareLike,
|
||||
FlareMatch,
|
||||
FlareProfile,
|
||||
FlareProfileDraft,
|
||||
} from '@/types/flare'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
const maya: FlareProfile = {
|
||||
age: 26,
|
||||
avatar: 0,
|
||||
bio: 'Ocean air and rooftop sunsets.',
|
||||
gender: 'woman',
|
||||
id: 11,
|
||||
interests: ['Beach days', 'Art'],
|
||||
lookingFor: 'longTerm',
|
||||
name: 'Maya',
|
||||
photoUrls: [],
|
||||
}
|
||||
const mayaLike: FlareLike = { ...maya, superLiked: true }
|
||||
const bootstrap: FlareBootstrap = {
|
||||
likes: [mayaLike],
|
||||
matches: [],
|
||||
profile: {
|
||||
age: 27,
|
||||
avatar: 5,
|
||||
bio: 'Late-night drives and good coffee.',
|
||||
discoverable: true,
|
||||
gender: 'nonbinary',
|
||||
id: 1,
|
||||
interestedIn: 'everyone',
|
||||
interests: ['Music', 'Coffee'],
|
||||
lookingFor: 'dates',
|
||||
maxAge: 39,
|
||||
minAge: 21,
|
||||
name: 'Alex',
|
||||
photoMediaIds: [],
|
||||
photoUrls: [],
|
||||
},
|
||||
suggestions: [maya],
|
||||
}
|
||||
|
||||
describe('flare store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads suggestions, incoming likes and own discovery state', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true })
|
||||
const flare = useFlareStore()
|
||||
|
||||
expect(await flare.bootstrap()).toBe(true)
|
||||
expect(flare.profile?.discoverable).toBe(true)
|
||||
expect(flare.likes).toEqual([mayaLike])
|
||||
expect(flare.suggestions).toEqual([maya])
|
||||
})
|
||||
|
||||
it('keeps matches available when Discovery is turned off', async () => {
|
||||
const hidden = {
|
||||
...bootstrap,
|
||||
profile: { ...bootstrap.profile!, discoverable: false },
|
||||
suggestions: [],
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({ data: hidden, success: true })
|
||||
const flare = useFlareStore()
|
||||
|
||||
expect(await flare.setDiscovery(false)).toBe(true)
|
||||
expect(flare.profile?.discoverable).toBe(false)
|
||||
expect(flare.suggestions).toEqual([])
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('flare:set-discovery', {
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only Gallery media ids when profile photos are saved', async () => {
|
||||
const updated = {
|
||||
...bootstrap,
|
||||
profile: {
|
||||
...bootstrap.profile!,
|
||||
photoMediaIds: [42],
|
||||
photoUrls: ['https://cdn.example.test/profile.jpg'],
|
||||
},
|
||||
}
|
||||
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: [42],
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
|
||||
const flare = useFlareStore()
|
||||
|
||||
expect(await flare.saveProfile(draft)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('flare:save-profile', draft)
|
||||
expect(flare.profile?.photoUrls).toEqual([
|
||||
'https://cdn.example.test/profile.jpg',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses a real Super Like and removes the target from both decks', async () => {
|
||||
const match: FlareMatch = {
|
||||
id: 'match-1',
|
||||
lastMessage: '',
|
||||
lastMessageAt: null,
|
||||
profile: maya,
|
||||
unread: 0,
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({ data: { match }, success: true })
|
||||
const flare = useFlareStore()
|
||||
flare.suggestions = [maya]
|
||||
flare.likes = [mayaLike]
|
||||
|
||||
expect(await flare.swipe(maya.id, 'superlike')).toEqual(match)
|
||||
expect(flare.suggestions).toEqual([])
|
||||
expect(flare.likes).toEqual([])
|
||||
expect(flare.matches).toEqual([match])
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('flare:swipe', {
|
||||
choice: 'superlike',
|
||||
targetId: maya.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the previous discovery state when the server rejects a toggle', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'invalid_discovery',
|
||||
success: false,
|
||||
})
|
||||
const flare = useFlareStore()
|
||||
flare.profile = bootstrap.profile
|
||||
|
||||
expect(await flare.setDiscovery(false)).toBe(false)
|
||||
expect(flare.profile?.discoverable).toBe(true)
|
||||
expect(flare.error).toBe('invalid_discovery')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
FlareBootstrap,
|
||||
FlareMatch,
|
||||
FlareMessage,
|
||||
FlareProfile,
|
||||
FlareProfileDraft,
|
||||
} from '@/types/flare'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
export const useFlareStore = defineStore('flare', {
|
||||
state: () => ({
|
||||
activeMatchId: '' as string,
|
||||
error: '' as string,
|
||||
loading: false,
|
||||
likes: [] as FlareBootstrap['likes'],
|
||||
matches: [] as FlareMatch[],
|
||||
messages: [] as FlareMessage[],
|
||||
profile: null as FlareBootstrap['profile'],
|
||||
sending: false,
|
||||
suggestions: [] as FlareProfile[],
|
||||
}),
|
||||
actions: {
|
||||
async bootstrap(): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<FlareBootstrap>('flare:bootstrap')
|
||||
this.loading = false
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success && response.data) {
|
||||
this.applyBootstrap(response.data)
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async loadThread(matchId: string): Promise<boolean> {
|
||||
this.activeMatchId = matchId
|
||||
const response = await nuiCall<{ messages: FlareMessage[] }>(
|
||||
'flare:thread',
|
||||
{ matchId },
|
||||
)
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) {
|
||||
this.messages = response.data?.messages ?? []
|
||||
const match = this.matches.find((item) => item.id === matchId)
|
||||
if (match) match.unread = 0
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async saveProfile(draft: FlareProfileDraft): Promise<boolean> {
|
||||
const response = await nuiCall<FlareBootstrap>(
|
||||
'flare:save-profile',
|
||||
draft,
|
||||
)
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success && response.data) {
|
||||
this.applyBootstrap(response.data)
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async send(matchId: string, body: string): Promise<boolean> {
|
||||
this.sending = true
|
||||
const response = await nuiCall<FlareMessage>('flare:send', {
|
||||
body,
|
||||
matchId,
|
||||
})
|
||||
this.sending = false
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success && response.data) {
|
||||
this.messages.push(response.data)
|
||||
const match = this.matches.find((item) => item.id === matchId)
|
||||
if (match) {
|
||||
match.lastMessage = response.data.body
|
||||
match.lastMessageAt = response.data.createdAt
|
||||
}
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async swipe(
|
||||
targetId: number,
|
||||
choice: 'like' | 'pass' | 'superlike',
|
||||
): Promise<FlareMatch | null> {
|
||||
const response = await nuiCall<{ match: FlareMatch | null }>(
|
||||
'flare:swipe',
|
||||
{ choice, targetId },
|
||||
)
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (!response.success) return null
|
||||
this.suggestions = this.suggestions.filter(
|
||||
(profile) => profile.id !== targetId,
|
||||
)
|
||||
this.likes = this.likes.filter((profile) => profile.id !== targetId)
|
||||
const match = response.data?.match ?? null
|
||||
if (match) {
|
||||
this.matches = [
|
||||
match,
|
||||
...this.matches.filter((item) => item.id !== match.id),
|
||||
]
|
||||
}
|
||||
return match
|
||||
},
|
||||
async rewind(): Promise<boolean> {
|
||||
const response = await nuiCall<FlareBootstrap>('flare:rewind')
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success && response.data) this.applyBootstrap(response.data)
|
||||
return response.success
|
||||
},
|
||||
async setDiscovery(enabled: boolean): Promise<boolean> {
|
||||
const response = await nuiCall<FlareBootstrap>('flare:set-discovery', {
|
||||
enabled,
|
||||
})
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success && response.data) this.applyBootstrap(response.data)
|
||||
return response.success
|
||||
},
|
||||
async unmatch(matchId: string): Promise<boolean> {
|
||||
const response = await nuiCall<{ matches: FlareMatch[] }>(
|
||||
'flare:unmatch',
|
||||
{
|
||||
matchId,
|
||||
},
|
||||
)
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) {
|
||||
this.matches = response.data?.matches ?? []
|
||||
this.messages = []
|
||||
if (this.activeMatchId === matchId) this.activeMatchId = ''
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
applyBootstrap(data: FlareBootstrap): void {
|
||||
this.profile = data.profile
|
||||
this.likes = data.likes
|
||||
this.matches = data.matches
|
||||
this.suggestions = data.suggestions
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -28,6 +28,139 @@ const namespaceQueues = new Map<string, Promise<void>>()
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
flare: {
|
||||
name: 'Flare',
|
||||
signInTitle: 'Sign in to Flare',
|
||||
signInBody:
|
||||
'Flare profiles and matches are linked to your private iFruit 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:
|
||||
'Choose up to six photos from Gallery. Your first photo is shown first.',
|
||||
addPhotos: 'Add photos',
|
||||
choosePhotos: 'Choose from Gallery',
|
||||
primaryPhoto: 'Main',
|
||||
removePhoto: 'Remove photo {number}',
|
||||
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.',
|
||||
navigation: 'Flare navigation',
|
||||
discover: 'Discover',
|
||||
matches: 'Matches',
|
||||
profile: 'Profile',
|
||||
tabs: {
|
||||
discover: 'Swipe',
|
||||
explore: 'Explore',
|
||||
likes: 'Likes',
|
||||
matches: 'Chat',
|
||||
profile: 'Profile',
|
||||
},
|
||||
filters: 'Discovery settings',
|
||||
nearby: 'Nearby',
|
||||
rewind: 'Rewind',
|
||||
superLike: 'Super Like',
|
||||
explore: 'Explore',
|
||||
clearExploreFilter: 'Show all profiles',
|
||||
exploreTitle: 'Explore',
|
||||
exploreBody: 'Find people who are into the same things.',
|
||||
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',
|
||||
profileGoalBody: 'Shown on your discovery card.',
|
||||
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.',
|
||||
discoveryOffTitle: 'Discovery is off',
|
||||
discoveryOffBody:
|
||||
'Your profile is hidden from new people. Your existing matches and chats stay available.',
|
||||
discoveryOffShort: 'Only existing matches can still reach you.',
|
||||
enableDiscovery: 'Enable Discovery',
|
||||
saveSettings: 'Save settings',
|
||||
likedYou: 'Likes you',
|
||||
superLikedYou: 'Super Liked you',
|
||||
noLikes: 'No new likes',
|
||||
noLikesBody: 'People who like you before you decide will appear here.',
|
||||
matchActions: 'Match actions',
|
||||
unmatch: 'Unmatch',
|
||||
unmatchTitle: 'Unmatch this person?',
|
||||
unmatchBody:
|
||||
'You and {name} will disappear from each other’s match lists. This cannot be undone.',
|
||||
like: 'Like',
|
||||
pass: 'Pass',
|
||||
noProfiles: "You're all caught up",
|
||||
noProfilesBody: 'Come back later when new people join Flare.',
|
||||
yourMatches: 'Your matches',
|
||||
matchesBody: 'A spark goes both ways.',
|
||||
newMatch: 'You matched — say hello!',
|
||||
noMatches: 'No sparks yet',
|
||||
noMatchesBody: 'When someone likes you back, they will appear here.',
|
||||
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 up to six photos from your own Gallery.',
|
||||
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.',
|
||||
rate_limited: 'Slow down for a moment and try again.',
|
||||
default: 'Flare could not complete the request.',
|
||||
},
|
||||
},
|
||||
darkchat: {
|
||||
name: 'DarkChat',
|
||||
newMessage: 'New DarkChat message from {sender}',
|
||||
|
||||
@@ -26,6 +26,7 @@ export type PhoneAppId =
|
||||
| 'neon-drop'
|
||||
| 'citymarkt'
|
||||
| 'local-pages'
|
||||
| 'flare'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export type FlareGender = 'woman' | 'man' | 'nonbinary'
|
||||
export type FlareInterest = FlareGender | 'everyone'
|
||||
|
||||
export type FlareProfile = {
|
||||
age: number
|
||||
avatar: number
|
||||
bio: string
|
||||
gender: FlareGender
|
||||
id: number
|
||||
interests: string[]
|
||||
lookingFor: string
|
||||
name: string
|
||||
photoUrls: string[]
|
||||
}
|
||||
|
||||
export type FlareLike = FlareProfile & { superLiked: boolean }
|
||||
|
||||
export type FlareMatch = {
|
||||
id: string
|
||||
lastMessage: string
|
||||
lastMessageAt: string | null
|
||||
profile: FlareProfile
|
||||
unread: number
|
||||
}
|
||||
|
||||
export type FlareOwnProfile = FlareProfile & {
|
||||
discoverable: boolean
|
||||
interestedIn: FlareInterest
|
||||
maxAge: number
|
||||
minAge: number
|
||||
photoMediaIds: number[]
|
||||
}
|
||||
|
||||
export type FlareMessage = {
|
||||
body: string
|
||||
createdAt: string
|
||||
direction: 'received' | 'sent'
|
||||
id: string
|
||||
}
|
||||
|
||||
export type FlareProfileDraft = Omit<
|
||||
FlareOwnProfile,
|
||||
'discoverable' | 'id' | 'photoUrls'
|
||||
>
|
||||
|
||||
export type FlareBootstrap = {
|
||||
likes: FlareLike[]
|
||||
matches: FlareMatch[]
|
||||
profile: FlareOwnProfile | null
|
||||
suggestions: FlareProfile[]
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
phone: { enabled: true, sounds: true },
|
||||
messages: { enabled: true, sounds: true },
|
||||
darkchat: { enabled: true, sounds: true },
|
||||
flare: { enabled: true, sounds: true },
|
||||
'app-store': { enabled: true, sounds: true },
|
||||
calculator: { enabled: true, sounds: true },
|
||||
snake: { enabled: true, sounds: true },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1039,6 +1039,131 @@ function counts() {
|
||||
}
|
||||
}
|
||||
|
||||
let flareProfile = {
|
||||
id: 1,
|
||||
name: 'Alex',
|
||||
age: 27,
|
||||
bio: 'Late-night drives, good coffee, and plans that turn into stories.',
|
||||
gender: 'nonbinary',
|
||||
interestedIn: 'everyone',
|
||||
minAge: 21,
|
||||
maxAge: 39,
|
||||
avatar: 5,
|
||||
interests: ['Night drives', 'Music', 'Coffee'],
|
||||
lookingFor: 'dates',
|
||||
discoverable: true,
|
||||
photoMediaIds: [],
|
||||
photoUrls: [],
|
||||
}
|
||||
let flareSuggestions = [
|
||||
{
|
||||
id: 11,
|
||||
name: 'Maya',
|
||||
age: 26,
|
||||
bio: 'Ocean air, rooftop sunsets and always up for finding the best tacos in the city.',
|
||||
gender: 'woman',
|
||||
avatar: 0,
|
||||
interests: ['Beach days', 'Food spots', 'Art'],
|
||||
lookingFor: 'longTerm',
|
||||
photoUrls: [],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Noah',
|
||||
age: 29,
|
||||
bio: 'Architect by day, terrible karaoke singer by night.',
|
||||
gender: 'man',
|
||||
avatar: 1,
|
||||
interests: ['Architecture', 'Karaoke', 'Travel'],
|
||||
lookingFor: 'dates',
|
||||
photoUrls: [],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Sofia',
|
||||
age: 25,
|
||||
bio: 'Tell me your favorite hidden corner of Los Santos.',
|
||||
gender: 'woman',
|
||||
avatar: 2,
|
||||
interests: ['Coffee', 'Photography', 'Dogs'],
|
||||
lookingFor: 'friends',
|
||||
photoUrls: [],
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'Leo',
|
||||
age: 28,
|
||||
bio: 'Desert roads, classic cars and a playlist for every mood.',
|
||||
gender: 'man',
|
||||
avatar: 3,
|
||||
interests: ['Cars', 'Road trips', 'Vinyl'],
|
||||
lookingFor: 'longTerm',
|
||||
photoUrls: [],
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: 'Jade',
|
||||
age: 24,
|
||||
bio: 'Usually somewhere near the water with an iced coffee.',
|
||||
gender: 'woman',
|
||||
avatar: 4,
|
||||
interests: ['Sailing', 'Fitness', 'Brunch'],
|
||||
lookingFor: 'dates',
|
||||
photoUrls: [],
|
||||
},
|
||||
]
|
||||
const flareMatches = [
|
||||
{
|
||||
id: 'flare-match-demo-0000-0000-0000000001',
|
||||
profile: {
|
||||
id: 16,
|
||||
name: 'Marcus',
|
||||
age: 30,
|
||||
bio: 'Live music and late dinners.',
|
||||
gender: 'man',
|
||||
avatar: 5,
|
||||
interests: ['Live music', 'Cooking'],
|
||||
lookingFor: 'dates',
|
||||
photoUrls: [],
|
||||
},
|
||||
lastMessage: 'That place sounds perfect. Friday?',
|
||||
lastMessageAt: isoTime(-38 * 60 * 1000),
|
||||
unread: 1,
|
||||
},
|
||||
]
|
||||
let flareLikes = [
|
||||
{
|
||||
...flareSuggestions[0],
|
||||
superLiked: true,
|
||||
},
|
||||
]
|
||||
let flareLastSwipe = null
|
||||
const flareMessages = {
|
||||
'flare-match-demo-0000-0000-0000000001': [
|
||||
{
|
||||
id: 'flare-message-1',
|
||||
direction: 'sent',
|
||||
body: 'Have you tried the little jazz bar in Vinewood?',
|
||||
createdAt: isoTime(-55 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: 'flare-message-2',
|
||||
direction: 'received',
|
||||
body: 'That place sounds perfect. Friday?',
|
||||
createdAt: isoTime(-38 * 60 * 1000),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function flareBootstrap() {
|
||||
return {
|
||||
profile: flareProfile,
|
||||
suggestions: flareProfile.discoverable ? flareSuggestions : [],
|
||||
likes: flareLikes,
|
||||
matches: flareMatches,
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
@@ -1050,6 +1175,152 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
playerName: 'Alex Morgan',
|
||||
transactions: mockBankTransactions,
|
||||
})
|
||||
if (endpoint === 'flare:bootstrap') {
|
||||
response.json({ success: true, data: flareBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:save-profile') {
|
||||
const requestedPhotoIds = request.body.photoMediaIds
|
||||
let photoUpdate = {}
|
||||
if (requestedPhotoIds !== undefined) {
|
||||
const validIds =
|
||||
Array.isArray(requestedPhotoIds) &&
|
||||
requestedPhotoIds.length <= 6 &&
|
||||
new Set(requestedPhotoIds).size === requestedPhotoIds.length &&
|
||||
requestedPhotoIds.every((id) => Number.isInteger(id) && id > 0)
|
||||
const photos = validIds
|
||||
? requestedPhotoIds.map((id) =>
|
||||
mockMedia.find(
|
||||
(item) => item.id === id && item.mediaType === 'photo',
|
||||
),
|
||||
)
|
||||
: []
|
||||
if (!validIds || photos.some((photo) => !photo)) {
|
||||
response.json({ success: false, error: 'invalid_profile_photos' })
|
||||
return
|
||||
}
|
||||
photoUpdate = {
|
||||
photoMediaIds: [...requestedPhotoIds],
|
||||
photoUrls: photos.map((photo) => photo.url),
|
||||
}
|
||||
}
|
||||
flareProfile = {
|
||||
...flareProfile,
|
||||
...request.body,
|
||||
discoverable: flareProfile.discoverable,
|
||||
...photoUpdate,
|
||||
}
|
||||
response.json({ success: true, data: flareBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:set-discovery') {
|
||||
if (typeof request.body.enabled !== 'boolean') {
|
||||
response.json({ success: false, error: 'invalid_discovery' })
|
||||
return
|
||||
}
|
||||
flareProfile.discoverable = request.body.enabled
|
||||
response.json({ success: true, data: flareBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:swipe') {
|
||||
if (!flareProfile.discoverable) {
|
||||
response.json({ success: false, error: 'discovery_disabled' })
|
||||
return
|
||||
}
|
||||
const target = flareSuggestions.find(
|
||||
(profile) => profile.id === Number(request.body.targetId),
|
||||
)
|
||||
flareLastSwipe = target
|
||||
? { choice: request.body.choice, profile: target }
|
||||
: null
|
||||
flareSuggestions = flareSuggestions.filter(
|
||||
(profile) => profile.id !== Number(request.body.targetId),
|
||||
)
|
||||
flareLikes = flareLikes.filter(
|
||||
(profile) => profile.id !== Number(request.body.targetId),
|
||||
)
|
||||
let match = null
|
||||
if (
|
||||
target &&
|
||||
['like', 'superlike'].includes(request.body.choice) &&
|
||||
target.id === 11
|
||||
) {
|
||||
match = {
|
||||
id: 'flare-match-maya-0000-0000-0000000001',
|
||||
profile: target,
|
||||
lastMessage: '',
|
||||
lastMessageAt: null,
|
||||
unread: 0,
|
||||
}
|
||||
flareMatches.unshift(match)
|
||||
flareMessages[match.id] = []
|
||||
}
|
||||
response.json({ success: true, data: { match } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:rewind') {
|
||||
if (!flareLastSwipe) {
|
||||
response.json({ success: false, error: 'nothing_to_rewind' })
|
||||
return
|
||||
}
|
||||
const hasMatch = flareMatches.some(
|
||||
(match) => match.profile.id === flareLastSwipe.profile.id,
|
||||
)
|
||||
if (hasMatch) {
|
||||
response.json({ success: false, error: 'cannot_rewind_match' })
|
||||
return
|
||||
}
|
||||
flareSuggestions.unshift(flareLastSwipe.profile)
|
||||
flareLastSwipe = null
|
||||
response.json({ success: true, data: flareBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:unmatch') {
|
||||
const index = flareMatches.findIndex(
|
||||
(match) => match.id === request.body.matchId,
|
||||
)
|
||||
if (index < 0) {
|
||||
response.json({ success: false, error: 'match_not_found' })
|
||||
return
|
||||
}
|
||||
flareMatches.splice(index, 1)
|
||||
delete flareMessages[request.body.matchId]
|
||||
response.json({ success: true, data: { matches: flareMatches } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:thread') {
|
||||
const match = flareMatches.find((item) => item.id === request.body.matchId)
|
||||
if (!match) {
|
||||
response.json({ success: false, error: 'match_not_found' })
|
||||
return
|
||||
}
|
||||
match.unread = 0
|
||||
response.json({
|
||||
success: true,
|
||||
data: { messages: flareMessages[match.id] ?? [] },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'flare:send') {
|
||||
const match = flareMatches.find((item) => item.id === request.body.matchId)
|
||||
const body = String(request.body.body ?? '').trim()
|
||||
if (!match || !body) {
|
||||
response.json({ success: false, error: 'invalid_message' })
|
||||
return
|
||||
}
|
||||
const message = {
|
||||
id: `flare-message-${Date.now()}`,
|
||||
direction: 'sent',
|
||||
body,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
flareMessages[match.id] ??= []
|
||||
flareMessages[match.id].push(message)
|
||||
match.lastMessage = body
|
||||
match.lastMessageAt = message.createdAt
|
||||
response.json({ success: true, data: message })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'banking:overview') {
|
||||
response.json({ success: true, data: bankingOverview() })
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user