mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
FIX - extend Flare chat attachments
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user