FIX - extend Flare chat attachments

This commit is contained in:
Dominik
2026-08-09 05:23:28 +02:00
parent 5e3f642cb7
commit 095562bbb7
11 changed files with 583 additions and 47 deletions
@@ -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>
+41
View File
@@ -118,6 +118,7 @@ describe('flare store', () => {
id: 'match-1',
lastMessage: '',
lastMessageAt: null,
lastMessageType: null,
profile: maya,
unread: 0,
}
@@ -148,4 +149,44 @@ describe('flare store', () => {
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')
})
})
+7 -2
View File
@@ -4,6 +4,7 @@ import type {
FlareBootstrap,
FlareMatch,
FlareMessage,
FlareOutgoingMessage,
FlareProfile,
FlareProfileDraft,
} from '@/types/flare'
@@ -57,11 +58,14 @@ export const useFlareStore = defineStore('flare', {
}
return response.success
},
async send(matchId: string, body: string): Promise<boolean> {
async send(
matchId: string,
outgoing: FlareOutgoingMessage,
): Promise<boolean> {
this.sending = true
const response = await nuiCall<FlareMessage>('flare:send', {
body,
matchId,
...outgoing,
})
this.sending = false
this.error = response.success ? '' : (response.error ?? 'default')
@@ -71,6 +75,7 @@ export const useFlareStore = defineStore('flare', {
if (match) {
match.lastMessage = response.data.body
match.lastMessageAt = response.data.createdAt
match.lastMessageType = response.data.messageType
}
}
return response.success
+18
View File
@@ -119,6 +119,19 @@ const defaultLocales: LocaleTree = {
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:
@@ -157,6 +170,11 @@ const defaultLocales: LocaleTree = {
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.',
},
+17 -2
View File
@@ -1,5 +1,8 @@
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
@@ -18,7 +21,8 @@ export type FlareLike = FlareProfile & { superLiked: boolean }
export type FlareMatch = {
id: string
lastMessage: string
lastMessageAt: string | null
lastMessageAt: DatabaseDateValue | null
lastMessageType: FlareMessageType | null
profile: FlareProfile
unread: number
}
@@ -33,11 +37,22 @@ export type FlareOwnProfile = FlareProfile & {
export type FlareMessage = {
body: string
createdAt: 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'
+323 -11
View File
@@ -28,6 +28,7 @@ import {
} from 'konsta/vue'
import {
ArrowUpCircle,
Camera,
Check,
ChevronRight,
Ellipsis,
@@ -35,33 +36,50 @@ import {
Grid2X2,
Flame,
Heart,
ImagePlay,
Images,
MapPin,
MessageCircle,
Pencil,
Plus,
RotateCcw,
Search,
Settings2,
Star,
SlidersHorizontal,
UserRound,
Video,
X,
} from 'lucide-vue-next'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import profilesSprite from '@/assets/img/flare/profiles-source.png'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import { useFlareStore } from '@/stores/flare'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type {
FlareGender,
FlareInterest,
FlareMatch,
FlareMessage,
FlareProfile,
FlareProfileDraft,
} from '@/types/flare'
import type { PhoneMedia } from '@/types/media'
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
type FlareTab = 'discover' | 'explore' | 'likes' | 'matches' | 'profile'
type ExploreMode = 'all' | 'dates' | 'friends' | 'longTerm'
@@ -76,10 +94,12 @@ type FlareMediaContext = {
editing: boolean
photos: FlareDraftPhoto[]
}
type FlareChatMediaContext = { matchId: string }
const phone = usePhoneStore()
const flare = useFlareStore()
const messageMedia = useMessageMediaStore()
const messages = useMessagesStore()
const route = useRoute()
const router = useRouter()
const activeTab = ref<FlareTab>('discover')
@@ -100,11 +120,21 @@ const discoverySaving = ref(false)
const unmatchDialog = ref(false)
const activeExploreMode = ref<ExploreMode>('all')
const actionToast = ref('')
const emojiOpen = ref(false)
const attachmentMenuOpen = ref(false)
const attachmentPicker = ref<'gifs' | null>(null)
const gifQuery = ref('')
const gifResults = ref<GifSearchResult[]>([])
const gifLoading = ref(false)
const gifError = ref<string | null>(null)
const gifHasMore = ref(true)
const gifNextOffset = ref(0)
const draftPhotos = ref<FlareDraftPhoto[]>([])
const activeChoiceField = ref<FlareChoiceField>('gender')
const choiceOpened = ref(false)
const choiceSheetContent = ref<HTMLElement | null>(null)
let activeChoiceTrigger: HTMLElement | null = null
let gifSearchTimer: ReturnType<typeof setTimeout> | undefined
const profileDraft = reactive<FlareProfileDraft>({
age: 25,
avatar: 0,
@@ -160,11 +190,14 @@ const normalizedPhotoIndex = computed(
() => currentPhotoIndex.value % currentPhotoCount.value,
)
const newMatches = computed(() =>
flare.matches.filter((match) => !match.lastMessage),
flare.matches.filter((match) => !match.lastMessage && !match.lastMessageType),
)
const unreadMatches = computed(() =>
flare.matches.reduce((total, match) => total + match.unread, 0),
)
const attachmentPanelOpen = computed(
() => emojiOpen.value || attachmentPicker.value !== null,
)
const cardStyle = computed(() => ({
transform: `translateX(${cardOffset.value}px) rotate(${cardOffset.value / 22}deg)`,
transition: dragging.value ? 'none' : undefined,
@@ -535,6 +568,14 @@ async function openMatch(match: FlareMatch): Promise<void> {
messageScroll.value?.scrollTo({ top: messageScroll.value.scrollHeight })
}
function closeMatch(): void {
activeMatch.value = null
draft.value = ''
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
}
async function openRevealedMatch(): Promise<void> {
const match = matchReveal.value
if (!match) return
@@ -546,7 +587,15 @@ async function sendMessage(): Promise<void> {
const body = draft.value.trim()
if (!body || !activeMatch.value || flare.sending) return
draft.value = ''
if (!(await flare.send(activeMatch.value.id, body))) {
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
if (
!(await flare.send(activeMatch.value.id, {
body,
messageType: 'text',
}))
) {
draft.value = body
showActionError()
}
@@ -557,10 +606,131 @@ async function sendMessage(): Promise<void> {
})
}
function messageTime(value: string): string {
const parsed = new Date(
value.includes('T') ? value : `${value.replace(' ', 'T')}Z`,
function appendEmoji(emoji: string): void {
draft.value += emoji
}
function toggleAttachmentMenu(): void {
attachmentMenuOpen.value = !attachmentMenuOpen.value
emojiOpen.value = false
attachmentPicker.value = null
}
function openEmojiPicker(): void {
attachmentMenuOpen.value = false
attachmentPicker.value = null
emojiOpen.value = true
}
function openGifPicker(): void {
attachmentMenuOpen.value = false
emojiOpen.value = false
attachmentPicker.value = 'gifs'
if (!gifResults.value.length) void loadGifs(true)
}
function openChatMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!activeMatch.value) return
const matchId = activeMatch.value.id
attachmentMenuOpen.value = false
messageMedia.begin(
'flare:chat-media',
mediaType,
`/apps/flare?match=${encodeURIComponent(matchId)}`,
1,
{ matchId } satisfies FlareChatMediaContext,
)
void router.push({
path: `/apps/${app}`,
query: { messageAttachment: mediaType },
})
}
async function sendAttachment(
messageType: SmsAttachmentType,
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (!activeMatch.value || flare.sending) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
if (
!(await flare.send(activeMatch.value.id, {
mediaAssetId,
mediaDurationMs,
messageType,
}))
) {
showActionError()
}
await nextTick()
messageScroll.value?.scrollTo({
behavior: 'smooth',
top: messageScroll.value.scrollHeight,
})
}
async function loadGifs(reset = false): Promise<void> {
if (gifLoading.value || (!reset && !gifHasMore.value)) return
gifError.value = null
gifLoading.value = true
const response = await messages.searchGifs(
gifQuery.value,
reset ? 0 : gifNextOffset.value,
)
gifLoading.value = false
if (!response.success || !response.data) {
if (reset) gifResults.value = []
gifError.value = response.error ?? 'gif_provider_failed'
return
}
const existingIds = new Set(
reset ? [] : gifResults.value.map((result) => result.id),
)
const uniqueResults = response.data.results.filter((result) => {
if (existingIds.has(result.id)) return false
existingIds.add(result.id)
return true
})
gifResults.value = reset
? uniqueResults
: [...gifResults.value, ...uniqueResults]
gifHasMore.value = response.data.hasMore
gifNextOffset.value = response.data.nextOffset
}
function queueGifSearch(): void {
if (gifSearchTimer) clearTimeout(gifSearchTimer)
gifSearchTimer = setTimeout(() => void loadGifs(true), 320)
}
function attachmentMessage(message: FlareMessage): {
media_asset_id: string | null
media_duration_ms: number | null
message_type: SmsAttachmentType
} {
return {
media_asset_id: message.mediaUrl,
media_duration_ms: message.mediaDurationMs,
message_type: message.messageType as SmsAttachmentType,
}
}
function matchPreview(match: FlareMatch): string {
if (match.lastMessageType === 'image') {
return phone.t('Apps.flare.messagePhoto')
}
if (match.lastMessageType === 'gif') return phone.t('Apps.flare.gif')
if (match.lastMessageType === 'video') return phone.t('Apps.flare.video')
return match.lastMessage || phone.t('Apps.flare.newMatch')
}
function messageTime(value: DatabaseDateValue): string {
const parsed = parseDatabaseDate(value)
if (Number.isNaN(parsed.getTime())) return ''
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
@@ -598,6 +768,30 @@ onMounted(async () => {
if (route.query.profileEdit === '1') {
void router.replace('/apps/flare')
}
const chatSelection =
messageMedia.consumeMany<FlareChatMediaContext>('flare:chat-media')
const requestedMatchId =
chatSelection?.context?.matchId ??
(typeof route.query.match === 'string' ? route.query.match : '')
if (requestedMatchId) {
const match = flare.matches.find((item) => item.id === requestedMatchId)
if (match) {
await openMatch(match)
const media = chatSelection?.media[0]
if (media) {
await sendAttachment(
media.mediaType === 'photo' ? 'image' : 'video',
import.meta.env.DEV ? media.url : String(media.id),
)
}
}
if (route.query.match) void router.replace('/apps/flare')
}
})
onBeforeUnmount(() => {
if (gifSearchTimer) clearTimeout(gifSearchTimer)
})
</script>
@@ -607,6 +801,7 @@ onMounted(async () => {
class="native-app flare-page"
:class="{
'flare-page--chat': Boolean(activeMatch),
'flare-page--attachment-panel': attachmentPanelOpen,
'flare-page--without-tabs':
!flare.profile || profileEditing || profileSettings,
}"
@@ -762,7 +957,7 @@ onMounted(async () => {
<template #left>
<k-navbar-back-link
:text="phone.t('Common.back')"
@click="activeMatch = null"
@click="closeMatch"
/>
</template>
<template #title>
@@ -788,19 +983,121 @@ onMounted(async () => {
v-for="message in flare.messages"
:key="message.id"
:type="message.direction"
:text="message.body"
:text="message.messageType === 'text' ? message.body : undefined"
:text-footer="messageTime(message.createdAt)"
/>
>
<template v-if="message.messageType !== 'text'" #text>
<MessageAttachmentBubble :message="attachmentMessage(message)" />
</template>
</k-message>
</k-messages>
</div>
<section v-if="attachmentMenuOpen" class="messages-attachment-menu">
<button type="button" @click="openChatMediaApp('photos', 'photo')">
<span><Images :size="20" /></span>
{{ phone.t('Apps.flare.attachPhoto') }}
</button>
<button type="button" @click="openChatMediaApp('camera', 'photo')">
<span><Camera :size="20" /></span>
{{ phone.t('Apps.flare.takePhoto') }}
</button>
<button type="button" @click="openEmojiPicker">
<span class="messages-action-emoji">😀</span>
{{ phone.t('Apps.flare.emoji') }}
</button>
<button type="button" @click="openGifPicker">
<span><ImagePlay :size="20" /></span>
{{ phone.t('Apps.flare.attachGif') }}
</button>
<button type="button" @click="openChatMediaApp('photos', 'video')">
<span><Video :size="20" /></span>
{{ phone.t('Apps.flare.attachVideo') }}
</button>
</section>
<section
v-if="attachmentPicker"
class="messages-media-picker flare-media-picker"
>
<header>
<strong>{{ phone.t('Apps.flare.gifs') }}</strong>
<button type="button" @click="attachmentPicker = null">
{{ phone.t('Common.done') }}
</button>
</header>
<div class="messages-media-picker__gifs">
<label class="messages-gif-search">
<Search :size="15" />
<input
v-model="gifQuery"
type="search"
:placeholder="phone.t('Apps.flare.searchGifs')"
@input="queueGifSearch"
/>
</label>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
@click="sendAttachment('gif', gif.url)"
>
<img
:src="gif.previewUrl"
:alt="gif.title"
loading="lazy"
referrerpolicy="no-referrer"
/>
</button>
<button
v-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
class="messages-gif-more"
@click="loadGifs()"
>
{{ phone.t('Apps.flare.loadMore') }}
</button>
<div v-if="gifError && !gifLoading" class="messages-gif-error">
<ImagePlay :size="24" />
<strong>{{ phone.t(`Apps.flare.errors.${gifError}`) }}</strong>
<button type="button" @click="loadGifs(true)">
{{ phone.t('Apps.flare.retryGifs') }}
</button>
</div>
<k-preloader v-if="gifLoading" class="messages-gif-loading" />
</div>
</section>
<FullEmojiPicker
v-if="emojiOpen"
@close="emojiOpen = false"
@pick="appendEmoji"
/>
<k-messagebar
class="flare-messagebar"
class="flare-messagebar messages-messagebar"
:placeholder="phone.t('Apps.flare.messagePlaceholder')"
:value="draft"
:disabled="flare.sending"
@input="draft = eventValue($event)"
@keydown.enter.exact.prevent="sendMessage"
>
<template #left>
<k-toolbar-pane class="ios:h-10 messages-messagebar__tools">
<k-link
component="button"
icon-only
:aria-label="phone.t('Apps.flare.moreActions')"
:class="{
active: attachmentMenuOpen || attachmentPanelOpen,
}"
@click="toggleAttachmentMenu"
>
<Plus :size="25" />
</k-link>
</k-toolbar-pane>
</template>
<template #right>
<k-toolbar-pane>
<k-link
@@ -1126,7 +1423,7 @@ onMounted(async () => {
:key="match.id"
link
:title="match.profile.name"
:subtitle="match.lastMessage || phone.t('Apps.flare.newMatch')"
:subtitle="matchPreview(match)"
@click="openMatch(match)"
>
<template #media>
@@ -2562,6 +2859,9 @@ onMounted(async () => {
.flare-chat-scroll {
padding: 10px 12px 82px;
}
.flare-page--attachment-panel .flare-chat-scroll {
padding-bottom: 390px;
}
.flare-chat-scroll :deep(.k-message-sent [class*='message-bubble']) {
background: linear-gradient(
110deg,
@@ -2581,6 +2881,18 @@ onMounted(async () => {
.flare-messagebar :deep(.k-link) {
color: var(--flare);
}
.flare-messagebar :deep(.messages-messagebar__tools .k-link) {
color: var(--flare-text);
}
.flare-messagebar :deep(.messages-messagebar__tools .k-link.active) {
background: rgb(255 56 92 / 13%);
color: var(--flare);
}
.flare-media-picker > header button,
.flare-media-picker .messages-gif-more,
.flare-media-picker .messages-gif-error button {
color: var(--flare) !important;
}
.flare-tabbar {
z-index: 30;
+23 -4
View File
@@ -1128,6 +1128,7 @@ const flareMatches = [
},
lastMessage: 'That place sounds perfect. Friday?',
lastMessageAt: isoTime(-38 * 60 * 1000),
lastMessageType: 'text',
unread: 1,
},
]
@@ -1145,12 +1146,18 @@ const flareMessages = {
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',
},
],
}
@@ -1250,6 +1257,7 @@ app.post('/api/:endpoint', (request, response) => {
profile: target,
lastMessage: '',
lastMessageAt: null,
lastMessageType: null,
unread: 0,
}
flareMatches.unshift(match)
@@ -1303,21 +1311,32 @@ app.post('/api/:endpoint', (request, response) => {
}
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()
if (!match || !body) {
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,
createdAt: new Date().toISOString(),
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 = body
match.lastMessage = message.body
match.lastMessageAt = message.createdAt
match.lastMessageType = messageType
response.json({ success: true, data: message })
return
}
+2 -1
View File
@@ -79,13 +79,14 @@ Locales["en"] = {
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.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.",
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.",
},
},
darkchat = {
+7
View File
@@ -940,6 +940,9 @@ local schema = {
{ 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" },
},
@@ -961,6 +964,10 @@ 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'
+114 -22
View File
@@ -2,6 +2,24 @@ 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
@@ -208,7 +226,8 @@ local function list_matches(account_id)
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`,
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` <> ?
@@ -242,7 +261,8 @@ local function list_matches(account_id)
photo_urls = row.photo_urls,
}, false),
lastMessage = row.last_message or "",
lastMessageAt = row.last_message_at,
lastMessageAt = tonumber(row.last_message_at),
lastMessageType = row.last_message_type,
unread = tonumber(row.unread) or 0,
}
end
@@ -349,6 +369,75 @@ local function load_match(account_id, match_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
@@ -581,7 +670,8 @@ Bridge.Callbacks.Register("sky_phone:flare:thread", function(source, data)
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`
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`
@@ -589,12 +679,7 @@ Bridge.Callbacks.Register("sky_phone:flare:thread", function(source, data)
]], { 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,
}
messages[#messages + 1] = message_payload(row, account.id)
end
return { success = true, data = { messages = messages } }
end)
@@ -607,36 +692,43 @@ Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
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" }
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`)
VALUES (?, ?, ?, ?)
]], { id, match.id, account.id, body })
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 = body,
body = message.body,
sender = own_profile and own_profile.name or "",
})
return {
success = true,
data = {
data = message_payload({
id = id,
direction = "sent",
body = body,
createdAt = os.date("!%Y-%m-%dT%H:%M:%SZ"),
},
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)
+3
View File
@@ -295,6 +295,9 @@ CREATE TABLE IF NOT EXISTS `sky_phone_flare_messages` (
`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`),