ADD - share contacts and rich content in chats

This commit is contained in:
smx.pusha
2026-08-11 21:37:39 +02:00
parent 7d146e709a
commit cdf8d276ef
25 changed files with 1350 additions and 79 deletions
+59
View File
@@ -4895,6 +4895,53 @@ button {
gap: 7px;
overflow-y: auto;
}
.shared-composer-preview {
position: fixed;
z-index: 43;
right: 12px;
bottom: 65px;
left: 12px;
padding: 7px;
border: 1px solid rgb(255 255 255 / 72%);
border-radius: 19px;
background: rgb(242 242 247 / 90%);
box-shadow: 0 10px 28px rgb(20 44 76 / 18%);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
}
.shared-composer-preview > button {
position: absolute;
z-index: 2;
top: 2px;
right: 2px;
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
background: rgb(60 60 67 / 78%);
}
.shared-composer-preview--dark {
position: relative;
inset: auto;
z-index: 2;
margin: 0 8px 7px;
flex: 0 0 auto;
border-color: rgb(255 255 255 / 10%);
background: rgb(20 18 27 / 94%);
box-shadow: 0 8px 22px rgb(0 0 0 / 28%);
}
.phone-app.dark .shared-composer-preview {
border-color: rgb(255 255 255 / 10%);
background: rgb(36 36 40 / 92%);
}
.messages-media-picker__contacts {
height: 270px;
margin: 0;
padding: 8px 0 18px;
overflow-y: auto;
}
.messages-media-picker__gifs button {
min-height: 83px;
display: flex;
@@ -6106,6 +6153,18 @@ button {
display: block;
margin: 0;
}
.messages-thread-page .messages-attachment-menu > button:nth-child(4) > span {
background: #e9f9ed;
color: #28a745;
}
.messages-thread-page .messages-attachment-menu > button:nth-child(5) > span {
background: #f6eefe;
color: #af52de;
}
.messages-thread-page .messages-attachment-menu > button:nth-child(6) > span {
background: #fff0ef;
color: #ff3b30;
}
.messages-thread-page
.messages-messagebar
> .k-toolbar
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { kButton } from 'konsta/vue'
import {
Check,
ChevronRight,
MessageCircle,
UserRound,
UserPlus,
} from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import type { SmsSharedContact } from '@/types/messages'
const props = defineProps<{
addLabel: string
contact: SmsSharedContact
messageLabel: string
saved: boolean
savedLabel: string
}>()
const emit = defineEmits<{ message: []; save: [] }>()
const imageFailed = ref(false)
watch(
() => props.contact.avatar_url,
() => {
imageFailed.value = false
},
)
const displayName = computed(
() => props.contact.name.trim() || props.contact.phone_number,
)
const showNumber = computed(() => Boolean(props.contact.name.trim()))
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join(''),
)
</script>
<template>
<div class="message-contact-card">
<div class="message-contact-card__identity">
<span class="message-contact-card__avatar">
<img
v-if="contact.avatar_url && !imageFailed"
:src="contact.avatar_url"
alt=""
@error="imageFailed = true"
/>
<b v-else-if="initials">{{ initials }}</b>
<UserRound v-else :size="28" />
</span>
<div>
<strong>{{ displayName }}</strong>
<small v-if="contact.organization">{{ contact.organization }}</small>
<small v-if="showNumber">({{ contact.phone_number }})</small>
</div>
<ChevronRight :size="20" class="message-contact-card__chevron" />
</div>
<div class="message-contact-card__actions">
<k-button rounded tonal @click.stop="emit('message')">
<MessageCircle :size="17" />
{{ messageLabel }}
</k-button>
<k-button
rounded
tonal
:disabled="saved"
@click.stop="emit('save')"
>
<Check v-if="saved" :size="17" />
<UserPlus v-else :size="17" />
{{ saved ? savedLabel : addLabel }}
</k-button>
</div>
</div>
</template>
<style scoped>
.message-contact-card {
width: min(258px, 72cqw);
overflow: hidden;
border: 1px solid rgb(255 255 255 / 48%);
border-radius: 21px;
color: #151517;
background: linear-gradient(155deg, rgb(255 255 255 / 96%), #edf5ff);
box-shadow: 0 8px 24px rgb(22 63 112 / 14%);
}
.message-contact-card__identity {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) 20px;
align-items: center;
gap: 11px;
min-height: 82px;
padding: 13px 12px;
}
.message-contact-card__avatar {
width: 58px;
height: 58px;
overflow: hidden;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #64d2ff, #0a84ff);
box-shadow: 0 4px 14px rgb(10 132 255 / 24%);
}
.message-contact-card__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.message-contact-card__identity b {
font-size: 18px;
}
.message-contact-card__identity div,
.message-contact-card__identity strong,
.message-contact-card__identity small {
min-width: 0;
}
.message-contact-card__identity strong,
.message-contact-card__identity small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-contact-card__identity strong {
font-size: 16px;
line-height: 1.2;
}
.message-contact-card__identity small {
margin-top: 3px;
color: #6e6e73;
font-size: 11.5px;
}
.message-contact-card__chevron {
color: #8e8e93;
}
.message-contact-card__actions {
display: grid;
gap: 1px;
padding: 8px;
border-top: 1px solid rgb(60 60 67 / 12%);
background: rgb(255 255 255 / 58%);
}
.message-contact-card__actions :deep(.button) {
width: 100%;
margin: 0;
min-height: 36px;
justify-content: flex-start;
padding-inline: 14px;
font-size: 13px;
}
:global(.phone-app.dark) .message-contact-card {
color: #f7f7f7;
border-color: rgb(255 255 255 / 10%);
background: linear-gradient(155deg, #34363b, #242a33);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
}
:global(.phone-app.dark) .message-contact-card__identity small {
color: #aeaeb2;
}
:global(.phone-app.dark) .message-contact-card__actions {
border-top-color: rgb(255 255 255 / 10%);
background: rgb(0 0 0 / 10%);
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { getPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { EasySharePayload } from '@/types/easyshare'
const props = withDefaults(
defineProps<{
compact?: boolean
payload: EasySharePayload
variant?: 'darkchat' | 'flare' | 'messages'
}>(),
{ compact: false, variant: 'messages' },
)
const phone = usePhoneStore()
const imageFailed = ref(false)
const sourceApp = computed(() => getPhoneApp(props.payload.appId))
const isVideo = computed(
() =>
props.payload.kind === 'video' ||
(props.payload.appId === 'fliptok' && props.payload.kind === 'post'),
)
const fallbackIcon = computed(() => {
if (props.payload.kind === 'location') return MapPin
if (props.payload.kind === 'profile') return UserRound
if (props.payload.kind === 'track' || props.payload.kind === 'playlist') {
return Music2
}
if (props.payload.kind === 'video') return Play
return Image
})
watch(
() => props.payload.imageUrl,
() => {
imageFailed.value = false
},
)
</script>
<template>
<article
class="shared-content-card"
:class="[
`shared-content-card--${variant}`,
{ 'shared-content-card--compact': compact },
]"
>
<div class="shared-content-card__source">
<img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" />
<span>
<b>{{ sourceApp ? phone.t(sourceApp.labelKey) : payload.appId }}</b>
<small>{{ phone.t(`Apps.easyShare.kinds.${payload.kind}`) }}</small>
</span>
</div>
<div class="shared-content-card__media">
<video
v-if="payload.imageUrl && !imageFailed && isVideo"
:src="payload.imageUrl"
muted
playsinline
preload="metadata"
@error="imageFailed = true"
/>
<img
v-else-if="payload.imageUrl && !imageFailed"
:src="payload.imageUrl"
alt=""
loading="lazy"
@error="imageFailed = true"
/>
<span v-else><component :is="fallbackIcon" :size="30" /></span>
</div>
<div class="shared-content-card__copy">
<small v-if="payload.subtitle">{{ payload.subtitle }}</small>
<strong>{{ payload.title }}</strong>
<p v-if="payload.copyText !== payload.title">{{ payload.copyText }}</p>
</div>
</article>
</template>
<style scoped>
.shared-content-card {
width: min(262px, 73cqw);
overflow: hidden;
border: 1px solid rgb(60 60 67 / 13%);
border-radius: 20px;
color: #171719;
background: rgb(255 255 255 / 96%);
box-shadow: 0 8px 24px rgb(19 45 78 / 13%);
}
.shared-content-card__source {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
}
.shared-content-card__source > img {
width: 27px;
height: 27px;
border-radius: 7px;
object-fit: cover;
}
.shared-content-card__source b,
.shared-content-card__source small {
display: block;
line-height: 1.1;
}
.shared-content-card__source b {
font-size: 12px;
}
.shared-content-card__source small {
margin-top: 2px;
color: #8e8e93;
font-size: 10px;
}
.shared-content-card__media {
height: 142px;
overflow: hidden;
background: linear-gradient(145deg, #dcecff, #b9d5ff);
}
.shared-content-card__media > img,
.shared-content-card__media > video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.shared-content-card__media > span {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: #0a84ff;
}
.shared-content-card__copy {
padding: 10px 12px 12px;
}
.shared-content-card__copy small,
.shared-content-card__copy strong,
.shared-content-card__copy p {
display: block;
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
}
.shared-content-card__copy small {
margin-bottom: 3px;
color: #0a84ff;
font-size: 10px;
font-weight: 650;
}
.shared-content-card__copy strong {
display: -webkit-box;
font-size: 14px;
line-height: 1.25;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.shared-content-card__copy p {
display: -webkit-box;
margin-top: 5px;
color: #636366;
font-size: 11px;
line-height: 1.3;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.shared-content-card--compact {
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
border-radius: 16px;
box-shadow: none;
}
.shared-content-card--compact .shared-content-card__source {
grid-column: 2;
padding: 7px 9px 0;
}
.shared-content-card--compact .shared-content-card__media {
grid-row: 1 / span 2;
height: 78px;
}
.shared-content-card--compact .shared-content-card__copy {
grid-column: 2;
padding: 5px 9px 8px;
}
.shared-content-card--compact .shared-content-card__copy p,
.shared-content-card--compact .shared-content-card__source small {
display: none;
}
.shared-content-card--darkchat {
border-color: rgb(255 255 255 / 10%);
color: #f5f5f7;
background: linear-gradient(155deg, #302c3d, #1b1922);
box-shadow: 0 9px 25px rgb(0 0 0 / 28%);
}
.shared-content-card--darkchat .shared-content-card__media {
background: linear-gradient(145deg, #392f5c, #201c31);
}
.shared-content-card--darkchat .shared-content-card__media > span,
.shared-content-card--darkchat .shared-content-card__copy small {
color: #bf9cff;
}
.shared-content-card--darkchat .shared-content-card__copy p {
color: #beb9c8;
}
.shared-content-card--flare {
border-color: rgb(255 255 255 / 58%);
background: linear-gradient(155deg, #fff, #fff0f6);
box-shadow: 0 8px 24px rgb(245 71 132 / 15%);
}
.shared-content-card--flare .shared-content-card__media {
background: linear-gradient(145deg, #ffd4e4, #ffc1d5);
}
.shared-content-card--flare .shared-content-card__media > span,
.shared-content-card--flare .shared-content-card__copy small {
color: #ef3f7c;
}
:global(.phone-app.dark) .shared-content-card--messages {
border-color: rgb(255 255 255 / 10%);
color: #f5f5f7;
background: linear-gradient(155deg, #34363b, #242a33);
}
:global(.phone-app.dark) .shared-content-card--messages .shared-content-card__copy p {
color: #b8b8bd;
}
</style>
+2
View File
@@ -95,6 +95,8 @@ export const useDarkChatStore = defineStore('darkchat', () => {
messageType: outgoing.messageType,
reactions: {},
replyToId: outgoing.replyToId,
sharePayload:
outgoing.messageType === 'share' ? outgoing.sharePayload : null,
}
messages.value.push(optimistic)
if (outgoing.messageType === 'voice' && outgoing.mediaPayload) {
+2
View File
@@ -85,6 +85,7 @@ describe('easyshare store', () => {
expect(easyShare.consumeChatDraft('messages')).toEqual({
appId: 'messages',
body: 'Meet at Mission Row.\nhttps://notes.sky/note-1',
payload: { ...payload, link: 'https://notes.sky/note-1' },
targetId: '5551234567',
})
expect(easyShare.consumeChatDraft('messages')).toBeNull()
@@ -98,6 +99,7 @@ describe('easyshare store', () => {
expect(easyShare.consumeChatDraft('darkchat')).toEqual({
appId: 'darkchat',
body: 'Meet at Mission Row.',
payload,
targetId: null,
})
})
+1
View File
@@ -56,6 +56,7 @@ export const useEasyShareStore = defineStore('easyshare', () => {
chatDraft.value = {
appId,
body: [...new Set(parts)].join('\n'),
payload: { ...payload.value },
targetId,
}
return Boolean(chatDraft.value.body)
+41
View File
@@ -78,6 +78,47 @@ describe('messages store', () => {
expect(messages.messages[0].delivery_status).toBe('failed')
})
it('shows a shared contact immediately and sends only its id', async () => {
const contact = {
avatar_url: 'https://picsum.photos/seed/shared-alex/240/240',
name: 'Alex Rivera',
organization: 'Maze Bank',
phone_number: '4205550137',
}
const serverMessage: SmsMessage = {
...sentMessage('contact-server-id'),
body: contact.name,
contact,
message_type: 'contact',
}
mockNuiCall
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: serverMessage, success: true })
.mockResolvedValueOnce({ data: [], success: true })
const messages = useMessagesStore()
await messages.openThread('4205550196')
const sending = messages.send({
contact,
contactId: 'contact-alex',
messageType: 'contact',
})
expect(messages.messages[0]).toMatchObject({
contact,
delivery_status: 'sending',
message_type: 'contact',
})
await sending
expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'messages:send', {
contactId: 'contact-alex',
messageType: 'contact',
phoneNumber: '4205550196',
})
})
it('normalizes numeric phone data from the NUI boundary', async () => {
mockNuiCall.mockResolvedValueOnce({
data: [
+19 -5
View File
@@ -57,8 +57,12 @@ export const useMessagesStore = defineStore('messages', () => {
if (!activeNumber.value) return { success: false, error: 'invalid_number' }
const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const optimistic: SmsMessage = {
body: outgoing.messageType === 'text' ? outgoing.body.trim() : '',
body:
outgoing.messageType === 'text' || outgoing.messageType === 'share'
? outgoing.body?.trim() ?? ''
: '',
client_id: clientId,
contact: outgoing.messageType === 'contact' ? outgoing.contact : null,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
delivery_status: 'sending',
direction: 'sent',
@@ -78,6 +82,7 @@ export const useMessagesStore = defineStore('messages', () => {
media_waveform:
outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null,
message_type: outgoing.messageType,
share: outgoing.messageType === 'share' ? outgoing.sharePayload : null,
read_at: null,
recipient_number: activeNumber.value,
sender_number: '',
@@ -88,10 +93,19 @@ export const useMessagesStore = defineStore('messages', () => {
`data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}`
}
const response = await nuiCall<SmsMessage>('messages:send', {
...outgoing,
phoneNumber: activeNumber.value,
})
const response = await nuiCall<SmsMessage>(
'messages:send',
outgoing.messageType === 'contact'
? {
contactId: outgoing.contactId,
messageType: outgoing.messageType,
phoneNumber: activeNumber.value,
}
: {
...outgoing,
phoneNumber: activeNumber.value,
},
)
const index = messages.value.findIndex(
(message) => message.client_id === clientId,
)
+21
View File
@@ -47,6 +47,20 @@ const defaultLocales: LocaleTree = {
destinations: 'Share destinations',
newMessage: 'New Message',
shareProfile: 'Share Profile',
kinds: {
contact: 'Contact',
document: 'Document',
link: 'Link',
location: 'Location',
note: 'Note',
photo: 'Photo',
playlist: 'Playlist',
post: 'Post',
profile: 'Profile',
text: 'Message',
track: 'Track',
video: 'Video',
},
chooseConversation: 'Choose a conversation',
sentToChat: 'Sent to chat.',
savedToNotes: 'Saved to Notes.',
@@ -1122,6 +1136,7 @@ const defaultLocales: LocaleTree = {
photo: 'Photo',
gif: 'GIF',
video: 'Video',
contact: 'Contact',
attachPhoto: 'Attach Photo',
takePhoto: 'Take Photo',
attachGif: 'Attach GIF',
@@ -1129,6 +1144,10 @@ const defaultLocales: LocaleTree = {
photos: 'Photos',
gifs: 'GIFs',
videos: 'Videos',
contacts: 'Contacts',
shareContact: 'Share Contact',
noContactsToShare: 'Save a contact first to share it here.',
contactSaved: 'Contact Saved',
noPhotos: 'Take a photo first to attach it here.',
noVideos: 'Record a video in Camera first.',
searchGifs: 'Search GIPHY',
@@ -1172,6 +1191,8 @@ const defaultLocales: LocaleTree = {
invalid_message: 'Enter a message.',
invalid_voice: 'The audio message is invalid.',
invalid_attachment: 'The attachment is invalid.',
invalid_contact: 'The contact is invalid.',
contact_not_found: 'This contact is no longer available.',
media_provider_unconfigured: 'Photo uploads are not configured.',
capture_provider_unavailable: 'The screenshot resource is unavailable.',
capture_failed: 'The photo could not be captured.',
+5 -1
View File
@@ -1,4 +1,5 @@
import type { DatabaseDateValue } from '@/utils/date'
import type { EasySharePayload } from '@/types/easyshare'
export type DarkChatMessageType =
| 'text'
@@ -7,6 +8,7 @@ export type DarkChatMessageType =
| 'voice'
| 'image'
| 'video'
| 'share'
| 'system'
export type DarkChatNotificationMode = 'full' | 'private' | 'hidden'
@@ -68,6 +70,7 @@ export type DarkChatMessage = {
replyToId?: string | null
replyBody?: string | null
reactions: Record<string, string>
sharePayload?: EasySharePayload | null
expiresAt?: DatabaseDateValue | null
createdAt: DatabaseDateValue
readAt?: DatabaseDateValue | null
@@ -88,12 +91,13 @@ export type DarkChatThread = {
export type DarkChatOutgoing = {
body?: string
messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video'
messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' | 'share'
mediaAssetId?: string
mediaPayload?: string
mediaPreviewUrl?: string
mediaMime?: string
mediaDurationMs?: number
mediaWaveform?: number[]
sharePayload?: EasySharePayload
replyToId?: string
}
+1
View File
@@ -19,6 +19,7 @@ export type EasyShareChatApp = 'darkchat' | 'flare' | 'messages'
export type EasyShareChatDraft = {
appId: EasyShareChatApp
body: string
payload: EasySharePayload
targetId: string | null
}
export type EasyShareStatus =
+5 -2
View File
@@ -1,8 +1,9 @@
import type { DatabaseDateValue } from '@/utils/date'
import type { EasySharePayload } from '@/types/easyshare'
export type FlareGender = 'woman' | 'man' | 'nonbinary'
export type FlareInterest = FlareGender | 'everyone'
export type FlareMessageType = 'text' | 'image' | 'gif' | 'video'
export type FlareMessageType = 'text' | 'image' | 'gif' | 'video' | 'share'
export type FlareProfile = {
age: number
@@ -43,14 +44,16 @@ export type FlareMessage = {
mediaDurationMs: number | null
mediaUrl: string | null
messageType: FlareMessageType
sharePayload: EasySharePayload | null
}
export type FlareOutgoingMessage =
| { body: string; messageType: 'text' }
| { body?: string; messageType: 'share'; sharePayload: EasySharePayload }
| {
mediaAssetId: string
mediaDurationMs?: number
messageType: Exclude<FlareMessageType, 'text'>
messageType: Exclude<FlareMessageType, 'text' | 'share'>
}
export type FlareProfileDraft = Omit<
+22 -1
View File
@@ -1,10 +1,23 @@
import type { DatabaseDateValue } from '@/utils/date'
import type { EasySharePayload } from '@/types/easyshare'
export type SmsDirection = 'sent' | 'received'
export type SmsAttachmentType = 'image' | 'gif' | 'video'
export type SmsMessageType = 'text' | 'voice' | SmsAttachmentType
export type SmsMessageType =
| 'contact'
| 'share'
| 'text'
| 'voice'
| SmsAttachmentType
export type SmsDeliveryStatus = 'sending' | 'delivered' | 'failed'
export type SmsSharedContact = {
avatar_url: string | null
name: string
organization: string | null
phone_number: string
}
export type SmsConversation = {
lastMessage: string
lastMessageAt: DatabaseDateValue
@@ -16,6 +29,7 @@ export type SmsConversation = {
export type SmsMessage = {
body: string
client_id?: string
contact?: SmsSharedContact | null
created_at: DatabaseDateValue
delivery_status?: SmsDeliveryStatus
direction: SmsDirection
@@ -25,6 +39,7 @@ export type SmsMessage = {
media_mime: string | null
media_waveform: number[] | null
message_type: SmsMessageType
share?: EasySharePayload | null
read_at: string | null
recipient_number: string
sender_number: string
@@ -32,6 +47,12 @@ export type SmsMessage = {
export type SmsOutgoingMessage =
| { body: string; messageType: 'text' }
| {
contact: SmsSharedContact
contactId: string
messageType: 'contact'
}
| { body?: string; messageType: 'share'; sharePayload: EasySharePayload }
| {
mediaDurationMs: number
mediaMime: string
+42 -11
View File
@@ -51,6 +51,7 @@ import { useRouter } from 'vue-router'
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import SharedContentCard from '@/components/SharedContentCard.vue'
import { useAccountStore } from '@/stores/account'
import { useDarkChatStore } from '@/stores/darkchat'
import { useEasyShareStore } from '@/stores/easyshare'
@@ -63,6 +64,7 @@ import type {
DarkChatNotificationMode,
} from '@/types/darkchat'
import type { GifSearchResult } from '@/types/messages'
import type { EasySharePayload } from '@/types/easyshare'
import type { MediaType, PhoneMedia } from '@/types/media'
import { copyText } from '@/utils/clipboard'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
@@ -110,7 +112,8 @@ const identifier = ref('')
const pendingIdentifier = ref('')
const safetyOpen = ref(false)
const draft = ref('')
const queuedShareBody = ref('')
const queuedSharePayload = ref<EasySharePayload | null>(null)
const shareDraft = ref<EasySharePayload | null>(null)
const aliasDraft = ref('')
const contactAliasDraft = ref('')
const notificationMode = ref<DarkChatNotificationMode>('private')
@@ -231,6 +234,7 @@ function preview(conversation: DarkChatConversationSummary): string {
if (conversation.lastMessageType === 'gif') return `GIF · ${t('gif')}`
if (conversation.lastMessageType === 'image') return `📷 ${t('photo')}`
if (conversation.lastMessageType === 'video') return `${t('video')}`
if (conversation.lastMessageType === 'share') return `🔗 ${conversation.lastMessage}`
if (conversation.lastMessageType === 'system') return t('securityUpdate')
return conversation.lastMessage
}
@@ -330,9 +334,9 @@ async function openConversation(conversationId: string): Promise<void> {
}
screen.value = 'thread'
resetPanels()
if (queuedShareBody.value) {
draft.value = queuedShareBody.value
queuedShareBody.value = ''
if (queuedSharePayload.value) {
shareDraft.value = queuedSharePayload.value
queuedSharePayload.value = null
}
await scrollBottom(false)
}
@@ -344,6 +348,7 @@ function back(): void {
}
if (screen.value === 'thread') darkchat.closeThread()
screen.value = 'inbox'
shareDraft.value = null
resetPanels()
}
@@ -411,19 +416,26 @@ async function confirmStart(): Promise<void> {
async function sendText(): Promise<void> {
const body = draft.value.trim()
if (!body || sending.value) return
if ((!body && !shareDraft.value) || sending.value) return
const shared = shareDraft.value
draft.value = ''
shareDraft.value = null
const outgoingReply = replyTo.value?.id
replyTo.value = null
resetPanels()
sending.value = true
const response = await darkchat.send({
body,
messageType: 'text',
messageType: shared ? 'share' : 'text',
replyToId: outgoingReply,
sharePayload: shared ?? undefined,
})
sending.value = false
if (!response.success) showToast(errorText(response.error))
if (!response.success) {
draft.value = body
shareDraft.value = shared
showToast(errorText(response.error))
}
await scrollBottom()
}
@@ -513,7 +525,7 @@ async function openEasyShareDraft(): Promise<boolean> {
darkchat.closeThread()
screen.value = 'inbox'
resetPanels()
queuedShareBody.value = shared.body
queuedSharePayload.value = shared.payload
draft.value = ''
return true
}
@@ -523,8 +535,9 @@ async function openEasyShareDraft(): Promise<boolean> {
}
screen.value = 'thread'
resetPanels()
queuedShareBody.value = ''
draft.value = shared.body
queuedSharePayload.value = null
shareDraft.value = shared.payload
draft.value = ''
await scrollBottom(false)
return true
}
@@ -2450,6 +2463,11 @@ onBeforeUnmount(() => {
v-else-if="message.messageType === 'voice'"
:message="message"
/>
<SharedContentCard
v-else-if="message.messageType === 'share' && message.sharePayload"
:payload="message.sharePayload"
variant="darkchat"
/>
<span v-else class="dc-message-body">{{ message.body }}</span>
<span
v-if="Object.keys(message.reactions).length"
@@ -2542,6 +2560,19 @@ onBeforeUnmount(() => {
@close="emojiOpen = false"
@pick="appendEmoji"
/>
<div
v-if="shareDraft"
class="shared-composer-preview shared-composer-preview--dark"
>
<SharedContentCard compact :payload="shareDraft" variant="darkchat" />
<button
type="button"
:aria-label="phone.t('Common.close')"
@click="shareDraft = null"
>
<X :size="15" />
</button>
</div>
<div v-if="recording" class="dc-recorder">
<k-link
component="button"
@@ -2583,7 +2614,7 @@ onBeforeUnmount(() => {
></template>
<template #right
><k-link
v-if="draft.trim()"
v-if="draft.trim() || shareDraft"
component="button"
type="button"
icon-only
+32 -8
View File
@@ -67,6 +67,7 @@ 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 SharedContentCard from '@/components/SharedContentCard.vue'
import { useEasyShareStore } from '@/stores/easyshare'
import { useFlareStore } from '@/stores/flare'
import { useMessageMediaStore } from '@/stores/messageMedia'
@@ -81,6 +82,7 @@ import type {
FlareProfileDraft,
} from '@/types/flare'
import type { PhoneMedia } from '@/types/media'
import type { EasySharePayload } from '@/types/easyshare'
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
@@ -109,6 +111,7 @@ const router = useRouter()
const activeTab = ref<FlareTab>('discover')
const activeMatch = ref<FlareMatch | null>(null)
const draft = ref('')
const shareDraft = ref<EasySharePayload | null>(null)
const matchReveal = ref<FlareMatch | null>(null)
const cardOffset = ref(0)
const currentPhotoIndex = ref(0)
@@ -596,13 +599,17 @@ async function openEasyShareDraft(): Promise<boolean> {
return true
}
await openMatch(match)
if (activeMatch.value?.id === match.id) draft.value = shared.body
if (activeMatch.value?.id === match.id) {
draft.value = ''
shareDraft.value = shared.payload
}
return true
}
function closeMatch(): void {
activeMatch.value = null
draft.value = ''
shareDraft.value = null
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
@@ -617,18 +624,23 @@ async function openRevealedMatch(): Promise<void> {
async function sendMessage(): Promise<void> {
const body = draft.value.trim()
if (!body || !activeMatch.value || flare.sending) return
if ((!body && !shareDraft.value) || !activeMatch.value || flare.sending) return
const shared = shareDraft.value
draft.value = ''
shareDraft.value = null
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
if (
!(await flare.send(activeMatch.value.id, {
body,
messageType: 'text',
}))
!(await flare.send(
activeMatch.value.id,
shared
? { body, messageType: 'share', sharePayload: shared }
: { body, messageType: 'text' },
))
) {
draft.value = body
shareDraft.value = shared
showActionError()
}
await nextTick()
@@ -1027,7 +1039,12 @@ onBeforeUnmount(() => {
:text-footer="messageTime(message.createdAt)"
>
<template v-if="message.messageType !== 'text'" #text>
<MessageAttachmentBubble :message="attachmentMessage(message)" />
<SharedContentCard
v-if="message.messageType === 'share' && message.sharePayload"
:payload="message.sharePayload"
variant="flare"
/>
<MessageAttachmentBubble v-else :message="attachmentMessage(message)" />
</template>
</k-message>
</k-messages>
@@ -1115,6 +1132,13 @@ onBeforeUnmount(() => {
@pick="appendEmoji"
/>
<div v-if="shareDraft" class="shared-composer-preview">
<SharedContentCard compact :payload="shareDraft" variant="flare" />
<button type="button" :aria-label="phone.t('Common.close')" @click="shareDraft = null">
<X :size="15" />
</button>
</div>
<k-messagebar
class="flare-messagebar messages-messagebar"
:placeholder="phone.t('Apps.flare.messagePlaceholder')"
@@ -1143,7 +1167,7 @@ onBeforeUnmount(() => {
<k-link
component="button"
icon-only
:disabled="!draft.trim() || flare.sending"
:disabled="(!draft.trim() && !shareDraft) || flare.sending"
@click="sendMessage"
>
<ArrowUpCircle :size="29" />
+165 -13
View File
@@ -35,6 +35,7 @@ import {
Search,
SquarePen,
Trash2,
ContactRound,
UserPlus,
Video,
X,
@@ -43,6 +44,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import MessageContactBubble from '@/components/MessageContactBubble.vue'
import SharedContentCard from '@/components/SharedContentCard.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue'
import { useCallsStore } from '@/stores/calls'
@@ -56,7 +59,10 @@ import type {
SmsAttachmentType,
SmsConversation,
SmsMessage,
SmsSharedContact,
} from '@/types/messages'
import type { PhoneContact } from '@/types/phone'
import type { EasySharePayload } from '@/types/easyshare'
const VOICE_MAX_DURATION_MS = 30_000
const VOICE_MAX_BYTES = 135_000
@@ -74,14 +80,15 @@ const editingList = ref(false)
const selectedNumbers = ref<string[]>([])
const composerNumber = ref('')
const draft = ref('')
const queuedShareBody = ref('')
const queuedSharePayload = ref<EasySharePayload | null>(null)
const shareDraft = ref<EasySharePayload | null>(null)
const composing = ref(false)
const sending = ref(false)
const toastOpened = ref(false)
const toastText = ref('')
const emojiOpen = ref(false)
const attachmentMenuOpen = ref(false)
const attachmentPicker = ref<'gifs' | null>(null)
const attachmentPicker = ref<'contacts' | 'gifs' | null>(null)
const contactDetailsOpen = ref(false)
const contactEditing = ref(false)
const contactNameDraft = ref('')
@@ -169,6 +176,12 @@ function conversationPreview(conversation: SmsConversation): string {
if (conversation.lastMessageType === 'video') {
return `▶️ ${phone.t('Apps.messages.video')}`
}
if (conversation.lastMessageType === 'contact') {
return `\u{1F464} ${phone.t('Apps.messages.contact')}`
}
if (conversation.lastMessageType === 'share') {
return `\u{1F517} ${conversation.lastMessage}`
}
return conversation.lastMessageType === 'voice'
? `🎙️ ${phone.t('Apps.messages.voiceMessage')}`
: conversation.lastMessage
@@ -286,6 +299,8 @@ function errorText(error?: string): string {
'invalid_message',
'invalid_voice',
'invalid_attachment',
'invalid_contact',
'contact_not_found',
'media_provider_unconfigured',
'capture_provider_unavailable',
'capture_failed',
@@ -326,8 +341,8 @@ async function openConversation(conversation: SmsConversation): Promise<void> {
return
}
composing.value = false
draft.value = queuedShareBody.value
queuedShareBody.value = ''
shareDraft.value = queuedSharePayload.value
queuedSharePayload.value = null
await scrollToBottom(false)
}
@@ -337,7 +352,7 @@ async function openEasyShareDraft(): Promise<void> {
if (!shared.targetId) {
messages.closeThread()
composing.value = false
queuedShareBody.value = shared.body
queuedSharePayload.value = shared.payload
draft.value = ''
return
}
@@ -346,8 +361,9 @@ async function openEasyShareDraft(): Promise<void> {
return
}
composing.value = false
queuedShareBody.value = ''
draft.value = shared.body
queuedSharePayload.value = null
shareDraft.value = shared.payload
draft.value = ''
await scrollToBottom(false)
}
@@ -382,6 +398,8 @@ async function chooseRecipient(number: string): Promise<void> {
return
}
composing.value = false
shareDraft.value = queuedSharePayload.value
queuedSharePayload.value = null
await scrollToBottom(false)
}
@@ -396,6 +414,7 @@ function goBack(): void {
composing.value = false
composerNumber.value = ''
draft.value = ''
shareDraft.value = null
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
@@ -460,6 +479,12 @@ function openGifPicker(): void {
if (!gifResults.value.length) void loadGifs(true)
}
function openContactPicker(): void {
attachmentMenuOpen.value = false
attachmentPicker.value = 'contacts'
emojiOpen.value = false
}
function openEmojiPicker(): void {
attachmentMenuOpen.value = false
attachmentPicker.value = null
@@ -498,6 +523,51 @@ async function sendAttachment(
await scrollToBottom()
}
async function sendContact(contact: PhoneContact): Promise<void> {
if (!messages.activeNumber || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
const response = await messages.send({
contact: {
avatar_url: contact.avatar_url ?? null,
name: contact.name,
organization: contact.organization ?? null,
phone_number: contact.phone_number,
},
contactId: contact.id,
messageType: 'contact',
})
sending.value = false
if (!response.success) showToast(errorText(response.error))
await scrollToBottom()
}
async function messageSharedContact(contact: SmsSharedContact): Promise<void> {
if (!(await messages.openThread(contact.phone_number))) {
showToast(errorText('invalid_number'))
return
}
attachmentMenuOpen.value = false
attachmentPicker.value = null
emojiOpen.value = false
await scrollToBottom(false)
}
async function saveSharedContact(contact: SmsSharedContact): Promise<void> {
if (knownContactNumbers.value.has(contact.phone_number)) return
const response = await calls.saveContact({
name: contact.name,
organization: contact.organization ?? '',
phoneNumber: contact.phone_number,
})
showToast(
response.success
? phone.t('Apps.messages.contactSaved')
: phone.t('Apps.messages.contactSaveFailed'),
)
}
async function loadGifs(reset = false): Promise<void> {
if (gifLoading.value || (!reset && !gifHasMore.value)) return
gifError.value = null
@@ -534,17 +604,31 @@ function queueGifSearch(): void {
}
async function sendTextMessage(): Promise<void> {
if (!messages.activeNumber || !draft.value.trim() || sending.value) return
if (
!messages.activeNumber ||
(!draft.value.trim() && !shareDraft.value) ||
sending.value
) return
const body = draft.value
const shared = shareDraft.value
draft.value = ''
shareDraft.value = null
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
await scrollToBottom()
const response = await messages.send({ body, messageType: 'text' })
const response = await messages.send(
shared
? { body, messageType: 'share', sharePayload: shared }
: { body, messageType: 'text' },
)
sending.value = false
if (!response.success) showToast(errorText(response.error))
if (!response.success) {
draft.value = body
shareDraft.value = shared
showToast(errorText(response.error))
}
await scrollToBottom()
}
@@ -1170,6 +1254,21 @@ onBeforeUnmount(() => {
v-if="message.message_type === 'voice'"
:message="message"
/>
<MessageContactBubble
v-else-if="message.message_type === 'contact' && message.contact"
:add-label="phone.t('Apps.messages.addContact')"
:contact="message.contact"
:message-label="phone.t('Apps.messages.messageAction')"
:saved="knownContactNumbers.has(message.contact.phone_number)"
:saved-label="phone.t('Apps.messages.contactSaved')"
@message="messageSharedContact(message.contact)"
@save="saveSharedContact(message.contact)"
/>
<SharedContentCard
v-else-if="message.message_type === 'share' && message.share"
:payload="message.share"
variant="messages"
/>
<MessageAttachmentBubble v-else :message="message" />
</template>
</k-message>
@@ -1189,6 +1288,10 @@ onBeforeUnmount(() => {
<span class="messages-action-emoji">😀</span>
{{ phone.t('Apps.messages.emoji') }}
</button>
<button type="button" @click="openContactPicker">
<span><ContactRound :size="20" /></span>
{{ phone.t('Apps.messages.shareContact') }}
</button>
<button type="button" @click="openGifPicker">
<span><ImagePlay :size="20" /></span>
{{ phone.t('Apps.messages.attachGif') }}
@@ -1202,13 +1305,51 @@ onBeforeUnmount(() => {
<section v-if="attachmentPicker" class="messages-media-picker">
<header>
<strong>
{{ phone.t('Apps.messages.gifs') }}
{{
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
}}
</strong>
<button type="button" @click="attachmentPicker = null">
{{ phone.t('Common.done') }}
</button>
</header>
<div class="messages-media-picker__gifs">
<k-list
v-if="attachmentPicker === 'contacts'"
inset
strong
class="messages-media-picker__contacts"
>
<k-list-item
v-for="contact in calls.contacts"
:key="contact.id"
link
:title="contact.name"
:subtitle="contact.organization || contact.phone_number"
@click="sendContact(contact)"
>
<template #media>
<span class="messages-avatar messages-avatar--small">
<img
v-if="contact.avatar_url"
class="messages-avatar__image"
:src="contact.avatar_url"
alt=""
/>
<span v-else class="messages-avatar__initials">{{
contactInitials(contact.phone_number)
}}</span>
</span>
</template>
</k-list-item>
<p v-if="!calls.contacts.length" class="messages-media-picker__empty">
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</k-list>
<div v-else class="messages-media-picker__gifs">
<label class="messages-gif-search">
<Search :size="15" />
<input
@@ -1252,6 +1393,17 @@ onBeforeUnmount(() => {
@pick="appendEmoji"
/>
<div v-if="shareDraft && !recording" class="shared-composer-preview">
<SharedContentCard compact :payload="shareDraft" variant="messages" />
<button
type="button"
:aria-label="phone.t('Common.close')"
@click="shareDraft = null"
>
<X :size="15" />
</button>
</div>
<section v-if="recording" class="messages-recorder">
<button
type="button"
@@ -1305,7 +1457,7 @@ onBeforeUnmount(() => {
<template #right>
<k-toolbar-pane class="ios:h-10">
<k-link
v-if="draft.trim()"
v-if="draft.trim() || shareDraft"
component="button"
icon-only
:disabled="sending"
+302 -20
View File
@@ -1534,6 +1534,56 @@ const smsMessages = [
recipient_number: '5551234567',
sender_number: '5551110001',
},
{
body: 'Samantha Cole',
contact: {
avatar_url: 'https://picsum.photos/seed/shared-samantha/240/240',
name: 'Samantha Cole',
organization: 'Downtown Cab Co.',
phone_number: '5553330044',
},
created_at: isoTime(-8 * 60_000),
direction: 'received',
id: 'sms-contact-1',
media_duration_ms: null,
media_mime: null,
media_payload: {
avatar_url: 'https://picsum.photos/seed/shared-samantha/240/240',
name: 'Samantha Cole',
organization: 'Downtown Cab Co.',
phone_number: '5553330044',
},
media_waveform: null,
message_type: 'contact',
media_asset_id: null,
read_at: null,
recipient_number: '5551234567',
sender_number: '5551110001',
},
{
body: 'Neon nights in Vinewood',
created_at: isoTime(-4 * 60_000),
direction: 'received',
id: 'sms-share-1',
media_duration_ms: null,
media_mime: null,
media_payload: {
appId: 'picstagram',
copyText: 'Die besten Lichter der Stadt direkt aus Vinewood.',
id: 'picstagram-post-neon-nights',
imageUrl: 'https://picsum.photos/seed/easyshare-neon/720/720',
kind: 'post',
link: 'skyphone://picstagram/post/picstagram-post-neon-nights',
subtitle: '@morgan',
title: 'Neon nights in Vinewood',
},
media_waveform: null,
message_type: 'share',
media_asset_id: null,
read_at: null,
recipient_number: '5551234567',
sender_number: '5551110001',
},
]
const darkChatProfile = {
id: 1,
@@ -1613,6 +1663,27 @@ const darkChatMessages = [
createdAt: '2026-08-06 22:44:00',
readAt: null,
},
{
id: 'dc-message-00000000-0000-000000000004',
conversationId: darkChatConversations[0].id,
direction: 'received',
senderProfileId: 2,
messageType: 'share',
body: 'Downtown is awake',
reactions: {},
sharePayload: {
appId: 'feather',
copyText: 'Vinewood after midnight. No filters, just city light.',
id: 'feather-post-downtown-awake',
imageUrl: 'https://picsum.photos/seed/easyshare-downtown/900/600',
kind: 'post',
link: 'skyphone://feather/post/feather-post-downtown-awake',
subtitle: '@nightowl',
title: 'Downtown is awake',
},
createdAt: isoTime(-3 * 60_000),
readAt: null,
},
]
function darkChatBootstrap() {
@@ -2478,9 +2549,9 @@ const flareMatches = [
lookingFor: 'dates',
photoUrls: [],
},
lastMessage: 'That place sounds perfect. Friday?',
lastMessageAt: isoTime(-38 * 60 * 1000),
lastMessageType: 'text',
lastMessage: 'Friday night jazz',
lastMessageAt: isoTime(-18 * 60 * 1000),
lastMessageType: 'share',
unread: 1,
},
]
@@ -2506,6 +2577,25 @@ const flareMessages = {
mediaUrl: null,
messageType: 'text',
},
{
id: 'flare-message-3',
direction: 'received',
body: 'Friday night jazz',
createdAt: isoTime(-18 * 60 * 1000),
mediaDurationMs: null,
mediaUrl: null,
messageType: 'share',
sharePayload: {
appId: 'music',
copyText: 'A late-night playlist for the drive to Vinewood.',
id: 'music-playlist-friday-jazz',
imageUrl: 'https://picsum.photos/seed/easyshare-jazz/720/720',
kind: 'playlist',
link: 'skyphone://music/playlist/music-playlist-friday-jazz',
subtitle: '12 tracks · 48 min',
title: 'Friday night jazz',
},
},
],
}
@@ -3076,9 +3166,150 @@ const featherTopics = [
]
let featherOnboarded = true
const easyShareHistory = []
const easyShareTargets = [
{ distance: 2.4, id: 41, name: 'Mia Santos' },
{ distance: 7.8, id: 72, name: 'Noah Walker' },
{ distance: 14.6, id: 105, name: 'Jamie Rivera' },
]
const easyShareHistory = [
{
createdAt: Date.now() - 2 * 60 * 1000,
direction: 'incoming',
id: 'easyshare-incoming-pending',
otherName: 'Mia Santos',
payload: {
appId: 'notes',
copyText: 'Meet at Mission Row at 20:30.',
id: 'note-easyshare-meeting',
kind: 'note',
title: 'Mission Row meeting',
},
progress: 0,
status: 'pending',
},
{
createdAt: Date.now() - 8 * 60 * 1000,
direction: 'outgoing',
id: 'easyshare-outgoing-transferring',
otherName: 'Noah Walker',
payload: {
appId: 'gallery',
copyText: 'Sunset over Los Santos.',
id: 3,
imageUrl:
'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
kind: 'photo',
title: 'Los Santos sunset',
},
progress: 58,
status: 'transferring',
},
{
createdAt: Date.now() - 22 * 60 * 1000,
direction: 'incoming',
id: 'easyshare-completed',
otherName: 'Jamie Rivera',
payload: {
appId: 'map',
copyText: 'Legion Square',
kind: 'location',
link: 'https://maps.sky/legion-square',
title: 'Legion Square',
},
progress: 100,
status: 'completed',
},
{
createdAt: Date.now() - 48 * 60 * 1000,
direction: 'outgoing',
id: 'easyshare-accepted',
otherName: 'Mia Santos',
payload: {
appId: 'music',
copyText: 'Night Drive by Neon Coast',
kind: 'track',
title: 'Night Drive',
},
progress: 15,
status: 'accepted',
},
{
createdAt: Date.now() - 2 * 60 * 60 * 1000,
direction: 'outgoing',
id: 'easyshare-declined',
otherName: 'Noah Walker',
payload: {
appId: 'feather',
copyText: 'Road closure near Alta Street.',
id: 'feather-post-3',
kind: 'post',
title: 'Road closure',
},
progress: 0,
status: 'declined',
},
{
createdAt: Date.now() - 4 * 60 * 60 * 1000,
direction: 'outgoing',
id: 'easyshare-cancelled',
otherName: 'Jamie Rivera',
payload: {
appId: 'phone',
copyText: 'Mia Santos\n5550142',
kind: 'contact',
title: 'Mia Santos',
},
progress: 31,
status: 'cancelled',
},
{
createdAt: Date.now() - 7 * 60 * 60 * 1000,
direction: 'incoming',
id: 'easyshare-expired',
otherName: 'Mia Santos',
payload: {
appId: 'picstagram',
copyText: 'New post from @mia.santos',
id: 'picstagram-post-1',
kind: 'post',
title: 'Vespucci evening',
},
progress: 0,
status: 'expired',
},
{
createdAt: Date.now() - 24 * 60 * 60 * 1000,
direction: 'outgoing',
id: 'easyshare-failed',
otherName: 'Noah Walker',
payload: {
appId: 'gallery',
copyText: 'Vehicle walkaround video.',
id: 7,
kind: 'video',
title: 'Vehicle walkaround',
},
progress: 73,
status: 'failed',
},
]
let easyShareVisibility = 'everyone'
function easyShareHistoryForScenario(testScenario) {
if (testScenario === 'easyshare-empty') return []
if (testScenario === 'easyshare-incoming') {
return easyShareHistory.filter(
(transfer) => transfer.id === 'easyshare-incoming-pending',
)
}
if (testScenario === 'easyshare-history') {
return easyShareHistory.filter(
(transfer) => !['pending', 'transferring'].includes(transfer.status),
)
}
return easyShareHistory
}
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
@@ -3996,7 +4227,9 @@ app.post('/api/:endpoint', (request, response) => {
!match ||
(messageType === 'text'
? !body
: !['image', 'gif', 'video'].includes(messageType) || !mediaUrl)
: messageType === 'share'
? !request.body.sharePayload
: !['image', 'gif', 'video'].includes(messageType) || !mediaUrl)
) {
response.json({ success: false, error: 'invalid_message' })
return
@@ -4004,11 +4237,18 @@ app.post('/api/:endpoint', (request, response) => {
const message = {
id: `flare-message-${Date.now()}`,
direction: 'sent',
body: messageType === 'text' ? body : '',
body:
messageType === 'share'
? body || request.body.sharePayload.title
: messageType === 'text'
? body
: '',
createdAt: Date.now(),
mediaDurationMs: request.body.mediaDurationMs ?? null,
mediaUrl: messageType === 'text' ? null : mediaUrl,
messageType,
sharePayload:
messageType === 'share' ? request.body.sharePayload : null,
}
flareMessages[match.id] ??= []
flareMessages[match.id].push(message)
@@ -5330,7 +5570,10 @@ app.post('/api/:endpoint', (request, response) => {
}
const messageType = request.body.messageType ?? 'text'
const body = String(request.body.body ?? '')
if ((messageType === 'text' || messageType === 'emoji') && !body.trim()) {
if (
((messageType === 'text' || messageType === 'emoji') && !body.trim()) ||
(messageType === 'share' && !request.body.sharePayload)
) {
response.json({ success: false, error: 'invalid_message' })
return
}
@@ -5343,7 +5586,10 @@ app.post('/api/:endpoint', (request, response) => {
direction: 'sent',
senderProfileId: darkChatProfile.id,
messageType,
body,
body:
messageType === 'share'
? body.trim() || request.body.sharePayload.title
: body,
mediaPayload:
messageType === 'gif' ? request.body.mediaPayload : undefined,
mediaSecret:
@@ -5354,6 +5600,8 @@ app.post('/api/:endpoint', (request, response) => {
replyToId: request.body.replyToId,
replyBody: reply?.body,
reactions: {},
sharePayload:
messageType === 'share' ? request.body.sharePayload : null,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
readAt: null,
}
@@ -5494,17 +5742,15 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'easyshare:bootstrap') {
const history = easyShareHistoryForScenario(testScenario)
response.json({
success: true,
data: {
history: easyShareHistory,
pending: easyShareHistory.filter((item) =>
history,
pending: history.filter((item) =>
['pending', 'transferring'].includes(item.status),
),
targets: [
{ distance: 2.4, id: 41, name: 'Mia Santos' },
{ distance: 7.8, id: 72, name: 'Noah Walker' },
],
targets: testScenario === 'easyshare-empty' ? [] : easyShareTargets,
visibility: easyShareVisibility,
},
})
@@ -5516,21 +5762,26 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'easyshare:request') {
const target = easyShareTargets.find(
(candidate) => candidate.id === Number(request.body.targetId),
)
const transfer = {
createdAt: Date.now(),
direction: 'outgoing',
id: `easyshare-${Date.now()}`,
otherName: request.body.targetId === 72 ? 'Noah Walker' : 'Mia Santos',
otherName: target?.name ?? 'Unknown device',
payload: request.body.payload,
progress: 0,
status: 'pending',
progress: target?.id === 72 ? 58 : 0,
status: target?.id === 72 ? 'transferring' : 'pending',
}
easyShareHistory.unshift(transfer)
response.json({ success: true, data: transfer })
return
}
if (endpoint === 'easyshare:respond' || endpoint === 'easyshare:cancel') {
const transfer = easyShareHistory.find((item) => item.id === request.body.id)
const transfer = easyShareHistory.find(
(item) => item.id === request.body.id,
)
if (!transfer) {
response.json({ success: false, error: 'transfer_not_found' })
return
@@ -5541,7 +5792,8 @@ app.post('/api/:endpoint', (request, response) => {
: request.body.accepted
? 'completed'
: 'declined'
transfer.progress = transfer.status === 'completed' ? 100 : transfer.progress
transfer.progress =
transfer.status === 'completed' ? 100 : transfer.progress
response.json({ success: true, data: transfer })
return
}
@@ -5608,6 +5860,8 @@ app.post('/api/:endpoint', (request, response) => {
success: true,
data: thread.map(({ media_payload, ...message }) => ({
...message,
contact: message.message_type === 'contact' ? media_payload : null,
share: message.message_type === 'share' ? media_payload : null,
media_asset_id: ['image', 'gif', 'video'].includes(message.message_type)
? media_payload
: null,
@@ -5637,6 +5891,10 @@ app.post('/api/:endpoint', (request, response) => {
const phoneNumber = String(request.body.phoneNumber ?? '')
const messageType = request.body.messageType ?? 'text'
const isAttachment = ['image', 'gif', 'video'].includes(messageType)
const selectedContact =
messageType === 'contact'
? contacts.find((contact) => contact.id === request.body.contactId)
: null
const requestedAttachmentId = String(request.body.mediaAssetId ?? '')
const selectedMedia = /^\d+$/.test(requestedAttachmentId)
? mockMedia.find((item) => String(item.id) === requestedAttachmentId)
@@ -5646,6 +5904,8 @@ app.post('/api/:endpoint', (request, response) => {
!phoneNumber ||
(messageType === 'text' && !body) ||
(messageType === 'voice' && !request.body.mediaPayload) ||
(messageType === 'contact' && !selectedContact) ||
(messageType === 'share' && !request.body.sharePayload) ||
(isAttachment &&
!attachmentAssets[messageType].has(attachmentId) &&
!attachmentId.startsWith('https://') &&
@@ -5655,7 +5915,19 @@ app.post('/api/:endpoint', (request, response) => {
return
}
const message = {
body,
body:
selectedContact?.name ??
(messageType === 'share'
? body || request.body.sharePayload.title
: body),
contact: selectedContact
? {
avatar_url: selectedContact.avatar_url ?? null,
name: selectedContact.name,
organization: selectedContact.organization ?? null,
phone_number: selectedContact.phone_number,
}
: null,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
direction: 'sent',
id: `sms-${Date.now()}`,
@@ -5675,6 +5947,15 @@ app.post('/api/:endpoint', (request, response) => {
media_payload:
messageType === 'voice'
? request.body.mediaPayload
: selectedContact
? {
avatar_url: selectedContact.avatar_url ?? null,
name: selectedContact.name,
organization: selectedContact.organization ?? null,
phone_number: selectedContact.phone_number,
}
: messageType === 'share'
? request.body.sharePayload
: isAttachment
? attachmentId
: null,
@@ -5685,6 +5966,7 @@ app.post('/api/:endpoint', (request, response) => {
read_at: null,
recipient_number: phoneNumber,
sender_number: '5551234567',
share: messageType === 'share' ? request.body.sharePayload : null,
}
smsMessages.push(message)
const { media_payload, ...publicMessage } = message