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` - Profile onboarding: `http://localhost:5174/?apiPort=3002&testScenario=feather-onboarding#/apps/feather`
- Empty states: `http://localhost:5174/?apiPort=3002&testScenario=feather-empty#/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. 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. `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; gap: 7px;
overflow-y: auto; 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 { .messages-media-picker__gifs button {
min-height: 83px; min-height: 83px;
display: flex; display: flex;
@@ -6106,6 +6153,18 @@ button {
display: block; display: block;
margin: 0; 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-thread-page
.messages-messagebar .messages-messagebar
> .k-toolbar > .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, messageType: outgoing.messageType,
reactions: {}, reactions: {},
replyToId: outgoing.replyToId, replyToId: outgoing.replyToId,
sharePayload:
outgoing.messageType === 'share' ? outgoing.sharePayload : null,
} }
messages.value.push(optimistic) messages.value.push(optimistic)
if (outgoing.messageType === 'voice' && outgoing.mediaPayload) { if (outgoing.messageType === 'voice' && outgoing.mediaPayload) {
+2
View File
@@ -85,6 +85,7 @@ describe('easyshare store', () => {
expect(easyShare.consumeChatDraft('messages')).toEqual({ expect(easyShare.consumeChatDraft('messages')).toEqual({
appId: 'messages', appId: 'messages',
body: 'Meet at Mission Row.\nhttps://notes.sky/note-1', body: 'Meet at Mission Row.\nhttps://notes.sky/note-1',
payload: { ...payload, link: 'https://notes.sky/note-1' },
targetId: '5551234567', targetId: '5551234567',
}) })
expect(easyShare.consumeChatDraft('messages')).toBeNull() expect(easyShare.consumeChatDraft('messages')).toBeNull()
@@ -98,6 +99,7 @@ describe('easyshare store', () => {
expect(easyShare.consumeChatDraft('darkchat')).toEqual({ expect(easyShare.consumeChatDraft('darkchat')).toEqual({
appId: 'darkchat', appId: 'darkchat',
body: 'Meet at Mission Row.', body: 'Meet at Mission Row.',
payload,
targetId: null, targetId: null,
}) })
}) })
+1
View File
@@ -56,6 +56,7 @@ export const useEasyShareStore = defineStore('easyshare', () => {
chatDraft.value = { chatDraft.value = {
appId, appId,
body: [...new Set(parts)].join('\n'), body: [...new Set(parts)].join('\n'),
payload: { ...payload.value },
targetId, targetId,
} }
return Boolean(chatDraft.value.body) return Boolean(chatDraft.value.body)
+41
View File
@@ -78,6 +78,47 @@ describe('messages store', () => {
expect(messages.messages[0].delivery_status).toBe('failed') 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 () => { it('normalizes numeric phone data from the NUI boundary', async () => {
mockNuiCall.mockResolvedValueOnce({ mockNuiCall.mockResolvedValueOnce({
data: [ data: [
+19 -5
View File
@@ -57,8 +57,12 @@ export const useMessagesStore = defineStore('messages', () => {
if (!activeNumber.value) return { success: false, error: 'invalid_number' } if (!activeNumber.value) return { success: false, error: 'invalid_number' }
const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const optimistic: SmsMessage = { const optimistic: SmsMessage = {
body: outgoing.messageType === 'text' ? outgoing.body.trim() : '', body:
outgoing.messageType === 'text' || outgoing.messageType === 'share'
? outgoing.body?.trim() ?? ''
: '',
client_id: clientId, client_id: clientId,
contact: outgoing.messageType === 'contact' ? outgoing.contact : null,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '), created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
delivery_status: 'sending', delivery_status: 'sending',
direction: 'sent', direction: 'sent',
@@ -78,6 +82,7 @@ export const useMessagesStore = defineStore('messages', () => {
media_waveform: media_waveform:
outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null, outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null,
message_type: outgoing.messageType, message_type: outgoing.messageType,
share: outgoing.messageType === 'share' ? outgoing.sharePayload : null,
read_at: null, read_at: null,
recipient_number: activeNumber.value, recipient_number: activeNumber.value,
sender_number: '', sender_number: '',
@@ -88,10 +93,19 @@ export const useMessagesStore = defineStore('messages', () => {
`data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}`
} }
const response = await nuiCall<SmsMessage>('messages:send', { const response = await nuiCall<SmsMessage>(
...outgoing, 'messages:send',
phoneNumber: activeNumber.value, outgoing.messageType === 'contact'
}) ? {
contactId: outgoing.contactId,
messageType: outgoing.messageType,
phoneNumber: activeNumber.value,
}
: {
...outgoing,
phoneNumber: activeNumber.value,
},
)
const index = messages.value.findIndex( const index = messages.value.findIndex(
(message) => message.client_id === clientId, (message) => message.client_id === clientId,
) )
+21
View File
@@ -47,6 +47,20 @@ const defaultLocales: LocaleTree = {
destinations: 'Share destinations', destinations: 'Share destinations',
newMessage: 'New Message', newMessage: 'New Message',
shareProfile: 'Share Profile', 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', chooseConversation: 'Choose a conversation',
sentToChat: 'Sent to chat.', sentToChat: 'Sent to chat.',
savedToNotes: 'Saved to Notes.', savedToNotes: 'Saved to Notes.',
@@ -1122,6 +1136,7 @@ const defaultLocales: LocaleTree = {
photo: 'Photo', photo: 'Photo',
gif: 'GIF', gif: 'GIF',
video: 'Video', video: 'Video',
contact: 'Contact',
attachPhoto: 'Attach Photo', attachPhoto: 'Attach Photo',
takePhoto: 'Take Photo', takePhoto: 'Take Photo',
attachGif: 'Attach GIF', attachGif: 'Attach GIF',
@@ -1129,6 +1144,10 @@ const defaultLocales: LocaleTree = {
photos: 'Photos', photos: 'Photos',
gifs: 'GIFs', gifs: 'GIFs',
videos: 'Videos', 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.', noPhotos: 'Take a photo first to attach it here.',
noVideos: 'Record a video in Camera first.', noVideos: 'Record a video in Camera first.',
searchGifs: 'Search GIPHY', searchGifs: 'Search GIPHY',
@@ -1172,6 +1191,8 @@ const defaultLocales: LocaleTree = {
invalid_message: 'Enter a message.', invalid_message: 'Enter a message.',
invalid_voice: 'The audio message is invalid.', 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.', media_provider_unconfigured: 'Photo uploads are not configured.',
capture_provider_unavailable: 'The screenshot resource is unavailable.', capture_provider_unavailable: 'The screenshot resource is unavailable.',
capture_failed: 'The photo could not be captured.', capture_failed: 'The photo could not be captured.',
+5 -1
View File
@@ -1,4 +1,5 @@
import type { DatabaseDateValue } from '@/utils/date' import type { DatabaseDateValue } from '@/utils/date'
import type { EasySharePayload } from '@/types/easyshare'
export type DarkChatMessageType = export type DarkChatMessageType =
| 'text' | 'text'
@@ -7,6 +8,7 @@ export type DarkChatMessageType =
| 'voice' | 'voice'
| 'image' | 'image'
| 'video' | 'video'
| 'share'
| 'system' | 'system'
export type DarkChatNotificationMode = 'full' | 'private' | 'hidden' export type DarkChatNotificationMode = 'full' | 'private' | 'hidden'
@@ -68,6 +70,7 @@ export type DarkChatMessage = {
replyToId?: string | null replyToId?: string | null
replyBody?: string | null replyBody?: string | null
reactions: Record<string, string> reactions: Record<string, string>
sharePayload?: EasySharePayload | null
expiresAt?: DatabaseDateValue | null expiresAt?: DatabaseDateValue | null
createdAt: DatabaseDateValue createdAt: DatabaseDateValue
readAt?: DatabaseDateValue | null readAt?: DatabaseDateValue | null
@@ -88,12 +91,13 @@ export type DarkChatThread = {
export type DarkChatOutgoing = { export type DarkChatOutgoing = {
body?: string body?: string
messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' | 'share'
mediaAssetId?: string mediaAssetId?: string
mediaPayload?: string mediaPayload?: string
mediaPreviewUrl?: string mediaPreviewUrl?: string
mediaMime?: string mediaMime?: string
mediaDurationMs?: number mediaDurationMs?: number
mediaWaveform?: number[] mediaWaveform?: number[]
sharePayload?: EasySharePayload
replyToId?: string replyToId?: string
} }
+1
View File
@@ -19,6 +19,7 @@ export type EasyShareChatApp = 'darkchat' | 'flare' | 'messages'
export type EasyShareChatDraft = { export type EasyShareChatDraft = {
appId: EasyShareChatApp appId: EasyShareChatApp
body: string body: string
payload: EasySharePayload
targetId: string | null targetId: string | null
} }
export type EasyShareStatus = export type EasyShareStatus =
+5 -2
View File
@@ -1,8 +1,9 @@
import type { DatabaseDateValue } from '@/utils/date' import type { DatabaseDateValue } from '@/utils/date'
import type { EasySharePayload } from '@/types/easyshare'
export type FlareGender = 'woman' | 'man' | 'nonbinary' export type FlareGender = 'woman' | 'man' | 'nonbinary'
export type FlareInterest = FlareGender | 'everyone' export type FlareInterest = FlareGender | 'everyone'
export type FlareMessageType = 'text' | 'image' | 'gif' | 'video' export type FlareMessageType = 'text' | 'image' | 'gif' | 'video' | 'share'
export type FlareProfile = { export type FlareProfile = {
age: number age: number
@@ -43,14 +44,16 @@ export type FlareMessage = {
mediaDurationMs: number | null mediaDurationMs: number | null
mediaUrl: string | null mediaUrl: string | null
messageType: FlareMessageType messageType: FlareMessageType
sharePayload: EasySharePayload | null
} }
export type FlareOutgoingMessage = export type FlareOutgoingMessage =
| { body: string; messageType: 'text' } | { body: string; messageType: 'text' }
| { body?: string; messageType: 'share'; sharePayload: EasySharePayload }
| { | {
mediaAssetId: string mediaAssetId: string
mediaDurationMs?: number mediaDurationMs?: number
messageType: Exclude<FlareMessageType, 'text'> messageType: Exclude<FlareMessageType, 'text' | 'share'>
} }
export type FlareProfileDraft = Omit< export type FlareProfileDraft = Omit<
+22 -1
View File
@@ -1,10 +1,23 @@
import type { DatabaseDateValue } from '@/utils/date' import type { DatabaseDateValue } from '@/utils/date'
import type { EasySharePayload } from '@/types/easyshare'
export type SmsDirection = 'sent' | 'received' export type SmsDirection = 'sent' | 'received'
export type SmsAttachmentType = 'image' | 'gif' | 'video' 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 SmsDeliveryStatus = 'sending' | 'delivered' | 'failed'
export type SmsSharedContact = {
avatar_url: string | null
name: string
organization: string | null
phone_number: string
}
export type SmsConversation = { export type SmsConversation = {
lastMessage: string lastMessage: string
lastMessageAt: DatabaseDateValue lastMessageAt: DatabaseDateValue
@@ -16,6 +29,7 @@ export type SmsConversation = {
export type SmsMessage = { export type SmsMessage = {
body: string body: string
client_id?: string client_id?: string
contact?: SmsSharedContact | null
created_at: DatabaseDateValue created_at: DatabaseDateValue
delivery_status?: SmsDeliveryStatus delivery_status?: SmsDeliveryStatus
direction: SmsDirection direction: SmsDirection
@@ -25,6 +39,7 @@ export type SmsMessage = {
media_mime: string | null media_mime: string | null
media_waveform: number[] | null media_waveform: number[] | null
message_type: SmsMessageType message_type: SmsMessageType
share?: EasySharePayload | null
read_at: string | null read_at: string | null
recipient_number: string recipient_number: string
sender_number: string sender_number: string
@@ -32,6 +47,12 @@ export type SmsMessage = {
export type SmsOutgoingMessage = export type SmsOutgoingMessage =
| { body: string; messageType: 'text' } | { body: string; messageType: 'text' }
| {
contact: SmsSharedContact
contactId: string
messageType: 'contact'
}
| { body?: string; messageType: 'share'; sharePayload: EasySharePayload }
| { | {
mediaDurationMs: number mediaDurationMs: number
mediaMime: string mediaMime: string
+42 -11
View File
@@ -51,6 +51,7 @@ import { useRouter } from 'vue-router'
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue' import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue' import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import SharedContentCard from '@/components/SharedContentCard.vue'
import { useAccountStore } from '@/stores/account' import { useAccountStore } from '@/stores/account'
import { useDarkChatStore } from '@/stores/darkchat' import { useDarkChatStore } from '@/stores/darkchat'
import { useEasyShareStore } from '@/stores/easyshare' import { useEasyShareStore } from '@/stores/easyshare'
@@ -63,6 +64,7 @@ import type {
DarkChatNotificationMode, DarkChatNotificationMode,
} from '@/types/darkchat' } from '@/types/darkchat'
import type { GifSearchResult } from '@/types/messages' import type { GifSearchResult } from '@/types/messages'
import type { EasySharePayload } from '@/types/easyshare'
import type { MediaType, PhoneMedia } from '@/types/media' import type { MediaType, PhoneMedia } from '@/types/media'
import { copyText } from '@/utils/clipboard' import { copyText } from '@/utils/clipboard'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
@@ -110,7 +112,8 @@ const identifier = ref('')
const pendingIdentifier = ref('') const pendingIdentifier = ref('')
const safetyOpen = ref(false) const safetyOpen = ref(false)
const draft = ref('') const draft = ref('')
const queuedShareBody = ref('') const queuedSharePayload = ref<EasySharePayload | null>(null)
const shareDraft = ref<EasySharePayload | null>(null)
const aliasDraft = ref('') const aliasDraft = ref('')
const contactAliasDraft = ref('') const contactAliasDraft = ref('')
const notificationMode = ref<DarkChatNotificationMode>('private') 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 === 'gif') return `GIF · ${t('gif')}`
if (conversation.lastMessageType === 'image') return `📷 ${t('photo')}` if (conversation.lastMessageType === 'image') return `📷 ${t('photo')}`
if (conversation.lastMessageType === 'video') return `${t('video')}` if (conversation.lastMessageType === 'video') return `${t('video')}`
if (conversation.lastMessageType === 'share') return `🔗 ${conversation.lastMessage}`
if (conversation.lastMessageType === 'system') return t('securityUpdate') if (conversation.lastMessageType === 'system') return t('securityUpdate')
return conversation.lastMessage return conversation.lastMessage
} }
@@ -330,9 +334,9 @@ async function openConversation(conversationId: string): Promise<void> {
} }
screen.value = 'thread' screen.value = 'thread'
resetPanels() resetPanels()
if (queuedShareBody.value) { if (queuedSharePayload.value) {
draft.value = queuedShareBody.value shareDraft.value = queuedSharePayload.value
queuedShareBody.value = '' queuedSharePayload.value = null
} }
await scrollBottom(false) await scrollBottom(false)
} }
@@ -344,6 +348,7 @@ function back(): void {
} }
if (screen.value === 'thread') darkchat.closeThread() if (screen.value === 'thread') darkchat.closeThread()
screen.value = 'inbox' screen.value = 'inbox'
shareDraft.value = null
resetPanels() resetPanels()
} }
@@ -411,19 +416,26 @@ async function confirmStart(): Promise<void> {
async function sendText(): Promise<void> { async function sendText(): Promise<void> {
const body = draft.value.trim() const body = draft.value.trim()
if (!body || sending.value) return if ((!body && !shareDraft.value) || sending.value) return
const shared = shareDraft.value
draft.value = '' draft.value = ''
shareDraft.value = null
const outgoingReply = replyTo.value?.id const outgoingReply = replyTo.value?.id
replyTo.value = null replyTo.value = null
resetPanels() resetPanels()
sending.value = true sending.value = true
const response = await darkchat.send({ const response = await darkchat.send({
body, body,
messageType: 'text', messageType: shared ? 'share' : 'text',
replyToId: outgoingReply, replyToId: outgoingReply,
sharePayload: shared ?? undefined,
}) })
sending.value = false 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() await scrollBottom()
} }
@@ -513,7 +525,7 @@ async function openEasyShareDraft(): Promise<boolean> {
darkchat.closeThread() darkchat.closeThread()
screen.value = 'inbox' screen.value = 'inbox'
resetPanels() resetPanels()
queuedShareBody.value = shared.body queuedSharePayload.value = shared.payload
draft.value = '' draft.value = ''
return true return true
} }
@@ -523,8 +535,9 @@ async function openEasyShareDraft(): Promise<boolean> {
} }
screen.value = 'thread' screen.value = 'thread'
resetPanels() resetPanels()
queuedShareBody.value = '' queuedSharePayload.value = null
draft.value = shared.body shareDraft.value = shared.payload
draft.value = ''
await scrollBottom(false) await scrollBottom(false)
return true return true
} }
@@ -2450,6 +2463,11 @@ onBeforeUnmount(() => {
v-else-if="message.messageType === 'voice'" v-else-if="message.messageType === 'voice'"
:message="message" :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-else class="dc-message-body">{{ message.body }}</span>
<span <span
v-if="Object.keys(message.reactions).length" v-if="Object.keys(message.reactions).length"
@@ -2542,6 +2560,19 @@ onBeforeUnmount(() => {
@close="emojiOpen = false" @close="emojiOpen = false"
@pick="appendEmoji" @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"> <div v-if="recording" class="dc-recorder">
<k-link <k-link
component="button" component="button"
@@ -2583,7 +2614,7 @@ onBeforeUnmount(() => {
></template> ></template>
<template #right <template #right
><k-link ><k-link
v-if="draft.trim()" v-if="draft.trim() || shareDraft"
component="button" component="button"
type="button" type="button"
icon-only 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 profilesSprite from '@/assets/img/flare/profiles-source.png'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue' import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue' import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import SharedContentCard from '@/components/SharedContentCard.vue'
import { useEasyShareStore } from '@/stores/easyshare' import { useEasyShareStore } from '@/stores/easyshare'
import { useFlareStore } from '@/stores/flare' import { useFlareStore } from '@/stores/flare'
import { useMessageMediaStore } from '@/stores/messageMedia' import { useMessageMediaStore } from '@/stores/messageMedia'
@@ -81,6 +82,7 @@ import type {
FlareProfileDraft, FlareProfileDraft,
} from '@/types/flare' } from '@/types/flare'
import type { PhoneMedia } from '@/types/media' import type { PhoneMedia } from '@/types/media'
import type { EasySharePayload } from '@/types/easyshare'
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages' import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
@@ -109,6 +111,7 @@ const router = useRouter()
const activeTab = ref<FlareTab>('discover') const activeTab = ref<FlareTab>('discover')
const activeMatch = ref<FlareMatch | null>(null) const activeMatch = ref<FlareMatch | null>(null)
const draft = ref('') const draft = ref('')
const shareDraft = ref<EasySharePayload | null>(null)
const matchReveal = ref<FlareMatch | null>(null) const matchReveal = ref<FlareMatch | null>(null)
const cardOffset = ref(0) const cardOffset = ref(0)
const currentPhotoIndex = ref(0) const currentPhotoIndex = ref(0)
@@ -596,13 +599,17 @@ async function openEasyShareDraft(): Promise<boolean> {
return true return true
} }
await openMatch(match) 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 return true
} }
function closeMatch(): void { function closeMatch(): void {
activeMatch.value = null activeMatch.value = null
draft.value = '' draft.value = ''
shareDraft.value = null
emojiOpen.value = false emojiOpen.value = false
attachmentMenuOpen.value = false attachmentMenuOpen.value = false
attachmentPicker.value = null attachmentPicker.value = null
@@ -617,18 +624,23 @@ async function openRevealedMatch(): Promise<void> {
async function sendMessage(): Promise<void> { async function sendMessage(): Promise<void> {
const body = draft.value.trim() 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 = '' draft.value = ''
shareDraft.value = null
emojiOpen.value = false emojiOpen.value = false
attachmentMenuOpen.value = false attachmentMenuOpen.value = false
attachmentPicker.value = null attachmentPicker.value = null
if ( if (
!(await flare.send(activeMatch.value.id, { !(await flare.send(
body, activeMatch.value.id,
messageType: 'text', shared
})) ? { body, messageType: 'share', sharePayload: shared }
: { body, messageType: 'text' },
))
) { ) {
draft.value = body draft.value = body
shareDraft.value = shared
showActionError() showActionError()
} }
await nextTick() await nextTick()
@@ -1027,7 +1039,12 @@ onBeforeUnmount(() => {
:text-footer="messageTime(message.createdAt)" :text-footer="messageTime(message.createdAt)"
> >
<template v-if="message.messageType !== 'text'" #text> <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> </template>
</k-message> </k-message>
</k-messages> </k-messages>
@@ -1115,6 +1132,13 @@ onBeforeUnmount(() => {
@pick="appendEmoji" @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 <k-messagebar
class="flare-messagebar messages-messagebar" class="flare-messagebar messages-messagebar"
:placeholder="phone.t('Apps.flare.messagePlaceholder')" :placeholder="phone.t('Apps.flare.messagePlaceholder')"
@@ -1143,7 +1167,7 @@ onBeforeUnmount(() => {
<k-link <k-link
component="button" component="button"
icon-only icon-only
:disabled="!draft.trim() || flare.sending" :disabled="(!draft.trim() && !shareDraft) || flare.sending"
@click="sendMessage" @click="sendMessage"
> >
<ArrowUpCircle :size="29" /> <ArrowUpCircle :size="29" />
+165 -13
View File
@@ -35,6 +35,7 @@ import {
Search, Search,
SquarePen, SquarePen,
Trash2, Trash2,
ContactRound,
UserPlus, UserPlus,
Video, Video,
X, X,
@@ -43,6 +44,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue' 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 FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue' import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue'
import { useCallsStore } from '@/stores/calls' import { useCallsStore } from '@/stores/calls'
@@ -56,7 +59,10 @@ import type {
SmsAttachmentType, SmsAttachmentType,
SmsConversation, SmsConversation,
SmsMessage, SmsMessage,
SmsSharedContact,
} from '@/types/messages' } 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_DURATION_MS = 30_000
const VOICE_MAX_BYTES = 135_000 const VOICE_MAX_BYTES = 135_000
@@ -74,14 +80,15 @@ const editingList = ref(false)
const selectedNumbers = ref<string[]>([]) const selectedNumbers = ref<string[]>([])
const composerNumber = ref('') const composerNumber = ref('')
const draft = ref('') const draft = ref('')
const queuedShareBody = ref('') const queuedSharePayload = ref<EasySharePayload | null>(null)
const shareDraft = ref<EasySharePayload | null>(null)
const composing = ref(false) const composing = ref(false)
const sending = ref(false) const sending = ref(false)
const toastOpened = ref(false) const toastOpened = ref(false)
const toastText = ref('') const toastText = ref('')
const emojiOpen = ref(false) const emojiOpen = ref(false)
const attachmentMenuOpen = ref(false) const attachmentMenuOpen = ref(false)
const attachmentPicker = ref<'gifs' | null>(null) const attachmentPicker = ref<'contacts' | 'gifs' | null>(null)
const contactDetailsOpen = ref(false) const contactDetailsOpen = ref(false)
const contactEditing = ref(false) const contactEditing = ref(false)
const contactNameDraft = ref('') const contactNameDraft = ref('')
@@ -169,6 +176,12 @@ function conversationPreview(conversation: SmsConversation): string {
if (conversation.lastMessageType === 'video') { if (conversation.lastMessageType === 'video') {
return `▶️ ${phone.t('Apps.messages.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' return conversation.lastMessageType === 'voice'
? `🎙️ ${phone.t('Apps.messages.voiceMessage')}` ? `🎙️ ${phone.t('Apps.messages.voiceMessage')}`
: conversation.lastMessage : conversation.lastMessage
@@ -286,6 +299,8 @@ function errorText(error?: string): string {
'invalid_message', 'invalid_message',
'invalid_voice', 'invalid_voice',
'invalid_attachment', 'invalid_attachment',
'invalid_contact',
'contact_not_found',
'media_provider_unconfigured', 'media_provider_unconfigured',
'capture_provider_unavailable', 'capture_provider_unavailable',
'capture_failed', 'capture_failed',
@@ -326,8 +341,8 @@ async function openConversation(conversation: SmsConversation): Promise<void> {
return return
} }
composing.value = false composing.value = false
draft.value = queuedShareBody.value shareDraft.value = queuedSharePayload.value
queuedShareBody.value = '' queuedSharePayload.value = null
await scrollToBottom(false) await scrollToBottom(false)
} }
@@ -337,7 +352,7 @@ async function openEasyShareDraft(): Promise<void> {
if (!shared.targetId) { if (!shared.targetId) {
messages.closeThread() messages.closeThread()
composing.value = false composing.value = false
queuedShareBody.value = shared.body queuedSharePayload.value = shared.payload
draft.value = '' draft.value = ''
return return
} }
@@ -346,8 +361,9 @@ async function openEasyShareDraft(): Promise<void> {
return return
} }
composing.value = false composing.value = false
queuedShareBody.value = '' queuedSharePayload.value = null
draft.value = shared.body shareDraft.value = shared.payload
draft.value = ''
await scrollToBottom(false) await scrollToBottom(false)
} }
@@ -382,6 +398,8 @@ async function chooseRecipient(number: string): Promise<void> {
return return
} }
composing.value = false composing.value = false
shareDraft.value = queuedSharePayload.value
queuedSharePayload.value = null
await scrollToBottom(false) await scrollToBottom(false)
} }
@@ -396,6 +414,7 @@ function goBack(): void {
composing.value = false composing.value = false
composerNumber.value = '' composerNumber.value = ''
draft.value = '' draft.value = ''
shareDraft.value = null
emojiOpen.value = false emojiOpen.value = false
attachmentMenuOpen.value = false attachmentMenuOpen.value = false
attachmentPicker.value = null attachmentPicker.value = null
@@ -460,6 +479,12 @@ function openGifPicker(): void {
if (!gifResults.value.length) void loadGifs(true) if (!gifResults.value.length) void loadGifs(true)
} }
function openContactPicker(): void {
attachmentMenuOpen.value = false
attachmentPicker.value = 'contacts'
emojiOpen.value = false
}
function openEmojiPicker(): void { function openEmojiPicker(): void {
attachmentMenuOpen.value = false attachmentMenuOpen.value = false
attachmentPicker.value = null attachmentPicker.value = null
@@ -498,6 +523,51 @@ async function sendAttachment(
await scrollToBottom() 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> { async function loadGifs(reset = false): Promise<void> {
if (gifLoading.value || (!reset && !gifHasMore.value)) return if (gifLoading.value || (!reset && !gifHasMore.value)) return
gifError.value = null gifError.value = null
@@ -534,17 +604,31 @@ function queueGifSearch(): void {
} }
async function sendTextMessage(): Promise<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 body = draft.value
const shared = shareDraft.value
draft.value = '' draft.value = ''
shareDraft.value = null
emojiOpen.value = false emojiOpen.value = false
attachmentMenuOpen.value = false attachmentMenuOpen.value = false
attachmentPicker.value = null attachmentPicker.value = null
sending.value = true sending.value = true
await scrollToBottom() 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 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() await scrollToBottom()
} }
@@ -1170,6 +1254,21 @@ onBeforeUnmount(() => {
v-if="message.message_type === 'voice'" v-if="message.message_type === 'voice'"
:message="message" :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" /> <MessageAttachmentBubble v-else :message="message" />
</template> </template>
</k-message> </k-message>
@@ -1189,6 +1288,10 @@ onBeforeUnmount(() => {
<span class="messages-action-emoji">😀</span> <span class="messages-action-emoji">😀</span>
{{ phone.t('Apps.messages.emoji') }} {{ phone.t('Apps.messages.emoji') }}
</button> </button>
<button type="button" @click="openContactPicker">
<span><ContactRound :size="20" /></span>
{{ phone.t('Apps.messages.shareContact') }}
</button>
<button type="button" @click="openGifPicker"> <button type="button" @click="openGifPicker">
<span><ImagePlay :size="20" /></span> <span><ImagePlay :size="20" /></span>
{{ phone.t('Apps.messages.attachGif') }} {{ phone.t('Apps.messages.attachGif') }}
@@ -1202,13 +1305,51 @@ onBeforeUnmount(() => {
<section v-if="attachmentPicker" class="messages-media-picker"> <section v-if="attachmentPicker" class="messages-media-picker">
<header> <header>
<strong> <strong>
{{ phone.t('Apps.messages.gifs') }} {{
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
}}
</strong> </strong>
<button type="button" @click="attachmentPicker = null"> <button type="button" @click="attachmentPicker = null">
{{ phone.t('Common.done') }} {{ phone.t('Common.done') }}
</button> </button>
</header> </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"> <label class="messages-gif-search">
<Search :size="15" /> <Search :size="15" />
<input <input
@@ -1252,6 +1393,17 @@ onBeforeUnmount(() => {
@pick="appendEmoji" @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"> <section v-if="recording" class="messages-recorder">
<button <button
type="button" type="button"
@@ -1305,7 +1457,7 @@ onBeforeUnmount(() => {
<template #right> <template #right>
<k-toolbar-pane class="ios:h-10"> <k-toolbar-pane class="ios:h-10">
<k-link <k-link
v-if="draft.trim()" v-if="draft.trim() || shareDraft"
component="button" component="button"
icon-only icon-only
:disabled="sending" :disabled="sending"
+302 -20
View File
@@ -1534,6 +1534,56 @@ const smsMessages = [
recipient_number: '5551234567', recipient_number: '5551234567',
sender_number: '5551110001', 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 = { const darkChatProfile = {
id: 1, id: 1,
@@ -1613,6 +1663,27 @@ const darkChatMessages = [
createdAt: '2026-08-06 22:44:00', createdAt: '2026-08-06 22:44:00',
readAt: null, 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() { function darkChatBootstrap() {
@@ -2478,9 +2549,9 @@ const flareMatches = [
lookingFor: 'dates', lookingFor: 'dates',
photoUrls: [], photoUrls: [],
}, },
lastMessage: 'That place sounds perfect. Friday?', lastMessage: 'Friday night jazz',
lastMessageAt: isoTime(-38 * 60 * 1000), lastMessageAt: isoTime(-18 * 60 * 1000),
lastMessageType: 'text', lastMessageType: 'share',
unread: 1, unread: 1,
}, },
] ]
@@ -2506,6 +2577,25 @@ const flareMessages = {
mediaUrl: null, mediaUrl: null,
messageType: 'text', 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 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' 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) => { app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body) console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint const endpoint = request.params.endpoint
@@ -3996,7 +4227,9 @@ app.post('/api/:endpoint', (request, response) => {
!match || !match ||
(messageType === 'text' (messageType === 'text'
? !body ? !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' }) response.json({ success: false, error: 'invalid_message' })
return return
@@ -4004,11 +4237,18 @@ app.post('/api/:endpoint', (request, response) => {
const message = { const message = {
id: `flare-message-${Date.now()}`, id: `flare-message-${Date.now()}`,
direction: 'sent', direction: 'sent',
body: messageType === 'text' ? body : '', body:
messageType === 'share'
? body || request.body.sharePayload.title
: messageType === 'text'
? body
: '',
createdAt: Date.now(), createdAt: Date.now(),
mediaDurationMs: request.body.mediaDurationMs ?? null, mediaDurationMs: request.body.mediaDurationMs ?? null,
mediaUrl: messageType === 'text' ? null : mediaUrl, mediaUrl: messageType === 'text' ? null : mediaUrl,
messageType, messageType,
sharePayload:
messageType === 'share' ? request.body.sharePayload : null,
} }
flareMessages[match.id] ??= [] flareMessages[match.id] ??= []
flareMessages[match.id].push(message) flareMessages[match.id].push(message)
@@ -5330,7 +5570,10 @@ app.post('/api/:endpoint', (request, response) => {
} }
const messageType = request.body.messageType ?? 'text' const messageType = request.body.messageType ?? 'text'
const body = String(request.body.body ?? '') 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' }) response.json({ success: false, error: 'invalid_message' })
return return
} }
@@ -5343,7 +5586,10 @@ app.post('/api/:endpoint', (request, response) => {
direction: 'sent', direction: 'sent',
senderProfileId: darkChatProfile.id, senderProfileId: darkChatProfile.id,
messageType, messageType,
body, body:
messageType === 'share'
? body.trim() || request.body.sharePayload.title
: body,
mediaPayload: mediaPayload:
messageType === 'gif' ? request.body.mediaPayload : undefined, messageType === 'gif' ? request.body.mediaPayload : undefined,
mediaSecret: mediaSecret:
@@ -5354,6 +5600,8 @@ app.post('/api/:endpoint', (request, response) => {
replyToId: request.body.replyToId, replyToId: request.body.replyToId,
replyBody: reply?.body, replyBody: reply?.body,
reactions: {}, reactions: {},
sharePayload:
messageType === 'share' ? request.body.sharePayload : null,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '), createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
readAt: null, readAt: null,
} }
@@ -5494,17 +5742,15 @@ app.post('/api/:endpoint', (request, response) => {
return return
} }
if (endpoint === 'easyshare:bootstrap') { if (endpoint === 'easyshare:bootstrap') {
const history = easyShareHistoryForScenario(testScenario)
response.json({ response.json({
success: true, success: true,
data: { data: {
history: easyShareHistory, history,
pending: easyShareHistory.filter((item) => pending: history.filter((item) =>
['pending', 'transferring'].includes(item.status), ['pending', 'transferring'].includes(item.status),
), ),
targets: [ targets: testScenario === 'easyshare-empty' ? [] : easyShareTargets,
{ distance: 2.4, id: 41, name: 'Mia Santos' },
{ distance: 7.8, id: 72, name: 'Noah Walker' },
],
visibility: easyShareVisibility, visibility: easyShareVisibility,
}, },
}) })
@@ -5516,21 +5762,26 @@ app.post('/api/:endpoint', (request, response) => {
return return
} }
if (endpoint === 'easyshare:request') { if (endpoint === 'easyshare:request') {
const target = easyShareTargets.find(
(candidate) => candidate.id === Number(request.body.targetId),
)
const transfer = { const transfer = {
createdAt: Date.now(), createdAt: Date.now(),
direction: 'outgoing', direction: 'outgoing',
id: `easyshare-${Date.now()}`, id: `easyshare-${Date.now()}`,
otherName: request.body.targetId === 72 ? 'Noah Walker' : 'Mia Santos', otherName: target?.name ?? 'Unknown device',
payload: request.body.payload, payload: request.body.payload,
progress: 0, progress: target?.id === 72 ? 58 : 0,
status: 'pending', status: target?.id === 72 ? 'transferring' : 'pending',
} }
easyShareHistory.unshift(transfer) easyShareHistory.unshift(transfer)
response.json({ success: true, data: transfer }) response.json({ success: true, data: transfer })
return return
} }
if (endpoint === 'easyshare:respond' || endpoint === 'easyshare:cancel') { 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) { if (!transfer) {
response.json({ success: false, error: 'transfer_not_found' }) response.json({ success: false, error: 'transfer_not_found' })
return return
@@ -5541,7 +5792,8 @@ app.post('/api/:endpoint', (request, response) => {
: request.body.accepted : request.body.accepted
? 'completed' ? 'completed'
: 'declined' : 'declined'
transfer.progress = transfer.status === 'completed' ? 100 : transfer.progress transfer.progress =
transfer.status === 'completed' ? 100 : transfer.progress
response.json({ success: true, data: transfer }) response.json({ success: true, data: transfer })
return return
} }
@@ -5608,6 +5860,8 @@ app.post('/api/:endpoint', (request, response) => {
success: true, success: true,
data: thread.map(({ media_payload, ...message }) => ({ data: thread.map(({ media_payload, ...message }) => ({
...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_asset_id: ['image', 'gif', 'video'].includes(message.message_type)
? media_payload ? media_payload
: null, : null,
@@ -5637,6 +5891,10 @@ app.post('/api/:endpoint', (request, response) => {
const phoneNumber = String(request.body.phoneNumber ?? '') const phoneNumber = String(request.body.phoneNumber ?? '')
const messageType = request.body.messageType ?? 'text' const messageType = request.body.messageType ?? 'text'
const isAttachment = ['image', 'gif', 'video'].includes(messageType) 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 requestedAttachmentId = String(request.body.mediaAssetId ?? '')
const selectedMedia = /^\d+$/.test(requestedAttachmentId) const selectedMedia = /^\d+$/.test(requestedAttachmentId)
? mockMedia.find((item) => String(item.id) === requestedAttachmentId) ? mockMedia.find((item) => String(item.id) === requestedAttachmentId)
@@ -5646,6 +5904,8 @@ app.post('/api/:endpoint', (request, response) => {
!phoneNumber || !phoneNumber ||
(messageType === 'text' && !body) || (messageType === 'text' && !body) ||
(messageType === 'voice' && !request.body.mediaPayload) || (messageType === 'voice' && !request.body.mediaPayload) ||
(messageType === 'contact' && !selectedContact) ||
(messageType === 'share' && !request.body.sharePayload) ||
(isAttachment && (isAttachment &&
!attachmentAssets[messageType].has(attachmentId) && !attachmentAssets[messageType].has(attachmentId) &&
!attachmentId.startsWith('https://') && !attachmentId.startsWith('https://') &&
@@ -5655,7 +5915,19 @@ app.post('/api/:endpoint', (request, response) => {
return return
} }
const message = { 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', ' '), created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
direction: 'sent', direction: 'sent',
id: `sms-${Date.now()}`, id: `sms-${Date.now()}`,
@@ -5675,6 +5947,15 @@ app.post('/api/:endpoint', (request, response) => {
media_payload: media_payload:
messageType === 'voice' messageType === 'voice'
? request.body.mediaPayload ? 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 : isAttachment
? attachmentId ? attachmentId
: null, : null,
@@ -5685,6 +5966,7 @@ app.post('/api/:endpoint', (request, response) => {
read_at: null, read_at: null,
recipient_number: phoneNumber, recipient_number: phoneNumber,
sender_number: '5551234567', sender_number: '5551234567',
share: messageType === 'share' ? request.body.sharePayload : null,
} }
smsMessages.push(message) smsMessages.push(message)
const { media_payload, ...publicMessage } = 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", name = "Messages", newMessage = "New message from {sender}", compose = "New Message",
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details", search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS", 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", 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", 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", moreActions = "More Actions", contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number",
call = "Call", messageAction = "Message", addContact = "Add Contact", deleteContact = "Delete Contact", call = "Call", messageAction = "Message", addContact = "Add Contact", deleteContact = "Delete Contact",
@@ -363,7 +364,8 @@ Locales["en"] = {
noResults = "No Results", today = "Today", yesterday = "Yesterday", noResults = "No Results", today = "Today", yesterday = "Yesterday",
errors = { errors = {
invalid_number = "Enter a valid phone number.", invalid_message = "Enter a message.", invalid_voice = "The audio message is invalid.", 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.", media_provider_unconfigured = "Photo uploads are not configured.",
capture_provider_unavailable = "The screenshot resource is unavailable.", capture_failed = "The photo could not be captured.", 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.", 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", name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.", destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
shareProfile = "Share Profile", chooseConversation = "Choose a conversation", 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", savedToNotes = "Saved to Notes.", copied = "Copied.", copy = "Copy", copyLink = "Copy Link",
nearby = "People Nearby", history = "Transfer History", noHistory = "No transfers yet.", nearby = "People Nearby", history = "Transfer History", noHistory = "No transfers yet.",
noNearby = "No visible players are nearby.", visibility = "Visibility", requestSent = "Waiting for acceptance...", 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 deleted_everywhere = row.deleted_for_everyone == 1 or row.deleted_for_everyone == true
local reactions = decode_array(row.reactions, "reactions") local reactions = decode_array(row.reactions, "reactions")
local waveform = row.media_waveform and decode_array(row.media_waveform, "media_waveform") or nil 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 { return {
id = row.id, id = row.id,
conversationId = row.conversation_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, messageType = deleted_everywhere and "system" or row.message_type,
body = deleted_everywhere and "message_deleted" or row.body, body = deleted_everywhere and "message_deleted" or row.body,
mediaMime = deleted_everywhere and nil or row.media_mime, 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), mediaDurationMs = deleted_everywhere and nil or tonumber(row.media_duration_ms),
mediaWaveform = deleted_everywhere and nil or waveform, mediaWaveform = deleted_everywhere and nil or waveform,
replyToId = row.reply_to_id, replyToId = row.reply_to_id,
replyBody = row.reply_body, replyBody = row.reply_body,
reactions = reactions, reactions = reactions,
sharePayload = not deleted_everywhere and share_payload or nil,
expiresAt = row.expires_at, expiresAt = row.expires_at,
createdAt = row.created_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, 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 end
media_payload = media_url media_payload = media_url
media_mime = message_type == "image" and "image/jpeg" or "video/mp4" 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 else
return { success = false, error = "invalid_message" } return { success = false, error = "invalid_message" }
end end
@@ -579,7 +596,8 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
conversationId = conversation_id, conversationId = conversation_id,
sender = profile.alias, sender = profile.alias,
messageType = message_type, 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 } return { success = true, data = message }
end) 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 = "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 = "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 = "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 = "body", type = "VARCHAR(2000) NOT NULL" },
{ name = "media_payload", type = "MEDIUMTEXT NULL" }, { name = "media_payload", type = "MEDIUMTEXT NULL" },
{ name = "media_mime", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" }, { 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 = "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 = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_profile_id", type = "BIGINT UNSIGNED NULL" }, { 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 = "body", type = "TEXT NOT NULL" },
{ name = "media_payload", type = "LONGTEXT NULL" }, { name = "media_payload", type = "LONGTEXT NULL" },
{ name = "media_mime", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, { 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 = "match_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_account_id", type = "BIGINT UNSIGNED NOT NULL" }, { name = "sender_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "body", type = "VARCHAR(1000) 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 = "media_url", type = "VARCHAR(2048) NULL" },
{ name = "share_payload", type = "LONGTEXT NULL" },
{ name = "media_duration_ms", type = "INT UNSIGNED NULL" }, { name = "media_duration_ms", type = "INT UNSIGNED NULL" },
{ name = "read_at", type = "DATETIME NULL" }, { name = "read_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
@@ -2173,15 +2174,15 @@ Bridge.Database.Query([[
]], {}) ]], {})
Bridge.Database.Query([[ Bridge.Database.Query([[
ALTER TABLE `sky_phone_flare_messages` 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([[ Bridge.Database.Query([[
ALTER TABLE `sky_phone_sms_messages` 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([[ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages` 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([[ Bridge.Database.Query([[
ALTER TABLE `sky_phone_marketplace_images` ALTER TABLE `sky_phone_marketplace_images`
+10
View File
@@ -1,3 +1,5 @@
SkyPhoneEasyShare = {}
Bridge.Database.AfterMigration("sky_phone", function() Bridge.Database.AfterMigration("sky_phone", function()
local active_transfers = {} local active_transfers = {}
local valid_kinds = { local valid_kinds = {
@@ -259,6 +261,14 @@ local function sanitize_payload(source, device, data)
return payload, nil, encoded return payload, nil, encoded
end 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 function transfer_for_source(transfer, source)
local incoming = transfer.recipient_source == source local incoming = transfer.recipient_source == source
return { return {
+27 -5
View File
@@ -2,7 +2,7 @@ Bridge.Database.AfterMigration("sky_phone", function()
local genders = { woman = true, man = true, nonbinary = true } local genders = { woman = true, man = true, nonbinary = true }
local interests = { woman = true, man = true, nonbinary = true, everyone = true } local interests = { woman = true, man = true, nonbinary = true, everyone = true }
local looking_for = { longTerm = true, dates = true, friends = 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) local function allowed_gif_url(value)
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then 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 } return { body = body, message_type = message_type }
end 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 if type(data.mediaAssetId) ~= "string" then
return nil, "invalid_attachment" return nil, "invalid_attachment"
end end
@@ -427,6 +443,10 @@ local function validate_message(source, data)
end end
local function message_payload(row, account_id) 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 { return {
id = row.id, id = row.id,
direction = tonumber(row.sender_account_id) == tonumber(account_id) and "sent" or "received", 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", messageType = row.message_type or "text",
mediaUrl = row.media_url, mediaUrl = row.media_url,
mediaDurationMs = tonumber(row.media_duration_ms), mediaDurationMs = tonumber(row.media_duration_ms),
sharePayload = share_payload,
} }
end 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 WHERE `match_id` = ? AND `sender_account_id` <> ? AND `read_at` IS NULL
]], { match.id, account.id }) ]], { match.id, account.id })
local rows = Bridge.Database.Query([[ 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` `media_duration_ms`, UNIX_TIMESTAMP(`created_at`) * 1000 AS `created_at_ms`
FROM `sky_phone_flare_messages` FROM `sky_phone_flare_messages`
WHERE `match_id` = ? WHERE `match_id` = ?
@@ -705,11 +726,11 @@ Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
Bridge.Database.Query([[ Bridge.Database.Query([[
INSERT INTO `sky_phone_flare_messages` INSERT INTO `sky_phone_flare_messages`
(`id`, `match_id`, `sender_account_id`, `body`, `message_type`, `media_url`, (`id`, `match_id`, `sender_account_id`, `body`, `message_type`, `media_url`,
`media_duration_ms`) `media_duration_ms`, `share_payload`)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
]], { ]], {
id, match.id, account.id, message.body, message.message_type, message.media_url, 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) local recipient_account_id = tonumber(match.account_a_id) == tonumber(account.id)
and tonumber(match.account_b_id) or tonumber(match.account_a_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, message_type = message.message_type,
media_url = message.media_url, media_url = message.media_url,
media_duration_ms = message.media_duration_ms, media_duration_ms = message.media_duration_ms,
share_payload = message.share_payload,
}, account.id), }, account.id),
} }
end) end)
+100 -1
View File
@@ -97,9 +97,59 @@ local function current_device(source)
return device return device
end 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) local function format_message(row)
row.media_duration_ms = tonumber(row.media_duration_ms) row.media_duration_ms = tonumber(row.media_duration_ms)
row.media_asset_id = nil row.media_asset_id = nil
row.contact = nil
row.share = nil
if row.message_type == "voice" then if row.message_type == "voice" then
local waveform = row.media_waveform and json.decode(row.media_waveform) or nil local waveform = row.media_waveform and json.decode(row.media_waveform) or nil
if type(waveform) ~= "table" then if type(waveform) ~= "table" then
@@ -107,6 +157,33 @@ local function format_message(row)
end end
row.media_waveform = waveform row.media_waveform = waveform
row.media_payload = nil 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 elseif attachment_assets[row.message_type] then
if not valid_stored_attachment(row.message_type, row.media_payload) 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))) 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 body = trim(data.body) or ""
local voice = nil local voice = nil
local attachment = nil local attachment = nil
local contact = nil
local share = nil
if message_type == "text" then if message_type == "text" then
if body == "" or #body > Config.Messages.BodyMaxLength then if body == "" or #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" } 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" } return { success = false, error = "invalid_voice" }
end end
body = "" 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 elseif attachment_assets[message_type] then
attachment = validate_attachment(source, device, message_type, data) attachment = validate_attachment(source, device, message_type, data)
if not attachment then if not attachment then
@@ -459,7 +557,8 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
number, number,
message_type, message_type,
body, 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.mime or attachment and attachment.mime or nil,
voice and voice.duration or attachment and attachment.duration or nil, voice and voice.duration or attachment and attachment.duration or nil,
voice and voice.waveform 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, `recipient_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`sender_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT 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, `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, `body` VARCHAR(2000) NOT NULL,
`media_payload` MEDIUMTEXT NULL, `media_payload` MEDIUMTEXT NULL,
`media_mime` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci 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, `match_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_account_id` BIGINT UNSIGNED NOT NULL, `sender_account_id` BIGINT UNSIGNED NOT NULL,
`body` VARCHAR(1000) 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, `media_url` VARCHAR(2048) NULL,
`share_payload` LONGTEXT NULL,
`media_duration_ms` INT UNSIGNED NULL, `media_duration_ms` INT UNSIGNED NULL,
`read_at` DATETIME NULL, `read_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,