Merge pull request #2 from sky-systems/tinder-app

ADD - merge Flare dating app
This commit is contained in:
Dominik
2026-08-09 05:39:46 +02:00
committed by GitHub
20 changed files with 4980 additions and 9 deletions
+50
View File
@@ -31,6 +31,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 { useFlipTokStore } from '@/stores/fliptok'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
@@ -61,6 +62,7 @@ type AppMessage = {
| MarketplaceEventData
| MessagesEventData
| DarkChatEventData
| FlareEventData
| FlipTokVerificationData
| FlipTokNotificationData
| PhoneCall
@@ -100,6 +102,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
@@ -147,6 +158,7 @@ const banking = useBankingStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
@@ -441,6 +453,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

@@ -2,9 +2,15 @@
import { Camera, Pause, Play } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import type { SmsMessage } from '@/types/messages'
import type { SmsMessageType } from '@/types/messages'
const props = defineProps<{ message: SmsMessage }>()
type MessageAttachment = {
media_asset_id: string | null
media_duration_ms: number | null
message_type: SmsMessageType
}
const props = defineProps<{ message: MessageAttachment }>()
const playing = ref(false)
const video = ref<HTMLVideoElement>()
@@ -66,7 +72,13 @@ function durationLabel(milliseconds: number | null): string {
class="messages-attachment messages-attachment--image"
:style="{ background }"
>
<img v-if="mediaUrl" :src="mediaUrl" alt="" loading="lazy" referrerpolicy="no-referrer" />
<img
v-if="mediaUrl"
:src="mediaUrl"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
/>
<Camera v-else :size="18" />
</div>
<button
@@ -85,11 +97,22 @@ function durationLabel(milliseconds: number | null): string {
preload="metadata"
@ended="playing = false"
/>
<span><Pause v-if="playing" :size="22" fill="currentColor" /><Play v-else :size="22" fill="currentColor" /></span>
<span
><Pause v-if="playing" :size="22" fill="currentColor" /><Play
v-else
:size="22"
fill="currentColor"
/></span>
<small>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<img v-if="mediaUrl" :src="mediaUrl" alt="GIF" loading="lazy" referrerpolicy="no-referrer" />
<img
v-if="mediaUrl"
:src="mediaUrl"
alt="GIF"
loading="lazy"
referrerpolicy="no-referrer"
/>
<template v-else>
<span>{{ gif.emoji }}</span>
<strong>{{ gif.label }}</strong>
+5 -4
View File
@@ -124,10 +124,11 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual([
'fliptok',
'radio',
'local-pages',
).toEqual([
'fliptok',
'flare',
'radio',
'local-pages',
'phone',
'darkchat',
'banking',
+16
View File
@@ -25,6 +25,7 @@ import {
Wind,
Tag,
MapPinHouse,
Flame,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
@@ -54,6 +55,7 @@ import bankingIcon from '@/assets/img/app-icons/banking.webp'
import garageIcon from '@/assets/img/app-icons/garage.webp'
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 flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import type {
LaunchablePhoneAppDefinition,
@@ -76,6 +78,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.fliptok.name',
route: '/apps/fliptok',
},
{
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(
+192
View File
@@ -0,0 +1,192 @@
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,
lastMessageType: 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')
})
it('sends media messages and keeps numeric database timestamps intact', async () => {
const match: FlareMatch = {
id: 'match-media',
lastMessage: '',
lastMessageAt: null,
lastMessageType: null,
profile: maya,
unread: 0,
}
const sentAt = Date.now()
mockNuiCall.mockResolvedValueOnce({
data: {
body: '',
createdAt: sentAt,
direction: 'sent',
id: 'message-media',
mediaDurationMs: null,
mediaUrl: 'https://cdn.example.test/photo.jpg',
messageType: 'image',
},
success: true,
})
const flare = useFlareStore()
flare.matches = [match]
expect(
await flare.send(match.id, {
mediaAssetId: '42',
messageType: 'image',
}),
).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('flare:send', {
matchId: match.id,
mediaAssetId: '42',
messageType: 'image',
})
expect(flare.messages[0]?.createdAt).toBe(sentAt)
expect(match.lastMessageType).toBe('image')
})
})
+142
View File
@@ -0,0 +1,142 @@
import { defineStore } from 'pinia'
import type {
FlareBootstrap,
FlareMatch,
FlareMessage,
FlareOutgoingMessage,
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,
outgoing: FlareOutgoingMessage,
): Promise<boolean> {
this.sending = true
const response = await nuiCall<FlareMessage>('flare:send', {
matchId,
...outgoing,
})
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
match.lastMessageType = response.data.messageType
}
}
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
},
},
})
+151
View File
@@ -40,6 +40,157 @@ 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',
messagePhoto: 'Photo',
gif: 'GIF',
video: 'Video',
attachPhoto: 'Attach Photo',
takePhoto: 'Take Photo',
emoji: 'Emoji',
attachGif: 'Attach GIF',
attachVideo: 'Attach Video',
gifs: 'GIFs',
searchGifs: 'Search GIPHY',
loadMore: 'Load More',
retryGifs: 'Try Again',
moreActions: 'More Actions',
unmatch: 'Unmatch',
unmatchTitle: 'Unmatch this person?',
unmatchBody:
'You and {name} will disappear from each others 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.',
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.',
},
},
fliptok: {
name: 'FlipTok',
loading: 'Loading FlipTok',
+1
View File
@@ -27,6 +27,7 @@ export type PhoneAppId =
| 'neon-drop'
| 'citymarkt'
| 'local-pages'
| 'flare'
| 'fliptok'
export type LaunchablePhoneAppId = PhoneAppId
+66
View File
@@ -0,0 +1,66 @@
import type { DatabaseDateValue } from '@/utils/date'
export type FlareGender = 'woman' | 'man' | 'nonbinary'
export type FlareInterest = FlareGender | 'everyone'
export type FlareMessageType = 'text' | 'image' | 'gif' | 'video'
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: DatabaseDateValue | null
lastMessageType: FlareMessageType | 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: DatabaseDateValue
direction: 'received' | 'sent'
id: string
mediaDurationMs: number | null
mediaUrl: string | null
messageType: FlareMessageType
}
export type FlareOutgoingMessage =
| { body: string; messageType: 'text' }
| {
mediaAssetId: string
mediaDurationMs?: number
messageType: Exclude<FlareMessageType, 'text'>
}
export type FlareProfileDraft = Omit<
FlareOwnProfile,
'discoverable' | 'id' | 'photoUrls'
>
export type FlareBootstrap = {
likes: FlareLike[]
matches: FlareMatch[]
profile: FlareOwnProfile | null
suggestions: FlareProfile[]
}
+1
View File
@@ -70,6 +70,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
+285
View File
@@ -1194,6 +1194,133 @@ 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),
lastMessageType: 'text',
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),
mediaDurationMs: null,
mediaUrl: null,
messageType: 'text',
},
{
id: 'flare-message-2',
direction: 'received',
body: 'That place sounds perfect. Friday?',
createdAt: isoTime(-38 * 60 * 1000),
mediaDurationMs: null,
mediaUrl: null,
messageType: 'text',
},
],
}
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
@@ -1263,6 +1390,164 @@ 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,
lastMessageType: 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 messageType = request.body.messageType ?? 'text'
const body = String(request.body.body ?? '').trim()
const mediaUrl = String(request.body.mediaAssetId ?? '')
if (
!match ||
(messageType === 'text'
? !body
: !['image', 'gif', 'video'].includes(messageType) || !mediaUrl)
) {
response.json({ success: false, error: 'invalid_message' })
return
}
const message = {
id: `flare-message-${Date.now()}`,
direction: 'sent',
body: messageType === 'text' ? body : '',
createdAt: Date.now(),
mediaDurationMs: request.body.mediaDurationMs ?? null,
mediaUrl: messageType === 'text' ? null : mediaUrl,
messageType,
}
flareMessages[match.id] ??= []
flareMessages[match.id].push(message)
match.lastMessage = message.body
match.lastMessageAt = message.createdAt
match.lastMessageType = messageType
response.json({ success: true, data: message })
return
}
if (endpoint === 'fliptok:bootstrap') {
if (!flipTokAuthenticated) {
response.json({
+30
View File
@@ -72,6 +72,36 @@ Locales["en"] = {
},
},
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.", like = "Like", pass = "Pass",
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.",
messagePhoto = "Photo", gif = "GIF", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", emoji = "Emoji", attachGif = "Attach GIF", attachVideo = "Attach Video", gifs = "GIFs", searchGifs = "Search GIPHY", loadMore = "Load More", retryGifs = "Try Again", moreActions = "More Actions",
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.", 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.",
},
},
fliptok = {
name = "FlipTok", loading = "Loading FlipTok", following = "Following", forYou = "For You", verified = "Verified account",
originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile",
+1
View File
@@ -51,6 +51,7 @@ server_scripts {
'source/server/media.lua',
'source/server/messages.lua',
'source/server/darkchat.lua',
'source/server/flare.lua',
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
+22
View File
@@ -121,6 +121,14 @@ local server_callbacks = {
"darkchat:block",
"darkchat:report",
"darkchat:clear",
"flare:bootstrap",
"flare:save-profile",
"flare:set-discovery",
"flare:swipe",
"flare:rewind",
"flare:unmatch",
"flare:thread",
"flare:send",
"gallery:list",
"media:config",
}
@@ -470,6 +478,20 @@ RegisterNetEvent("sky_phone:calendar:reminder", function(data)
SendNUIMessage({ type = "calendar:reminder", data = data })
end)
RegisterNetEvent("sky_phone:flare:match", function(data)
local flare_locale = get_locale().Nui.Apps.flare
data.title = flare_locale.name
data.text = flare_locale.newMatchNotification:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "flare:new-match", data = data })
end)
RegisterNetEvent("sky_phone:flare:message", function(data)
local flare_locale = get_locale().Nui.Apps.flare
data.title = flare_locale.name
data.text = flare_locale.newMessageNotification:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "flare:new-message", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker", function(data)
sim_picker_open = true
SetNuiFocus(true, true)
+135
View File
@@ -1097,9 +1097,144 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_flare_profiles",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "name", type = "VARCHAR(32) NOT NULL" },
{ name = "age", type = "TINYINT UNSIGNED NOT NULL" },
{ name = "bio", type = "VARCHAR(300) NOT NULL DEFAULT ''" },
{ name = "gender", type = "ENUM('woman', 'man', 'nonbinary') NOT NULL" },
{ name = "interested_in", type = "ENUM('woman', 'man', 'nonbinary', 'everyone') NOT NULL DEFAULT 'everyone'" },
{ name = "min_age", type = "TINYINT UNSIGNED NOT NULL DEFAULT 18" },
{ name = "max_age", type = "TINYINT UNSIGNED NOT NULL DEFAULT 99" },
{ name = "avatar", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "interests", type = "JSON NOT NULL" },
{ name = "looking_for", type = "ENUM('longTerm', 'dates', 'friends') NOT NULL DEFAULT 'longTerm'" },
{ name = "discoverable", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_flare_profile_account", columns = "(`account_id`)" },
},
indexes = {
{ name = "idx_sky_phone_flare_discovery", columns = "(`gender`, `age`, `updated_at`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_flare_profile_photos",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_flare_photo_order", columns = "(`profile_id`, `sort_order`)" },
{ name = "uniq_sky_phone_flare_photo_media", columns = "(`profile_id`, `media_id`)" },
},
indexes = {
{ name = "idx_sky_phone_flare_photo_media", columns = "(`media_id`)" },
},
foreignKeys = {
{
column = "profile_id",
references = "`sky_phone_flare_profiles` (`id`) ON DELETE CASCADE",
},
{
column = "media_id",
references = "`sky_phone_media` (`id`) ON DELETE CASCADE",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_flare_swipes",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "swiper_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "target_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "choice", type = "ENUM('like', 'pass', 'superlike') NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_flare_swipe", columns = "(`swiper_account_id`, `target_account_id`)" },
},
indexes = {
{ name = "idx_sky_phone_flare_swipe_target", columns = "(`target_account_id`, `choice`)" },
},
foreignKeys = {
{ column = "swiper_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "target_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_flare_matches",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_a_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "account_b_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_flare_match_pair", columns = "(`account_a_id`, `account_b_id`)" },
},
indexes = {
{ name = "idx_sky_phone_flare_match_b", columns = "(`account_b_id`, `created_at`)" },
},
foreignKeys = {
{ column = "account_a_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "account_b_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_flare_messages",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "match_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "body", type = "VARCHAR(1000) NOT NULL" },
{ name = "message_type", type = "ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'" },
{ name = "media_url", type = "VARCHAR(2048) NULL" },
{ name = "media_duration_ms", type = "INT UNSIGNED NULL" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_flare_message_thread", columns = "(`match_id`, `created_at`, `id`)" },
{ name = "idx_sky_phone_flare_message_unread", columns = "(`match_id`, `sender_account_id`, `read_at`)" },
},
foreignKeys = {
{ column = "match_id", references = "`sky_phone_flare_matches` (`id`) ON DELETE CASCADE" },
{ column = "sender_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
Bridge.Database.Query([[
ALTER TABLE `sky_phone_flare_swipes`
MODIFY COLUMN `choice` ENUM('like', 'pass', 'superlike') NOT NULL
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_flare_messages`
MODIFY COLUMN `message_type` ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_sms_messages`
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
+734
View File
@@ -0,0 +1,734 @@
Bridge.Database.AfterMigration("sky_phone", function()
local genders = { woman = true, man = true, nonbinary = true }
local interests = { woman = true, man = true, nonbinary = true, everyone = true }
local looking_for = { longTerm = true, dates = true, friends = true }
local message_types = { text = true, image = true, gif = true, video = true }
local function allowed_gif_url(value)
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then
return false
end
local host = value:lower():match("^https://([^/:?#]+)")
if not host then
return false
end
for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do
local suffix = "." .. allowed_host
if host == allowed_host or host:sub(-#suffix) == suffix then
return true
end
end
return false
end
local function is_enabled(value)
return value == true or tonumber(value) == 1
end
local function text_length(value)
return type(value) == "string" and utf8.len(value) or nil
end
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate a Flare UUID.")
end
return rows[1].id
end
local function decode_interests(value)
if type(value) ~= "string" or value == "" then
return {}
end
local decoded = json.decode(value)
if type(decoded) ~= "table" then
error("[sky_phone] Invalid Flare interests JSON.")
end
local result = {}
for _, interest in ipairs(decoded) do
if type(interest) == "string" then
result[#result + 1] = interest
end
end
return result
end
local function attach_profile_photos(rows, id_field)
id_field = id_field or "id"
local profile_ids = {}
local seen = {}
for _, row in ipairs(rows) do
local profile_id = tonumber(row[id_field])
if profile_id and not seen[profile_id] then
seen[profile_id] = true
profile_ids[#profile_ids + 1] = profile_id
end
end
if #profile_ids == 0 then
return rows
end
local placeholders = {}
for index = 1, #profile_ids do
placeholders[index] = "?"
end
local photo_rows = Bridge.Database.Query(([[
SELECT photo.`profile_id`, photo.`media_id`, media.`url`
FROM `sky_phone_flare_profile_photos` photo
JOIN `sky_phone_flare_profiles` profile ON profile.`id` = photo.`profile_id`
JOIN `sky_phone_media` media
ON media.`id` = photo.`media_id`
AND media.`account_id` = profile.`account_id`
AND media.`media_type` = 'photo'
WHERE photo.`profile_id` IN (%s)
AND media.`url` LIKE 'https://%%'
ORDER BY photo.`profile_id`, photo.`sort_order`, photo.`id`
]]):format(table.concat(placeholders, ", ")), profile_ids)
local photos_by_profile = {}
for _, photo in ipairs(photo_rows) do
local profile_id = tonumber(photo.profile_id)
if profile_id and type(photo.url) == "string" then
local bucket = photos_by_profile[profile_id]
if not bucket then
bucket = { media_ids = {}, urls = {} }
photos_by_profile[profile_id] = bucket
end
bucket.media_ids[#bucket.media_ids + 1] = tonumber(photo.media_id)
bucket.urls[#bucket.urls + 1] = photo.url
end
end
for _, row in ipairs(rows) do
local bucket = photos_by_profile[tonumber(row[id_field])] or { media_ids = {}, urls = {} }
row.photo_media_ids = bucket.media_ids
row.photo_urls = bucket.urls
end
return rows
end
local function profile_payload(row, include_preferences)
local payload = {
id = tonumber(row.id),
name = row.name,
age = tonumber(row.age),
bio = row.bio,
gender = row.gender,
avatar = tonumber(row.avatar),
interests = decode_interests(row.interests),
lookingFor = row.looking_for,
photoUrls = row.photo_urls or {},
}
if include_preferences then
payload.discoverable = is_enabled(row.discoverable)
payload.interestedIn = row.interested_in
payload.minAge = tonumber(row.min_age)
payload.maxAge = tonumber(row.max_age)
payload.photoMediaIds = row.photo_media_ids or {}
end
return payload
end
local function load_profile(account_id)
local rows = Bridge.Database.Query([[
SELECT `id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`,
`discoverable`,
`avatar`, `interests`, `looking_for`
FROM `sky_phone_flare_profiles`
WHERE `account_id` = ? LIMIT 1
]], { account_id })
attach_profile_photos(rows)
return rows[1]
end
local function list_suggestions(account_id, profile)
if not profile or not is_enabled(profile.discoverable) then
return {}
end
local rows = Bridge.Database.Query([[
SELECT target.`id`, target.`name`, target.`age`, target.`bio`, target.`gender`,
target.`avatar`, target.`interests`, target.`looking_for`
FROM `sky_phone_flare_profiles` mine
JOIN `sky_phone_flare_profiles` target ON target.`account_id` <> mine.`account_id`
LEFT JOIN `sky_phone_flare_swipes` swipe
ON swipe.`swiper_account_id` = mine.`account_id`
AND swipe.`target_account_id` = target.`account_id`
LEFT JOIN `sky_phone_flare_matches` matched
ON (matched.`account_a_id` = mine.`account_id` AND matched.`account_b_id` = target.`account_id`)
OR (matched.`account_b_id` = mine.`account_id` AND matched.`account_a_id` = target.`account_id`)
WHERE mine.`account_id` = ?
AND mine.`discoverable` = 1
AND target.`discoverable` = 1
AND swipe.`id` IS NULL
AND matched.`id` IS NULL
AND target.`age` BETWEEN mine.`min_age` AND mine.`max_age`
AND mine.`age` BETWEEN target.`min_age` AND target.`max_age`
AND (mine.`interested_in` = 'everyone' OR mine.`interested_in` = target.`gender`)
AND (target.`interested_in` = 'everyone' OR target.`interested_in` = mine.`gender`)
ORDER BY target.`updated_at` DESC, target.`id`
LIMIT 30
]], { account_id })
attach_profile_photos(rows)
local suggestions = {}
for _, row in ipairs(rows) do
suggestions[#suggestions + 1] = profile_payload(row, false)
end
return suggestions
end
local function list_likes(account_id)
local rows = Bridge.Database.Query([[
SELECT profile.`id`, profile.`name`, profile.`age`, profile.`bio`, profile.`gender`,
profile.`avatar`, profile.`interests`, profile.`looking_for`, incoming.`choice`
FROM `sky_phone_flare_swipes` incoming
JOIN `sky_phone_flare_profiles` profile
ON profile.`account_id` = incoming.`swiper_account_id`
LEFT JOIN `sky_phone_flare_swipes` response
ON response.`swiper_account_id` = incoming.`target_account_id`
AND response.`target_account_id` = incoming.`swiper_account_id`
LEFT JOIN `sky_phone_flare_matches` matched
ON (matched.`account_a_id` = incoming.`swiper_account_id`
AND matched.`account_b_id` = incoming.`target_account_id`)
OR (matched.`account_b_id` = incoming.`swiper_account_id`
AND matched.`account_a_id` = incoming.`target_account_id`)
WHERE incoming.`target_account_id` = ?
AND incoming.`choice` IN ('like', 'superlike')
AND response.`id` IS NULL
AND matched.`id` IS NULL
ORDER BY incoming.`choice` = 'superlike' DESC, incoming.`updated_at` DESC
LIMIT 100
]], { account_id })
attach_profile_photos(rows)
local likes = {}
for _, row in ipairs(rows) do
local payload = profile_payload(row, false)
payload.superLiked = row.choice == "superlike"
likes[#likes + 1] = payload
end
return likes
end
local function list_matches(account_id)
local rows = Bridge.Database.Query([[
SELECT match_row.`id`, match_row.`created_at`,
profile.`id` AS `profile_id`, profile.`name`, profile.`age`, profile.`bio`,
profile.`gender`, profile.`avatar`, profile.`interests`, profile.`looking_for`,
latest.`body` AS `last_message`, latest.`message_type` AS `last_message_type`,
UNIX_TIMESTAMP(latest.`created_at`) * 1000 AS `last_message_at`,
(SELECT COUNT(*) FROM `sky_phone_flare_messages` unread
WHERE unread.`match_id` = match_row.`id`
AND unread.`sender_account_id` <> ?
AND unread.`read_at` IS NULL) AS `unread`
FROM `sky_phone_flare_matches` match_row
JOIN `sky_phone_flare_profiles` profile ON profile.`account_id` =
IF(match_row.`account_a_id` = ?, match_row.`account_b_id`, match_row.`account_a_id`)
LEFT JOIN `sky_phone_flare_messages` latest ON latest.`id` = (
SELECT message.`id` FROM `sky_phone_flare_messages` message
WHERE message.`match_id` = match_row.`id`
ORDER BY message.`created_at` DESC, message.`id` DESC LIMIT 1
)
WHERE match_row.`account_a_id` = ? OR match_row.`account_b_id` = ?
ORDER BY COALESCE(latest.`created_at`, match_row.`created_at`) DESC
LIMIT 100
]], { account_id, account_id, account_id, account_id })
attach_profile_photos(rows, "profile_id")
local matches = {}
for _, row in ipairs(rows) do
matches[#matches + 1] = {
id = row.id,
profile = profile_payload({
id = row.profile_id,
name = row.name,
age = row.age,
bio = row.bio,
gender = row.gender,
avatar = row.avatar,
interests = row.interests,
looking_for = row.looking_for,
photo_urls = row.photo_urls,
}, false),
lastMessage = row.last_message or "",
lastMessageAt = tonumber(row.last_message_at),
lastMessageType = row.last_message_type,
unread = tonumber(row.unread) or 0,
}
end
return matches
end
local function bootstrap(account_id)
local profile = load_profile(account_id)
return {
profile = profile and profile_payload(profile, true) or nil,
suggestions = list_suggestions(account_id, profile),
likes = list_likes(account_id),
matches = list_matches(account_id),
}
end
local function validate_profile(source, data)
if type(data) ~= "table" then
return nil
end
local name = trim(data.name)
local bio = trim(data.bio) or ""
local name_length = text_length(name)
local bio_length = text_length(bio)
local age = math.floor(tonumber(data.age) or 0)
local min_age = math.floor(tonumber(data.minAge) or 0)
local max_age = math.floor(tonumber(data.maxAge) or 0)
local avatar = math.floor(tonumber(data.avatar) or -1)
if not name_length or name_length < 2 or name_length > 32
or not bio_length or bio_length > 300
or age < 18 or age > 99
or min_age < 18 or min_age > 99
or max_age < min_age or max_age > 99
or avatar < 0 or avatar > 5
or not genders[data.gender]
or not interests[data.interestedIn]
or not looking_for[data.lookingFor]
then
return nil
end
local clean_interests = {}
if type(data.interests) == "table" then
for _, value in ipairs(data.interests) do
local interest = trim(value)
local length = text_length(interest)
if length and length > 0 and length <= 24 and #clean_interests < 5 then
clean_interests[#clean_interests + 1] = interest
end
end
end
local photo_media_ids = nil
local replace_photos = data.photoMediaIds ~= nil
if replace_photos then
if type(data.photoMediaIds) ~= "table" or #data.photoMediaIds > 6 then
return nil, "invalid_profile_photos"
end
photo_media_ids = {}
local seen_media = {}
for key in pairs(data.photoMediaIds) do
if type(key) ~= "number" or key < 1 or key > #data.photoMediaIds
or key ~= math.floor(key)
then
return nil, "invalid_profile_photos"
end
end
for _, value in ipairs(data.photoMediaIds) do
local media_id = tonumber(value)
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
or seen_media[media_id]
or not SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")
then
return nil, "invalid_profile_photos"
end
seen_media[media_id] = true
photo_media_ids[#photo_media_ids + 1] = media_id
end
end
return {
name = name,
age = age,
bio = bio,
gender = data.gender,
interested_in = data.interestedIn,
min_age = min_age,
max_age = max_age,
avatar = avatar,
interests = json.encode(clean_interests),
looking_for = data.lookingFor,
photo_media_ids = photo_media_ids,
replace_photos = replace_photos,
}, nil
end
local function load_match(account_id, match_id)
if type(match_id) ~= "string" or #match_id ~= 36 then
return nil
end
local rows = Bridge.Database.Query([[
SELECT `id`, `account_a_id`, `account_b_id`
FROM `sky_phone_flare_matches`
WHERE `id` = ? AND (`account_a_id` = ? OR `account_b_id` = ?)
LIMIT 1
]], { match_id, account_id, account_id })
return rows[1]
end
local function validate_message(source, data)
if type(data) ~= "table" then
return nil, "invalid_message"
end
local message_type = data.messageType or "text"
if not message_types[message_type] then
return nil, "invalid_message"
end
if message_type == "text" then
local body = trim(data.body)
local length = text_length(body)
if not length or length < 1 or length > 1000 then
return nil, "invalid_message"
end
return { body = body, message_type = message_type }
end
if type(data.mediaAssetId) ~= "string" then
return nil, "invalid_attachment"
end
local media_url
if message_type == "gif" then
if not allowed_gif_url(data.mediaAssetId) then
return nil, "invalid_attachment"
end
media_url = data.mediaAssetId
else
local media_id = tonumber(data.mediaAssetId)
local expected_type = message_type == "image" and "photo" or "video"
if not media_id or media_id < 1 or media_id ~= math.floor(media_id) then
return nil, "invalid_attachment"
end
media_url = SkyPhoneMedia.ResolveOwnedMedia(source, media_id, expected_type)
if type(media_url) ~= "string" or #media_url > Config.Media.UrlMaxLength
or not media_url:match("^https://")
then
Bridge.Debug("warn", ("[sky_phone] Rejected unowned Flare media from source %s."):format(tostring(source)))
return nil, "invalid_attachment"
end
end
local duration = nil
if message_type == "video" and data.mediaDurationMs ~= nil then
duration = tonumber(data.mediaDurationMs)
if not duration or duration < 1000 or duration > Config.Messages.VideoMaxDurationMs then
return nil, "invalid_attachment"
end
duration = math.floor(duration)
end
return {
body = "",
media_duration_ms = duration,
media_url = media_url,
message_type = message_type,
}
end
local function message_payload(row, account_id)
return {
id = row.id,
direction = tonumber(row.sender_account_id) == tonumber(account_id) and "sent" or "received",
body = row.body or "",
createdAt = tonumber(row.created_at_ms),
messageType = row.message_type or "text",
mediaUrl = row.media_url,
mediaDurationMs = tonumber(row.media_duration_ms),
}
end
Bridge.Callbacks.Register("sky_phone:flare:bootstrap", function(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
return { success = true, data = bootstrap(account.id) }
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" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local profile, validation_error = validate_profile(source, data)
if not profile then
return { success = false, error = validation_error or "invalid_profile" }
end
local statements = {{
query = [[
INSERT INTO `sky_phone_flare_profiles`
(`account_id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`,
`avatar`, `interests`, `looking_for`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `age` = VALUES(`age`), `bio` = VALUES(`bio`),
`gender` = VALUES(`gender`), `interested_in` = VALUES(`interested_in`),
`min_age` = VALUES(`min_age`), `max_age` = VALUES(`max_age`),
`avatar` = VALUES(`avatar`), `interests` = VALUES(`interests`),
`looking_for` = VALUES(`looking_for`)
]],
params = {
account.id, profile.name, profile.age, profile.bio, profile.gender,
profile.interested_in, profile.min_age, profile.max_age, profile.avatar,
profile.interests, profile.looking_for,
},
}}
if profile.replace_photos then
statements[#statements + 1] = {
query = [[
DELETE photo FROM `sky_phone_flare_profile_photos` photo
JOIN `sky_phone_flare_profiles` profile ON profile.`id` = photo.`profile_id`
WHERE profile.`account_id` = ?
]],
params = { account.id },
}
for index, media_id in ipairs(profile.photo_media_ids) do
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_flare_profile_photos`
(`profile_id`, `media_id`, `sort_order`)
SELECT `id`, ?, ? FROM `sky_phone_flare_profiles` WHERE `account_id` = ?
]],
params = { media_id, index, account.id },
}
end
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
return { success = true, data = bootstrap(account.id) }
end)
Bridge.Callbacks.Register("sky_phone:flare:set-discovery", function(source, data)
if not SkyPhone.AllowOperation(source, "flare_discovery", 20, 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 type(data) ~= "table" or type(data.enabled) ~= "boolean" then
return { success = false, error = "invalid_discovery" }
end
if not load_profile(account.id) then
return { success = false, error = "invalid_profile" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_flare_profiles` SET `discoverable` = ? WHERE `account_id` = ?
]], { data.enabled and 1 or 0, account.id })
return { success = true, data = bootstrap(account.id) }
end)
Bridge.Callbacks.Register("sky_phone:flare:swipe", function(source, data)
if not SkyPhone.AllowOperation(source, "flare_swipe", 120, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local own_profile = load_profile(account.id)
if not own_profile then
return { success = false, error = "invalid_profile" }
end
if not is_enabled(own_profile.discoverable) then
return { success = false, error = "discovery_disabled" }
end
local target_id = math.floor(tonumber(data and data.targetId) or 0)
local choice = data and data.choice
if target_id <= 0 or (choice ~= "like" and choice ~= "pass" and choice ~= "superlike") then
return { success = false, error = "invalid_choice" }
end
local targets = Bridge.Database.Query([[
SELECT target.`account_id`
FROM `sky_phone_flare_profiles` mine
JOIN `sky_phone_flare_profiles` target ON target.`id` = ?
WHERE mine.`account_id` = ?
AND target.`account_id` <> mine.`account_id`
AND mine.`discoverable` = 1
AND target.`discoverable` = 1
AND target.`age` BETWEEN mine.`min_age` AND mine.`max_age`
AND mine.`age` BETWEEN target.`min_age` AND target.`max_age`
AND (mine.`interested_in` = 'everyone' OR mine.`interested_in` = target.`gender`)
AND (target.`interested_in` = 'everyone' OR target.`interested_in` = mine.`gender`)
LIMIT 1
]], { target_id, account.id })
local target_account_id = targets[1] and tonumber(targets[1].account_id)
if not target_account_id then
return { success = false, error = "invalid_target" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_flare_swipes` (`swiper_account_id`, `target_account_id`, `choice`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `choice` = VALUES(`choice`), `updated_at` = CURRENT_TIMESTAMP
]], { account.id, target_account_id, choice })
if choice == "pass" then
return { success = true, data = { match = nil } }
end
local reciprocal = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_flare_swipes`
WHERE `swiper_account_id` = ? AND `target_account_id` = ?
AND `choice` IN ('like', 'superlike')
LIMIT 1
]], { target_account_id, account.id })
if not reciprocal[1] then
return { success = true, data = { match = nil } }
end
local account_a = math.min(account.id, target_account_id)
local account_b = math.max(account.id, target_account_id)
local insert_result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`)
VALUES (?, ?, ?)
]], { uuid(), account_a, account_b })
local match_created = affected_rows(insert_result) > 0
local matches = list_matches(account.id)
local created_match = nil
for _, match in ipairs(matches) do
if tonumber(match.profile.id) == target_id then
created_match = match
break
end
end
if created_match and match_created then
SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:flare:match", {
matchId = created_match.id,
sender = own_profile.name,
})
end
return { success = true, data = { match = created_match } }
end)
Bridge.Callbacks.Register("sky_phone:flare:rewind", function(source)
if not SkyPhone.AllowOperation(source, "flare_rewind", 20, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `id`, `target_account_id`
FROM `sky_phone_flare_swipes`
WHERE `swiper_account_id` = ?
ORDER BY `updated_at` DESC, `id` DESC
LIMIT 1
]], { account.id })
local swipe = rows[1]
if not swipe then
return { success = false, error = "nothing_to_rewind" }
end
local target_account_id = tonumber(swipe.target_account_id)
local matched = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_flare_matches`
WHERE (`account_a_id` = ? AND `account_b_id` = ?)
OR (`account_a_id` = ? AND `account_b_id` = ?)
LIMIT 1
]], { account.id, target_account_id, target_account_id, account.id })
if matched[1] then
return { success = false, error = "cannot_rewind_match" }
end
Bridge.Database.Query([[
DELETE FROM `sky_phone_flare_swipes` WHERE `id` = ? AND `swiper_account_id` = ?
]], { swipe.id, account.id })
return { success = true, data = bootstrap(account.id) }
end)
Bridge.Callbacks.Register("sky_phone:flare:unmatch", function(source, data)
if not SkyPhone.AllowOperation(source, "flare_unmatch", 20, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local match = load_match(account.id, data and data.matchId)
if not match then
return { success = false, error = "match_not_found" }
end
Bridge.Database.Query([[
DELETE FROM `sky_phone_flare_matches`
WHERE `id` = ? AND (`account_a_id` = ? OR `account_b_id` = ?)
]], { match.id, account.id, account.id })
return { success = true, data = { matches = list_matches(account.id) } }
end)
Bridge.Callbacks.Register("sky_phone:flare:thread", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local match = load_match(account.id, data and data.matchId)
if not match then
return { success = false, error = "match_not_found" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_flare_messages` SET `read_at` = CURRENT_TIMESTAMP
WHERE `match_id` = ? AND `sender_account_id` <> ? AND `read_at` IS NULL
]], { match.id, account.id })
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_account_id`, `body`, `message_type`, `media_url`,
`media_duration_ms`, UNIX_TIMESTAMP(`created_at`) * 1000 AS `created_at_ms`
FROM `sky_phone_flare_messages`
WHERE `match_id` = ?
ORDER BY `created_at`, `id`
LIMIT 500
]], { match.id })
local messages = {}
for _, row in ipairs(rows) do
messages[#messages + 1] = message_payload(row, account.id)
end
return { success = true, data = { messages = messages } }
end)
Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
if not SkyPhone.AllowOperation(source, "flare_message", 60, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local match = load_match(account.id, data and data.matchId)
if not match then
return { success = false, error = "match_not_found" }
end
local message, validation_error = validate_message(source, data)
if not message then
return { success = false, error = validation_error }
end
local id = uuid()
local own_profile = load_profile(account.id)
Bridge.Database.Query([[
INSERT INTO `sky_phone_flare_messages`
(`id`, `match_id`, `sender_account_id`, `body`, `message_type`, `media_url`,
`media_duration_ms`)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
id, match.id, account.id, message.body, message.message_type, message.media_url,
message.media_duration_ms,
})
local recipient_account_id = tonumber(match.account_a_id) == tonumber(account.id)
and tonumber(match.account_b_id) or tonumber(match.account_a_id)
SkyPhone.NotifyAccountDevices(recipient_account_id, "sky_phone:flare:message", {
matchId = match.id,
body = message.body,
sender = own_profile and own_profile.name or "",
})
return {
success = true,
data = message_payload({
id = id,
sender_account_id = account.id,
body = message.body,
created_at_ms = os.time() * 1000,
message_type = message.message_type,
media_url = message.media_url,
media_duration_ms = message.media_duration_ms,
}, account.id),
}
end)
end)
+78
View File
@@ -345,3 +345,81 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_blocks` (
FOREIGN KEY (`blocker_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`blocked_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_flare_profiles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` BIGINT UNSIGNED NOT NULL,
`name` VARCHAR(32) NOT NULL,
`age` TINYINT UNSIGNED NOT NULL,
`bio` VARCHAR(300) NOT NULL DEFAULT '',
`gender` ENUM('woman', 'man', 'nonbinary') NOT NULL,
`interested_in` ENUM('woman', 'man', 'nonbinary', 'everyone') NOT NULL DEFAULT 'everyone',
`min_age` TINYINT UNSIGNED NOT NULL DEFAULT 18,
`max_age` TINYINT UNSIGNED NOT NULL DEFAULT 99,
`avatar` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`interests` JSON NOT NULL,
`looking_for` ENUM('longTerm', 'dates', 'friends') NOT NULL DEFAULT 'longTerm',
`discoverable` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_flare_profile_account` (`account_id`),
KEY `idx_sky_phone_flare_discovery` (`gender`, `age`, `updated_at`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_flare_profile_photos` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`profile_id` BIGINT UNSIGNED NOT NULL,
`media_id` BIGINT UNSIGNED NOT NULL,
`sort_order` TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_flare_photo_order` (`profile_id`, `sort_order`),
UNIQUE KEY `uniq_sky_phone_flare_photo_media` (`profile_id`, `media_id`),
KEY `idx_sky_phone_flare_photo_media` (`media_id`),
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_flare_profiles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_flare_swipes` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`swiper_account_id` BIGINT UNSIGNED NOT NULL,
`target_account_id` BIGINT UNSIGNED NOT NULL,
`choice` ENUM('like', 'pass', 'superlike') NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_flare_swipe` (`swiper_account_id`, `target_account_id`),
KEY `idx_sky_phone_flare_swipe_target` (`target_account_id`, `choice`),
FOREIGN KEY (`swiper_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`target_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_flare_matches` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_a_id` BIGINT UNSIGNED NOT NULL,
`account_b_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_flare_match_pair` (`account_a_id`, `account_b_id`),
KEY `idx_sky_phone_flare_match_b` (`account_b_id`, `created_at`),
FOREIGN KEY (`account_a_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`account_b_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_flare_messages` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`match_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_account_id` BIGINT UNSIGNED NOT NULL,
`body` VARCHAR(1000) NOT NULL,
`message_type` ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text',
`media_url` VARCHAR(2048) NULL,
`media_duration_ms` INT UNSIGNED NULL,
`read_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_flare_message_thread` (`match_id`, `created_at`, `id`),
KEY `idx_sky_phone_flare_message_unread` (`match_id`, `sender_account_id`, `read_at`),
FOREIGN KEY (`match_id`) REFERENCES `sky_phone_flare_matches` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`sender_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;