diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bbb2634..8918086 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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): 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') { diff --git a/frontend/src/assets/img/app-icons/flare.svg b/frontend/src/assets/img/app-icons/flare.svg new file mode 100644 index 0000000..3cad880 --- /dev/null +++ b/frontend/src/assets/img/app-icons/flare.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/img/flare/profiles-source.png b/frontend/src/assets/img/flare/profiles-source.png new file mode 100644 index 0000000..b376352 Binary files /dev/null and b/frontend/src/assets/img/flare/profiles-source.png differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 2ef0ae6..85fae7a 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index e265d84..dc7a6e4 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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( diff --git a/frontend/src/stores/flare.test.ts b/frontend/src/stores/flare.test.ts new file mode 100644 index 0000000..1bead7e --- /dev/null +++ b/frontend/src/stores/flare.test.ts @@ -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') + }) +}) diff --git a/frontend/src/stores/flare.ts b/frontend/src/stores/flare.ts new file mode 100644 index 0000000..d4c6112 --- /dev/null +++ b/frontend/src/stores/flare.ts @@ -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 { + this.loading = true + const response = await nuiCall('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 { + 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 { + const response = await nuiCall( + '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 { + this.sending = true + const response = await nuiCall('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 { + 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 { + const response = await nuiCall('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 { + const response = await nuiCall('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 { + 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 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0238baa..0e64da5 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -28,6 +28,139 @@ const namespaceQueues = new Map>() 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}', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index fc9c11a..4b46098 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -26,6 +26,7 @@ export type PhoneAppId = | 'neon-drop' | 'citymarkt' | 'local-pages' + | 'flare' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/flare.ts b/frontend/src/types/flare.ts new file mode 100644 index 0000000..1518251 --- /dev/null +++ b/frontend/src/types/flare.ts @@ -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[] +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 2118a05..c644099 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/FlareApp.vue b/frontend/src/views/apps/FlareApp.vue new file mode 100644 index 0000000..f96a61e --- /dev/null +++ b/frontend/src/views/apps/FlareApp.vue @@ -0,0 +1,2508 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index b188c34..617e843 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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 diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 29b140e..1b3f97f 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -59,6 +59,35 @@ 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.", + 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}", privateNotification = "New DarkChat message", signInBody = "DarkChat identities are linked to your private iFruit account.", signInHint = "Sign in through Settings to continue.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 26d664c..73ace6b 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -48,6 +48,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', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 09ebfa3..ea38d23 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -92,6 +92,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", } @@ -411,6 +419,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) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 4275e4d..8609149 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -830,9 +830,137 @@ 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 = "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_sms_messages` MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text' diff --git a/sky_phone/source/server/flare.lua b/sky_phone/source/server/flare.lua new file mode 100644 index 0000000..2088b7b --- /dev/null +++ b/sky_phone/source/server/flare.lua @@ -0,0 +1,642 @@ +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 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.`created_at` 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 = row.last_message_at, + 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 + +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`, `created_at` + 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] = { + id = row.id, + direction = tonumber(row.sender_account_id) == tonumber(account.id) and "sent" or "received", + body = row.body, + createdAt = row.created_at, + } + 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 body = trim(data and data.body) + local length = text_length(body) + local match = load_match(account.id, data and data.matchId) + if not match then + return { success = false, error = "match_not_found" } + end + if not length or length < 1 or length > 1000 then + return { success = false, error = "invalid_message" } + 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`) + VALUES (?, ?, ?, ?) + ]], { id, match.id, account.id, body }) + 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 = body, + sender = own_profile and own_profile.name or "", + }) + return { + success = true, + data = { + id = id, + direction = "sent", + body = body, + createdAt = os.date("!%Y-%m-%dT%H:%M:%SZ"), + }, + } +end) +end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index fdb12b0..1de0425 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -228,3 +228,78 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` ( KEY `idx_sky_phone_calendar_reminders` (`reminded_at`, `starts_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_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, + `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;