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
+13
View File
@@ -224,6 +224,19 @@ From `frontend/`, run `pnpm dev` for browser development. The phone opens automa
- Profile onboarding: `http://localhost:5174/?apiPort=3002&testScenario=feather-onboarding#/apps/feather`
- Empty states: `http://localhost:5174/?apiPort=3002&testScenario=feather-empty#/apps/feather`
EasyShare browser data is available from every app that exposes a share action. Open the seeded
Gallery directly, share an item, and then use the EasyShare and History actions in the sheet:
- Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired,
and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/gallery`
- Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/gallery`
- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/gallery`
- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/apps/gallery`
In the full scenario, sending to Mia or Jamie creates a pending transfer. Sending to Noah creates a
transfer at 58 percent so the progress and cancel states can be tested. Visibility changes and
accepting or declining the seeded incoming request are kept in memory until the mock server restarts.
The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging.
`pnpm build` uses `build.cjs` to replace `sky_phone/source/html` deterministically with the Vite output. Production assets use relative paths so they work through the FiveM NUI protocol.
+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
+9 -2
View File
@@ -345,8 +345,9 @@ Locales["en"] = {
name = "Messages", newMessage = "New message from {sender}", compose = "New Message",
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS",
photo = "Photo", gif = "GIF", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
attachGif = "Attach GIF", attachVideo = "Attach Video", 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", loadMore = "Load More",
moreActions = "More Actions", contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number",
call = "Call", messageAction = "Message", addContact = "Add Contact", deleteContact = "Delete Contact",
@@ -363,7 +364,8 @@ Locales["en"] = {
noResults = "No Results", today = "Today", yesterday = "Yesterday",
errors = {
invalid_number = "Enter a valid phone number.", invalid_message = "Enter a message.", invalid_voice = "The audio message is invalid.",
invalid_attachment = "The attachment 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.",
video_provider_unavailable = "Video capture requires the screencapture resource.", video_capture_failed = "The video could not be recorded.",
@@ -1019,6 +1021,11 @@ Locales["en"] = {
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
shareProfile = "Share Profile", chooseConversation = "Choose a conversation",
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",
},
savedToNotes = "Saved to Notes.", copied = "Copied.", copy = "Copy", copyLink = "Copy Link",
nearby = "People Nearby", history = "Transfer History", noHistory = "No transfers yet.",
noNearby = "No visible players are nearby.", visibility = "Visibility", requestSent = "Waiting for acceptance...",
+20 -2
View File
@@ -181,6 +181,10 @@ local function message_payload(row, profile_id)
local deleted_everywhere = row.deleted_for_everyone == 1 or row.deleted_for_everyone == true
local reactions = decode_array(row.reactions, "reactions")
local waveform = row.media_waveform and decode_array(row.media_waveform, "media_waveform") or nil
local share_payload = row.message_type == "share" and json.decode(row.media_payload or "") or nil
if row.message_type == "share" and type(share_payload) ~= "table" then
error(("[sky_phone] DarkChat message %s has an invalid share payload."):format(tostring(row.id)))
end
return {
id = row.id,
conversationId = row.conversation_id,
@@ -189,12 +193,14 @@ local function message_payload(row, profile_id)
messageType = deleted_everywhere and "system" or row.message_type,
body = deleted_everywhere and "message_deleted" or row.body,
mediaMime = deleted_everywhere and nil or row.media_mime,
mediaPayload = not deleted_everywhere and row.message_type ~= "voice" and row.media_payload or nil,
mediaPayload = not deleted_everywhere and row.message_type ~= "voice"
and row.message_type ~= "share" and row.media_payload or nil,
mediaDurationMs = deleted_everywhere and nil or tonumber(row.media_duration_ms),
mediaWaveform = deleted_everywhere and nil or waveform,
replyToId = row.reply_to_id,
replyBody = row.reply_body,
reactions = reactions,
sharePayload = not deleted_everywhere and share_payload or nil,
expiresAt = row.expires_at,
createdAt = row.created_at,
readAt = row.peer_last_read_at and row.peer_last_read_at >= row.created_at and row.peer_last_read_at or nil,
@@ -538,6 +544,17 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
end
media_payload = media_url
media_mime = message_type == "image" and "image/jpeg" or "video/mp4"
elseif message_type == "share" then
local share
local share_error
share, share_error, media_payload = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
if not share then
return { success = false, error = share_error or "invalid_payload" }
end
if body ~= "" and #body > Config.DarkChat.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
body = body ~= "" and body or share.title
else
return { success = false, error = "invalid_message" }
end
@@ -579,7 +596,8 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
conversationId = conversation_id,
sender = profile.alias,
messageType = message_type,
preview = (message_type == "text" or message_type == "emoji") and body or message_type,
preview = (message_type == "text" or message_type == "emoji" or message_type == "share")
and body or message_type,
})
return { success = true, data = message }
end)
+7 -6
View File
@@ -521,7 +521,7 @@ local schema = {
{ name = "recipient_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "recipient_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "message_type", type = "ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'" },
{ name = "message_type", type = "ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text'" },
{ name = "body", type = "VARCHAR(2000) NOT NULL" },
{ name = "media_payload", type = "MEDIUMTEXT NULL" },
{ name = "media_mime", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" },
@@ -1022,7 +1022,7 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_profile_id", type = "BIGINT UNSIGNED NULL" },
{ name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'" },
{ name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'" },
{ name = "body", type = "TEXT NOT NULL" },
{ name = "media_payload", type = "LONGTEXT NULL" },
{ name = "media_mime", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
@@ -1680,8 +1680,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 = "message_type", type = "ENUM('text', 'image', 'gif', 'video', 'share') NOT NULL DEFAULT 'text'" },
{ name = "media_url", type = "VARCHAR(2048) NULL" },
{ name = "share_payload", type = "LONGTEXT 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" },
@@ -2173,15 +2174,15 @@ Bridge.Database.Query([[
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_flare_messages`
MODIFY COLUMN `message_type` ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
MODIFY COLUMN `message_type` ENUM('text', 'image', 'gif', 'video', 'share') 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'
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_marketplace_images`
+10
View File
@@ -1,3 +1,5 @@
SkyPhoneEasyShare = {}
Bridge.Database.AfterMigration("sky_phone", function()
local active_transfers = {}
local valid_kinds = {
@@ -259,6 +261,14 @@ local function sanitize_payload(source, device, data)
return payload, nil, encoded
end
function SkyPhoneEasyShare.SanitizeChatPayload(source, data)
local device, error_response = current_device(source)
if not device then
return nil, error_response.error
end
return sanitize_payload(source, device, data)
end
local function transfer_for_source(transfer, source)
local incoming = transfer.recipient_source == source
return {
+27 -5
View File
@@ -2,7 +2,7 @@ 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 message_types = { text = true, image = true, gif = true, video = true, share = true }
local function allowed_gif_url(value)
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then
@@ -386,6 +386,22 @@ local function validate_message(source, data)
return { body = body, message_type = message_type }
end
if message_type == "share" then
local share, share_error, encoded = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
if not share then
return nil, share_error or "invalid_payload"
end
local body = trim(data.body) or ""
if #body > 1000 then
return nil, "invalid_message"
end
return {
body = body ~= "" and body or share.title,
message_type = message_type,
share_payload = encoded,
}
end
if type(data.mediaAssetId) ~= "string" then
return nil, "invalid_attachment"
end
@@ -427,6 +443,10 @@ local function validate_message(source, data)
end
local function message_payload(row, account_id)
local share_payload = row.message_type == "share" and json.decode(row.share_payload or "") or nil
if row.message_type == "share" and type(share_payload) ~= "table" then
error(("[sky_phone] Flare message %s has an invalid share payload."):format(tostring(row.id)))
end
return {
id = row.id,
direction = tonumber(row.sender_account_id) == tonumber(account_id) and "sent" or "received",
@@ -435,6 +455,7 @@ local function message_payload(row, account_id)
messageType = row.message_type or "text",
mediaUrl = row.media_url,
mediaDurationMs = tonumber(row.media_duration_ms),
sharePayload = share_payload,
}
end
@@ -670,7 +691,7 @@ 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`, `message_type`, `media_url`,
SELECT `id`, `sender_account_id`, `body`, `message_type`, `media_url`, `share_payload`,
`media_duration_ms`, UNIX_TIMESTAMP(`created_at`) * 1000 AS `created_at_ms`
FROM `sky_phone_flare_messages`
WHERE `match_id` = ?
@@ -705,11 +726,11 @@ Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
Bridge.Database.Query([[
INSERT INTO `sky_phone_flare_messages`
(`id`, `match_id`, `sender_account_id`, `body`, `message_type`, `media_url`,
`media_duration_ms`)
VALUES (?, ?, ?, ?, ?, ?, ?)
`media_duration_ms`, `share_payload`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
]], {
id, match.id, account.id, message.body, message.message_type, message.media_url,
message.media_duration_ms,
message.media_duration_ms, message.share_payload,
})
local recipient_account_id = tonumber(match.account_a_id) == tonumber(account.id)
and tonumber(match.account_b_id) or tonumber(match.account_a_id)
@@ -728,6 +749,7 @@ Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
message_type = message.message_type,
media_url = message.media_url,
media_duration_ms = message.media_duration_ms,
share_payload = message.share_payload,
}, account.id),
}
end)
+100 -1
View File
@@ -97,9 +97,59 @@ local function current_device(source)
return device
end
local function shared_contact(device, contact_id)
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
return nil, "invalid_contact"
end
local condition
local params
if device.account_id then
condition = "contact.`account_id` = ?"
params = { contact_id, tonumber(device.account_id) }
else
condition = "contact.`account_id` IS NULL AND contact.`device_imei` = ?"
params = { contact_id, device.imei }
end
local rows = Bridge.Database.Query(([[
SELECT contact.`name`, contact.`organization`, contact.`phone_number`, media.`url` AS `avatar_url`
FROM `sky_phone_contacts` contact
LEFT JOIN `sky_phone_media` media ON media.`id` = contact.`avatar_media_id`
WHERE contact.`contact_id` = ? AND %s
LIMIT 1
]]):format(condition), params)
local contact = rows[1]
if not contact then
return nil, "contact_not_found"
end
local name = trim(contact.name)
local organization = trim(contact.organization)
local number = SkyPhoneSimNumber.Normalize(
contact.phone_number,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength
or (organization and #organization > Config.Calls.ContactNameMaxLength) or not number
then
error(("[sky_phone] Contact %s cannot be shared because its stored data is invalid."):format(contact_id))
end
local snapshot = {
avatar_url = contact.avatar_url,
name = name,
organization = organization ~= "" and organization or nil,
phone_number = number,
}
return {
data = snapshot,
payload = json.encode(snapshot),
}
end
local function format_message(row)
row.media_duration_ms = tonumber(row.media_duration_ms)
row.media_asset_id = nil
row.contact = nil
row.share = nil
if row.message_type == "voice" then
local waveform = row.media_waveform and json.decode(row.media_waveform) or nil
if type(waveform) ~= "table" then
@@ -107,6 +157,33 @@ local function format_message(row)
end
row.media_waveform = waveform
row.media_payload = nil
elseif row.message_type == "contact" then
local contact = row.media_payload and json.decode(row.media_payload) or nil
if type(contact) ~= "table" or type(contact.name) ~= "string"
or type(contact.phone_number) ~= "string"
or (contact.avatar_url ~= nil and type(contact.avatar_url) ~= "string")
or (contact.organization ~= nil and type(contact.organization) ~= "string")
then
error(("[sky_phone] Message %s has an invalid contact payload."):format(tostring(row.id)))
end
row.contact = contact
row.media_payload = nil
row.media_duration_ms = nil
row.media_mime = nil
row.media_waveform = nil
elseif row.message_type == "share" then
local share = row.media_payload and json.decode(row.media_payload) or nil
if type(share) ~= "table" or type(share.appId) ~= "string"
or type(share.kind) ~= "string" or type(share.title) ~= "string"
or type(share.copyText) ~= "string"
then
error(("[sky_phone] Message %s has an invalid share payload."):format(tostring(row.id)))
end
row.share = share
row.media_payload = nil
row.media_duration_ms = nil
row.media_mime = nil
row.media_waveform = nil
elseif attachment_assets[row.message_type] then
if not valid_stored_attachment(row.message_type, row.media_payload) then
error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id)))
@@ -414,6 +491,8 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
local body = trim(data.body) or ""
local voice = nil
local attachment = nil
local contact = nil
local share = nil
if message_type == "text" then
if body == "" or #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" }
@@ -424,6 +503,25 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
return { success = false, error = "invalid_voice" }
end
body = ""
elseif message_type == "contact" then
local contact_error
contact, contact_error = shared_contact(device, data.contactId)
if not contact then
return { success = false, error = contact_error }
end
body = contact.data.name
elseif message_type == "share" then
local share_error
local encoded
share, share_error, encoded = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
if not share then
return { success = false, error = share_error or "invalid_payload" }
end
if body ~= "" and #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
share = { data = share, payload = encoded }
body = body ~= "" and body or share.data.title
elseif attachment_assets[message_type] then
attachment = validate_attachment(source, device, message_type, data)
if not attachment then
@@ -459,7 +557,8 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
number,
message_type,
body,
voice and voice.payload or attachment and attachment.payload or nil,
voice and voice.payload or attachment and attachment.payload or contact and contact.payload
or share and share.payload or nil,
voice and voice.mime or attachment and attachment.mime or nil,
voice and voice.duration or attachment and attachment.duration or nil,
voice and voice.waveform or nil,
+3 -2
View File
@@ -292,7 +292,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
`recipient_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`sender_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`recipient_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text',
`message_type` ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text',
`body` VARCHAR(2000) NOT NULL,
`media_payload` MEDIUMTEXT NULL,
`media_mime` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL,
@@ -540,8 +540,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',
`message_type` ENUM('text', 'image', 'gif', 'video', 'share') NOT NULL DEFAULT 'text',
`media_url` VARCHAR(2048) NULL,
`share_payload` LONGTEXT NULL,
`media_duration_ms` INT UNSIGNED NULL,
`read_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,