mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ADD - share contacts and rich content in chats
This commit is contained in:
@@ -224,6 +224,19 @@ From `frontend/`, run `pnpm dev` for browser development. The phone opens automa
|
||||
- Profile onboarding: `http://localhost:5174/?apiPort=3002&testScenario=feather-onboarding#/apps/feather`
|
||||
- Empty states: `http://localhost:5174/?apiPort=3002&testScenario=feather-empty#/apps/feather`
|
||||
|
||||
EasyShare browser data is available from every app that exposes a share action. Open the seeded
|
||||
Gallery directly, share an item, and then use the EasyShare and History actions in the sheet:
|
||||
|
||||
- Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired,
|
||||
and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/gallery`
|
||||
- Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/gallery`
|
||||
- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/gallery`
|
||||
- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/apps/gallery`
|
||||
|
||||
In the full scenario, sending to Mia or Jamie creates a pending transfer. Sending to Noah creates a
|
||||
transfer at 58 percent so the progress and cancel states can be tested. Visibility changes and
|
||||
accepting or declining the seeded incoming request are kept in memory until the mock server restarts.
|
||||
|
||||
The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging.
|
||||
|
||||
`pnpm build` uses `build.cjs` to replace `sky_phone/source/html` deterministically with the Vite output. Production assets use relative paths so they work through the FiveM NUI protocol.
|
||||
|
||||
@@ -4895,6 +4895,53 @@ button {
|
||||
gap: 7px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.shared-composer-preview {
|
||||
position: fixed;
|
||||
z-index: 43;
|
||||
right: 12px;
|
||||
bottom: 65px;
|
||||
left: 12px;
|
||||
padding: 7px;
|
||||
border: 1px solid rgb(255 255 255 / 72%);
|
||||
border-radius: 19px;
|
||||
background: rgb(242 242 247 / 90%);
|
||||
box-shadow: 0 10px 28px rgb(20 44 76 / 18%);
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
}
|
||||
.shared-composer-preview > button {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
background: rgb(60 60 67 / 78%);
|
||||
}
|
||||
.shared-composer-preview--dark {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
z-index: 2;
|
||||
margin: 0 8px 7px;
|
||||
flex: 0 0 auto;
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(20 18 27 / 94%);
|
||||
box-shadow: 0 8px 22px rgb(0 0 0 / 28%);
|
||||
}
|
||||
.phone-app.dark .shared-composer-preview {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(36 36 40 / 92%);
|
||||
}
|
||||
.messages-media-picker__contacts {
|
||||
height: 270px;
|
||||
margin: 0;
|
||||
padding: 8px 0 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.messages-media-picker__gifs button {
|
||||
min-height: 83px;
|
||||
display: flex;
|
||||
@@ -6106,6 +6153,18 @@ button {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
.messages-thread-page .messages-attachment-menu > button:nth-child(4) > span {
|
||||
background: #e9f9ed;
|
||||
color: #28a745;
|
||||
}
|
||||
.messages-thread-page .messages-attachment-menu > button:nth-child(5) > span {
|
||||
background: #f6eefe;
|
||||
color: #af52de;
|
||||
}
|
||||
.messages-thread-page .messages-attachment-menu > button:nth-child(6) > span {
|
||||
background: #fff0ef;
|
||||
color: #ff3b30;
|
||||
}
|
||||
.messages-thread-page
|
||||
.messages-messagebar
|
||||
> .k-toolbar
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { kButton } from 'konsta/vue'
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
MessageCircle,
|
||||
UserRound,
|
||||
UserPlus,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SmsSharedContact } from '@/types/messages'
|
||||
|
||||
const props = defineProps<{
|
||||
addLabel: string
|
||||
contact: SmsSharedContact
|
||||
messageLabel: string
|
||||
saved: boolean
|
||||
savedLabel: string
|
||||
}>()
|
||||
const emit = defineEmits<{ message: []; save: [] }>()
|
||||
const imageFailed = ref(false)
|
||||
watch(
|
||||
() => props.contact.avatar_url,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
},
|
||||
)
|
||||
const displayName = computed(
|
||||
() => props.contact.name.trim() || props.contact.phone_number,
|
||||
)
|
||||
const showNumber = computed(() => Boolean(props.contact.name.trim()))
|
||||
const initials = computed(() =>
|
||||
displayName.value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join(''),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-contact-card">
|
||||
<div class="message-contact-card__identity">
|
||||
<span class="message-contact-card__avatar">
|
||||
<img
|
||||
v-if="contact.avatar_url && !imageFailed"
|
||||
:src="contact.avatar_url"
|
||||
alt=""
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<b v-else-if="initials">{{ initials }}</b>
|
||||
<UserRound v-else :size="28" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ displayName }}</strong>
|
||||
<small v-if="contact.organization">{{ contact.organization }}</small>
|
||||
<small v-if="showNumber">({{ contact.phone_number }})</small>
|
||||
</div>
|
||||
<ChevronRight :size="20" class="message-contact-card__chevron" />
|
||||
</div>
|
||||
<div class="message-contact-card__actions">
|
||||
<k-button rounded tonal @click.stop="emit('message')">
|
||||
<MessageCircle :size="17" />
|
||||
{{ messageLabel }}
|
||||
</k-button>
|
||||
<k-button
|
||||
rounded
|
||||
tonal
|
||||
:disabled="saved"
|
||||
@click.stop="emit('save')"
|
||||
>
|
||||
<Check v-if="saved" :size="17" />
|
||||
<UserPlus v-else :size="17" />
|
||||
{{ saved ? savedLabel : addLabel }}
|
||||
</k-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-contact-card {
|
||||
width: min(258px, 72cqw);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 48%);
|
||||
border-radius: 21px;
|
||||
color: #151517;
|
||||
background: linear-gradient(155deg, rgb(255 255 255 / 96%), #edf5ff);
|
||||
box-shadow: 0 8px 24px rgb(22 63 112 / 14%);
|
||||
}
|
||||
|
||||
.message-contact-card__identity {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-height: 82px;
|
||||
padding: 13px 12px;
|
||||
}
|
||||
|
||||
.message-contact-card__avatar {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
background: linear-gradient(145deg, #64d2ff, #0a84ff);
|
||||
box-shadow: 0 4px 14px rgb(10 132 255 / 24%);
|
||||
}
|
||||
|
||||
.message-contact-card__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-contact-card__identity b {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message-contact-card__identity div,
|
||||
.message-contact-card__identity strong,
|
||||
.message-contact-card__identity small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-contact-card__identity strong,
|
||||
.message-contact-card__identity small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-contact-card__identity strong {
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.message-contact-card__identity small {
|
||||
margin-top: 3px;
|
||||
color: #6e6e73;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.message-contact-card__chevron {
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.message-contact-card__actions {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid rgb(60 60 67 / 12%);
|
||||
background: rgb(255 255 255 / 58%);
|
||||
}
|
||||
|
||||
.message-contact-card__actions :deep(.button) {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
justify-content: flex-start;
|
||||
padding-inline: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .message-contact-card {
|
||||
color: #f7f7f7;
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: linear-gradient(155deg, #34363b, #242a33);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .message-contact-card__identity small {
|
||||
color: #aeaeb2;
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .message-contact-card__actions {
|
||||
border-top-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(0 0 0 / 10%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { getPhoneApp } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean
|
||||
payload: EasySharePayload
|
||||
variant?: 'darkchat' | 'flare' | 'messages'
|
||||
}>(),
|
||||
{ compact: false, variant: 'messages' },
|
||||
)
|
||||
const phone = usePhoneStore()
|
||||
const imageFailed = ref(false)
|
||||
const sourceApp = computed(() => getPhoneApp(props.payload.appId))
|
||||
const isVideo = computed(
|
||||
() =>
|
||||
props.payload.kind === 'video' ||
|
||||
(props.payload.appId === 'fliptok' && props.payload.kind === 'post'),
|
||||
)
|
||||
const fallbackIcon = computed(() => {
|
||||
if (props.payload.kind === 'location') return MapPin
|
||||
if (props.payload.kind === 'profile') return UserRound
|
||||
if (props.payload.kind === 'track' || props.payload.kind === 'playlist') {
|
||||
return Music2
|
||||
}
|
||||
if (props.payload.kind === 'video') return Play
|
||||
return Image
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.payload.imageUrl,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="shared-content-card"
|
||||
:class="[
|
||||
`shared-content-card--${variant}`,
|
||||
{ 'shared-content-card--compact': compact },
|
||||
]"
|
||||
>
|
||||
<div class="shared-content-card__source">
|
||||
<img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" />
|
||||
<span>
|
||||
<b>{{ sourceApp ? phone.t(sourceApp.labelKey) : payload.appId }}</b>
|
||||
<small>{{ phone.t(`Apps.easyShare.kinds.${payload.kind}`) }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shared-content-card__media">
|
||||
<video
|
||||
v-if="payload.imageUrl && !imageFailed && isVideo"
|
||||
:src="payload.imageUrl"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<img
|
||||
v-else-if="payload.imageUrl && !imageFailed"
|
||||
:src="payload.imageUrl"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<span v-else><component :is="fallbackIcon" :size="30" /></span>
|
||||
</div>
|
||||
<div class="shared-content-card__copy">
|
||||
<small v-if="payload.subtitle">{{ payload.subtitle }}</small>
|
||||
<strong>{{ payload.title }}</strong>
|
||||
<p v-if="payload.copyText !== payload.title">{{ payload.copyText }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-content-card {
|
||||
width: min(262px, 73cqw);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(60 60 67 / 13%);
|
||||
border-radius: 20px;
|
||||
color: #171719;
|
||||
background: rgb(255 255 255 / 96%);
|
||||
box-shadow: 0 8px 24px rgb(19 45 78 / 13%);
|
||||
}
|
||||
|
||||
.shared-content-card__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.shared-content-card__source > img {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shared-content-card__source b,
|
||||
.shared-content-card__source small {
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.shared-content-card__source b {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shared-content-card__source small {
|
||||
margin-top: 2px;
|
||||
color: #8e8e93;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.shared-content-card__media {
|
||||
height: 142px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, #dcecff, #b9d5ff);
|
||||
}
|
||||
|
||||
.shared-content-card__media > img,
|
||||
.shared-content-card__media > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shared-content-card__media > span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.shared-content-card__copy {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.shared-content-card__copy small,
|
||||
.shared-content-card__copy strong,
|
||||
.shared-content-card__copy p {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.shared-content-card__copy small {
|
||||
margin-bottom: 3px;
|
||||
color: #0a84ff;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.shared-content-card__copy strong {
|
||||
display: -webkit-box;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.shared-content-card__copy p {
|
||||
display: -webkit-box;
|
||||
margin-top: 5px;
|
||||
color: #636366;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.shared-content-card--compact {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
border-radius: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__source {
|
||||
grid-column: 2;
|
||||
padding: 7px 9px 0;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__media {
|
||||
grid-row: 1 / span 2;
|
||||
height: 78px;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__copy {
|
||||
grid-column: 2;
|
||||
padding: 5px 9px 8px;
|
||||
}
|
||||
|
||||
.shared-content-card--compact .shared-content-card__copy p,
|
||||
.shared-content-card--compact .shared-content-card__source small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
color: #f5f5f7;
|
||||
background: linear-gradient(155deg, #302c3d, #1b1922);
|
||||
box-shadow: 0 9px 25px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat .shared-content-card__media {
|
||||
background: linear-gradient(145deg, #392f5c, #201c31);
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat .shared-content-card__media > span,
|
||||
.shared-content-card--darkchat .shared-content-card__copy small {
|
||||
color: #bf9cff;
|
||||
}
|
||||
|
||||
.shared-content-card--darkchat .shared-content-card__copy p {
|
||||
color: #beb9c8;
|
||||
}
|
||||
|
||||
.shared-content-card--flare {
|
||||
border-color: rgb(255 255 255 / 58%);
|
||||
background: linear-gradient(155deg, #fff, #fff0f6);
|
||||
box-shadow: 0 8px 24px rgb(245 71 132 / 15%);
|
||||
}
|
||||
|
||||
.shared-content-card--flare .shared-content-card__media {
|
||||
background: linear-gradient(145deg, #ffd4e4, #ffc1d5);
|
||||
}
|
||||
|
||||
.shared-content-card--flare .shared-content-card__media > span,
|
||||
.shared-content-card--flare .shared-content-card__copy small {
|
||||
color: #ef3f7c;
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .shared-content-card--messages {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
color: #f5f5f7;
|
||||
background: linear-gradient(155deg, #34363b, #242a33);
|
||||
}
|
||||
|
||||
:global(.phone-app.dark) .shared-content-card--messages .shared-content-card__copy p {
|
||||
color: #b8b8bd;
|
||||
}
|
||||
</style>
|
||||
@@ -95,6 +95,8 @@ export const useDarkChatStore = defineStore('darkchat', () => {
|
||||
messageType: outgoing.messageType,
|
||||
reactions: {},
|
||||
replyToId: outgoing.replyToId,
|
||||
sharePayload:
|
||||
outgoing.messageType === 'share' ? outgoing.sharePayload : null,
|
||||
}
|
||||
messages.value.push(optimistic)
|
||||
if (outgoing.messageType === 'voice' && outgoing.mediaPayload) {
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('easyshare store', () => {
|
||||
expect(easyShare.consumeChatDraft('messages')).toEqual({
|
||||
appId: 'messages',
|
||||
body: 'Meet at Mission Row.\nhttps://notes.sky/note-1',
|
||||
payload: { ...payload, link: 'https://notes.sky/note-1' },
|
||||
targetId: '5551234567',
|
||||
})
|
||||
expect(easyShare.consumeChatDraft('messages')).toBeNull()
|
||||
@@ -98,6 +99,7 @@ describe('easyshare store', () => {
|
||||
expect(easyShare.consumeChatDraft('darkchat')).toEqual({
|
||||
appId: 'darkchat',
|
||||
body: 'Meet at Mission Row.',
|
||||
payload,
|
||||
targetId: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ export const useEasyShareStore = defineStore('easyshare', () => {
|
||||
chatDraft.value = {
|
||||
appId,
|
||||
body: [...new Set(parts)].join('\n'),
|
||||
payload: { ...payload.value },
|
||||
targetId,
|
||||
}
|
||||
return Boolean(chatDraft.value.body)
|
||||
|
||||
@@ -78,6 +78,47 @@ describe('messages store', () => {
|
||||
expect(messages.messages[0].delivery_status).toBe('failed')
|
||||
})
|
||||
|
||||
it('shows a shared contact immediately and sends only its id', async () => {
|
||||
const contact = {
|
||||
avatar_url: 'https://picsum.photos/seed/shared-alex/240/240',
|
||||
name: 'Alex Rivera',
|
||||
organization: 'Maze Bank',
|
||||
phone_number: '4205550137',
|
||||
}
|
||||
const serverMessage: SmsMessage = {
|
||||
...sentMessage('contact-server-id'),
|
||||
body: contact.name,
|
||||
contact,
|
||||
message_type: 'contact',
|
||||
}
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: serverMessage, success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
|
||||
const messages = useMessagesStore()
|
||||
await messages.openThread('4205550196')
|
||||
const sending = messages.send({
|
||||
contact,
|
||||
contactId: 'contact-alex',
|
||||
messageType: 'contact',
|
||||
})
|
||||
|
||||
expect(messages.messages[0]).toMatchObject({
|
||||
contact,
|
||||
delivery_status: 'sending',
|
||||
message_type: 'contact',
|
||||
})
|
||||
|
||||
await sending
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'messages:send', {
|
||||
contactId: 'contact-alex',
|
||||
messageType: 'contact',
|
||||
phoneNumber: '4205550196',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes numeric phone data from the NUI boundary', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: [
|
||||
|
||||
@@ -57,8 +57,12 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
if (!activeNumber.value) return { success: false, error: 'invalid_number' }
|
||||
const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const optimistic: SmsMessage = {
|
||||
body: outgoing.messageType === 'text' ? outgoing.body.trim() : '',
|
||||
body:
|
||||
outgoing.messageType === 'text' || outgoing.messageType === 'share'
|
||||
? outgoing.body?.trim() ?? ''
|
||||
: '',
|
||||
client_id: clientId,
|
||||
contact: outgoing.messageType === 'contact' ? outgoing.contact : null,
|
||||
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
delivery_status: 'sending',
|
||||
direction: 'sent',
|
||||
@@ -78,6 +82,7 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
media_waveform:
|
||||
outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null,
|
||||
message_type: outgoing.messageType,
|
||||
share: outgoing.messageType === 'share' ? outgoing.sharePayload : null,
|
||||
read_at: null,
|
||||
recipient_number: activeNumber.value,
|
||||
sender_number: '',
|
||||
@@ -88,10 +93,19 @@ export const useMessagesStore = defineStore('messages', () => {
|
||||
`data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}`
|
||||
}
|
||||
|
||||
const response = await nuiCall<SmsMessage>('messages:send', {
|
||||
...outgoing,
|
||||
phoneNumber: activeNumber.value,
|
||||
})
|
||||
const response = await nuiCall<SmsMessage>(
|
||||
'messages:send',
|
||||
outgoing.messageType === 'contact'
|
||||
? {
|
||||
contactId: outgoing.contactId,
|
||||
messageType: outgoing.messageType,
|
||||
phoneNumber: activeNumber.value,
|
||||
}
|
||||
: {
|
||||
...outgoing,
|
||||
phoneNumber: activeNumber.value,
|
||||
},
|
||||
)
|
||||
const index = messages.value.findIndex(
|
||||
(message) => message.client_id === clientId,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,20 @@ const defaultLocales: LocaleTree = {
|
||||
destinations: 'Share destinations',
|
||||
newMessage: 'New Message',
|
||||
shareProfile: 'Share Profile',
|
||||
kinds: {
|
||||
contact: 'Contact',
|
||||
document: 'Document',
|
||||
link: 'Link',
|
||||
location: 'Location',
|
||||
note: 'Note',
|
||||
photo: 'Photo',
|
||||
playlist: 'Playlist',
|
||||
post: 'Post',
|
||||
profile: 'Profile',
|
||||
text: 'Message',
|
||||
track: 'Track',
|
||||
video: 'Video',
|
||||
},
|
||||
chooseConversation: 'Choose a conversation',
|
||||
sentToChat: 'Sent to chat.',
|
||||
savedToNotes: 'Saved to Notes.',
|
||||
@@ -1122,6 +1136,7 @@ const defaultLocales: LocaleTree = {
|
||||
photo: 'Photo',
|
||||
gif: 'GIF',
|
||||
video: 'Video',
|
||||
contact: 'Contact',
|
||||
attachPhoto: 'Attach Photo',
|
||||
takePhoto: 'Take Photo',
|
||||
attachGif: 'Attach GIF',
|
||||
@@ -1129,6 +1144,10 @@ const defaultLocales: LocaleTree = {
|
||||
photos: 'Photos',
|
||||
gifs: 'GIFs',
|
||||
videos: 'Videos',
|
||||
contacts: 'Contacts',
|
||||
shareContact: 'Share Contact',
|
||||
noContactsToShare: 'Save a contact first to share it here.',
|
||||
contactSaved: 'Contact Saved',
|
||||
noPhotos: 'Take a photo first to attach it here.',
|
||||
noVideos: 'Record a video in Camera first.',
|
||||
searchGifs: 'Search GIPHY',
|
||||
@@ -1172,6 +1191,8 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_message: 'Enter a message.',
|
||||
invalid_voice: 'The audio message is invalid.',
|
||||
invalid_attachment: 'The attachment is invalid.',
|
||||
invalid_contact: 'The contact is invalid.',
|
||||
contact_not_found: 'This contact is no longer available.',
|
||||
media_provider_unconfigured: 'Photo uploads are not configured.',
|
||||
capture_provider_unavailable: 'The screenshot resource is unavailable.',
|
||||
capture_failed: 'The photo could not be captured.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseDateValue } from '@/utils/date'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
|
||||
export type DarkChatMessageType =
|
||||
| 'text'
|
||||
@@ -7,6 +8,7 @@ export type DarkChatMessageType =
|
||||
| 'voice'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'share'
|
||||
| 'system'
|
||||
export type DarkChatNotificationMode = 'full' | 'private' | 'hidden'
|
||||
|
||||
@@ -68,6 +70,7 @@ export type DarkChatMessage = {
|
||||
replyToId?: string | null
|
||||
replyBody?: string | null
|
||||
reactions: Record<string, string>
|
||||
sharePayload?: EasySharePayload | null
|
||||
expiresAt?: DatabaseDateValue | null
|
||||
createdAt: DatabaseDateValue
|
||||
readAt?: DatabaseDateValue | null
|
||||
@@ -88,12 +91,13 @@ export type DarkChatThread = {
|
||||
|
||||
export type DarkChatOutgoing = {
|
||||
body?: string
|
||||
messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video'
|
||||
messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' | 'share'
|
||||
mediaAssetId?: string
|
||||
mediaPayload?: string
|
||||
mediaPreviewUrl?: string
|
||||
mediaMime?: string
|
||||
mediaDurationMs?: number
|
||||
mediaWaveform?: number[]
|
||||
sharePayload?: EasySharePayload
|
||||
replyToId?: string
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type EasyShareChatApp = 'darkchat' | 'flare' | 'messages'
|
||||
export type EasyShareChatDraft = {
|
||||
appId: EasyShareChatApp
|
||||
body: string
|
||||
payload: EasySharePayload
|
||||
targetId: string | null
|
||||
}
|
||||
export type EasyShareStatus =
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DatabaseDateValue } from '@/utils/date'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
|
||||
export type FlareGender = 'woman' | 'man' | 'nonbinary'
|
||||
export type FlareInterest = FlareGender | 'everyone'
|
||||
export type FlareMessageType = 'text' | 'image' | 'gif' | 'video'
|
||||
export type FlareMessageType = 'text' | 'image' | 'gif' | 'video' | 'share'
|
||||
|
||||
export type FlareProfile = {
|
||||
age: number
|
||||
@@ -43,14 +44,16 @@ export type FlareMessage = {
|
||||
mediaDurationMs: number | null
|
||||
mediaUrl: string | null
|
||||
messageType: FlareMessageType
|
||||
sharePayload: EasySharePayload | null
|
||||
}
|
||||
|
||||
export type FlareOutgoingMessage =
|
||||
| { body: string; messageType: 'text' }
|
||||
| { body?: string; messageType: 'share'; sharePayload: EasySharePayload }
|
||||
| {
|
||||
mediaAssetId: string
|
||||
mediaDurationMs?: number
|
||||
messageType: Exclude<FlareMessageType, 'text'>
|
||||
messageType: Exclude<FlareMessageType, 'text' | 'share'>
|
||||
}
|
||||
|
||||
export type FlareProfileDraft = Omit<
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { DatabaseDateValue } from '@/utils/date'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
|
||||
export type SmsDirection = 'sent' | 'received'
|
||||
export type SmsAttachmentType = 'image' | 'gif' | 'video'
|
||||
export type SmsMessageType = 'text' | 'voice' | SmsAttachmentType
|
||||
export type SmsMessageType =
|
||||
| 'contact'
|
||||
| 'share'
|
||||
| 'text'
|
||||
| 'voice'
|
||||
| SmsAttachmentType
|
||||
export type SmsDeliveryStatus = 'sending' | 'delivered' | 'failed'
|
||||
|
||||
export type SmsSharedContact = {
|
||||
avatar_url: string | null
|
||||
name: string
|
||||
organization: string | null
|
||||
phone_number: string
|
||||
}
|
||||
|
||||
export type SmsConversation = {
|
||||
lastMessage: string
|
||||
lastMessageAt: DatabaseDateValue
|
||||
@@ -16,6 +29,7 @@ export type SmsConversation = {
|
||||
export type SmsMessage = {
|
||||
body: string
|
||||
client_id?: string
|
||||
contact?: SmsSharedContact | null
|
||||
created_at: DatabaseDateValue
|
||||
delivery_status?: SmsDeliveryStatus
|
||||
direction: SmsDirection
|
||||
@@ -25,6 +39,7 @@ export type SmsMessage = {
|
||||
media_mime: string | null
|
||||
media_waveform: number[] | null
|
||||
message_type: SmsMessageType
|
||||
share?: EasySharePayload | null
|
||||
read_at: string | null
|
||||
recipient_number: string
|
||||
sender_number: string
|
||||
@@ -32,6 +47,12 @@ export type SmsMessage = {
|
||||
|
||||
export type SmsOutgoingMessage =
|
||||
| { body: string; messageType: 'text' }
|
||||
| {
|
||||
contact: SmsSharedContact
|
||||
contactId: string
|
||||
messageType: 'contact'
|
||||
}
|
||||
| { body?: string; messageType: 'share'; sharePayload: EasySharePayload }
|
||||
| {
|
||||
mediaDurationMs: number
|
||||
mediaMime: string
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import SharedContentCard from '@/components/SharedContentCard.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
DarkChatNotificationMode,
|
||||
} from '@/types/darkchat'
|
||||
import type { GifSearchResult } from '@/types/messages'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
@@ -110,7 +112,8 @@ const identifier = ref('')
|
||||
const pendingIdentifier = ref('')
|
||||
const safetyOpen = ref(false)
|
||||
const draft = ref('')
|
||||
const queuedShareBody = ref('')
|
||||
const queuedSharePayload = ref<EasySharePayload | null>(null)
|
||||
const shareDraft = ref<EasySharePayload | null>(null)
|
||||
const aliasDraft = ref('')
|
||||
const contactAliasDraft = ref('')
|
||||
const notificationMode = ref<DarkChatNotificationMode>('private')
|
||||
@@ -231,6 +234,7 @@ function preview(conversation: DarkChatConversationSummary): string {
|
||||
if (conversation.lastMessageType === 'gif') return `GIF · ${t('gif')}`
|
||||
if (conversation.lastMessageType === 'image') return `📷 ${t('photo')}`
|
||||
if (conversation.lastMessageType === 'video') return `▶ ${t('video')}`
|
||||
if (conversation.lastMessageType === 'share') return `🔗 ${conversation.lastMessage}`
|
||||
if (conversation.lastMessageType === 'system') return t('securityUpdate')
|
||||
return conversation.lastMessage
|
||||
}
|
||||
@@ -330,9 +334,9 @@ async function openConversation(conversationId: string): Promise<void> {
|
||||
}
|
||||
screen.value = 'thread'
|
||||
resetPanels()
|
||||
if (queuedShareBody.value) {
|
||||
draft.value = queuedShareBody.value
|
||||
queuedShareBody.value = ''
|
||||
if (queuedSharePayload.value) {
|
||||
shareDraft.value = queuedSharePayload.value
|
||||
queuedSharePayload.value = null
|
||||
}
|
||||
await scrollBottom(false)
|
||||
}
|
||||
@@ -344,6 +348,7 @@ function back(): void {
|
||||
}
|
||||
if (screen.value === 'thread') darkchat.closeThread()
|
||||
screen.value = 'inbox'
|
||||
shareDraft.value = null
|
||||
resetPanels()
|
||||
}
|
||||
|
||||
@@ -411,19 +416,26 @@ async function confirmStart(): Promise<void> {
|
||||
|
||||
async function sendText(): Promise<void> {
|
||||
const body = draft.value.trim()
|
||||
if (!body || sending.value) return
|
||||
if ((!body && !shareDraft.value) || sending.value) return
|
||||
const shared = shareDraft.value
|
||||
draft.value = ''
|
||||
shareDraft.value = null
|
||||
const outgoingReply = replyTo.value?.id
|
||||
replyTo.value = null
|
||||
resetPanels()
|
||||
sending.value = true
|
||||
const response = await darkchat.send({
|
||||
body,
|
||||
messageType: 'text',
|
||||
messageType: shared ? 'share' : 'text',
|
||||
replyToId: outgoingReply,
|
||||
sharePayload: shared ?? undefined,
|
||||
})
|
||||
sending.value = false
|
||||
if (!response.success) showToast(errorText(response.error))
|
||||
if (!response.success) {
|
||||
draft.value = body
|
||||
shareDraft.value = shared
|
||||
showToast(errorText(response.error))
|
||||
}
|
||||
await scrollBottom()
|
||||
}
|
||||
|
||||
@@ -513,7 +525,7 @@ async function openEasyShareDraft(): Promise<boolean> {
|
||||
darkchat.closeThread()
|
||||
screen.value = 'inbox'
|
||||
resetPanels()
|
||||
queuedShareBody.value = shared.body
|
||||
queuedSharePayload.value = shared.payload
|
||||
draft.value = ''
|
||||
return true
|
||||
}
|
||||
@@ -523,8 +535,9 @@ async function openEasyShareDraft(): Promise<boolean> {
|
||||
}
|
||||
screen.value = 'thread'
|
||||
resetPanels()
|
||||
queuedShareBody.value = ''
|
||||
draft.value = shared.body
|
||||
queuedSharePayload.value = null
|
||||
shareDraft.value = shared.payload
|
||||
draft.value = ''
|
||||
await scrollBottom(false)
|
||||
return true
|
||||
}
|
||||
@@ -2450,6 +2463,11 @@ onBeforeUnmount(() => {
|
||||
v-else-if="message.messageType === 'voice'"
|
||||
:message="message"
|
||||
/>
|
||||
<SharedContentCard
|
||||
v-else-if="message.messageType === 'share' && message.sharePayload"
|
||||
:payload="message.sharePayload"
|
||||
variant="darkchat"
|
||||
/>
|
||||
<span v-else class="dc-message-body">{{ message.body }}</span>
|
||||
<span
|
||||
v-if="Object.keys(message.reactions).length"
|
||||
@@ -2542,6 +2560,19 @@ onBeforeUnmount(() => {
|
||||
@close="emojiOpen = false"
|
||||
@pick="appendEmoji"
|
||||
/>
|
||||
<div
|
||||
v-if="shareDraft"
|
||||
class="shared-composer-preview shared-composer-preview--dark"
|
||||
>
|
||||
<SharedContentCard compact :payload="shareDraft" variant="darkchat" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click="shareDraft = null"
|
||||
>
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recording" class="dc-recorder">
|
||||
<k-link
|
||||
component="button"
|
||||
@@ -2583,7 +2614,7 @@ onBeforeUnmount(() => {
|
||||
></template>
|
||||
<template #right
|
||||
><k-link
|
||||
v-if="draft.trim()"
|
||||
v-if="draft.trim() || shareDraft"
|
||||
component="button"
|
||||
type="button"
|
||||
icon-only
|
||||
|
||||
@@ -67,6 +67,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import profilesSprite from '@/assets/img/flare/profiles-source.png'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||
import SharedContentCard from '@/components/SharedContentCard.vue'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
@@ -81,6 +82,7 @@ import type {
|
||||
FlareProfileDraft,
|
||||
} from '@/types/flare'
|
||||
import type { PhoneMedia } from '@/types/media'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
|
||||
@@ -109,6 +111,7 @@ const router = useRouter()
|
||||
const activeTab = ref<FlareTab>('discover')
|
||||
const activeMatch = ref<FlareMatch | null>(null)
|
||||
const draft = ref('')
|
||||
const shareDraft = ref<EasySharePayload | null>(null)
|
||||
const matchReveal = ref<FlareMatch | null>(null)
|
||||
const cardOffset = ref(0)
|
||||
const currentPhotoIndex = ref(0)
|
||||
@@ -596,13 +599,17 @@ async function openEasyShareDraft(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
await openMatch(match)
|
||||
if (activeMatch.value?.id === match.id) draft.value = shared.body
|
||||
if (activeMatch.value?.id === match.id) {
|
||||
draft.value = ''
|
||||
shareDraft.value = shared.payload
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function closeMatch(): void {
|
||||
activeMatch.value = null
|
||||
draft.value = ''
|
||||
shareDraft.value = null
|
||||
emojiOpen.value = false
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
@@ -617,18 +624,23 @@ async function openRevealedMatch(): Promise<void> {
|
||||
|
||||
async function sendMessage(): Promise<void> {
|
||||
const body = draft.value.trim()
|
||||
if (!body || !activeMatch.value || flare.sending) return
|
||||
if ((!body && !shareDraft.value) || !activeMatch.value || flare.sending) return
|
||||
const shared = shareDraft.value
|
||||
draft.value = ''
|
||||
shareDraft.value = null
|
||||
emojiOpen.value = false
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
if (
|
||||
!(await flare.send(activeMatch.value.id, {
|
||||
body,
|
||||
messageType: 'text',
|
||||
}))
|
||||
!(await flare.send(
|
||||
activeMatch.value.id,
|
||||
shared
|
||||
? { body, messageType: 'share', sharePayload: shared }
|
||||
: { body, messageType: 'text' },
|
||||
))
|
||||
) {
|
||||
draft.value = body
|
||||
shareDraft.value = shared
|
||||
showActionError()
|
||||
}
|
||||
await nextTick()
|
||||
@@ -1027,7 +1039,12 @@ onBeforeUnmount(() => {
|
||||
:text-footer="messageTime(message.createdAt)"
|
||||
>
|
||||
<template v-if="message.messageType !== 'text'" #text>
|
||||
<MessageAttachmentBubble :message="attachmentMessage(message)" />
|
||||
<SharedContentCard
|
||||
v-if="message.messageType === 'share' && message.sharePayload"
|
||||
:payload="message.sharePayload"
|
||||
variant="flare"
|
||||
/>
|
||||
<MessageAttachmentBubble v-else :message="attachmentMessage(message)" />
|
||||
</template>
|
||||
</k-message>
|
||||
</k-messages>
|
||||
@@ -1115,6 +1132,13 @@ onBeforeUnmount(() => {
|
||||
@pick="appendEmoji"
|
||||
/>
|
||||
|
||||
<div v-if="shareDraft" class="shared-composer-preview">
|
||||
<SharedContentCard compact :payload="shareDraft" variant="flare" />
|
||||
<button type="button" :aria-label="phone.t('Common.close')" @click="shareDraft = null">
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<k-messagebar
|
||||
class="flare-messagebar messages-messagebar"
|
||||
:placeholder="phone.t('Apps.flare.messagePlaceholder')"
|
||||
@@ -1143,7 +1167,7 @@ onBeforeUnmount(() => {
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:disabled="!draft.trim() || flare.sending"
|
||||
:disabled="(!draft.trim() && !shareDraft) || flare.sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<ArrowUpCircle :size="29" />
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Search,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
ContactRound,
|
||||
UserPlus,
|
||||
Video,
|
||||
X,
|
||||
@@ -43,6 +44,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||
import MessageContactBubble from '@/components/MessageContactBubble.vue'
|
||||
import SharedContentCard from '@/components/SharedContentCard.vue'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
@@ -56,7 +59,10 @@ import type {
|
||||
SmsAttachmentType,
|
||||
SmsConversation,
|
||||
SmsMessage,
|
||||
SmsSharedContact,
|
||||
} from '@/types/messages'
|
||||
import type { PhoneContact } from '@/types/phone'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
|
||||
const VOICE_MAX_DURATION_MS = 30_000
|
||||
const VOICE_MAX_BYTES = 135_000
|
||||
@@ -74,14 +80,15 @@ const editingList = ref(false)
|
||||
const selectedNumbers = ref<string[]>([])
|
||||
const composerNumber = ref('')
|
||||
const draft = ref('')
|
||||
const queuedShareBody = ref('')
|
||||
const queuedSharePayload = ref<EasySharePayload | null>(null)
|
||||
const shareDraft = ref<EasySharePayload | null>(null)
|
||||
const composing = ref(false)
|
||||
const sending = ref(false)
|
||||
const toastOpened = ref(false)
|
||||
const toastText = ref('')
|
||||
const emojiOpen = ref(false)
|
||||
const attachmentMenuOpen = ref(false)
|
||||
const attachmentPicker = ref<'gifs' | null>(null)
|
||||
const attachmentPicker = ref<'contacts' | 'gifs' | null>(null)
|
||||
const contactDetailsOpen = ref(false)
|
||||
const contactEditing = ref(false)
|
||||
const contactNameDraft = ref('')
|
||||
@@ -169,6 +176,12 @@ function conversationPreview(conversation: SmsConversation): string {
|
||||
if (conversation.lastMessageType === 'video') {
|
||||
return `▶️ ${phone.t('Apps.messages.video')}`
|
||||
}
|
||||
if (conversation.lastMessageType === 'contact') {
|
||||
return `\u{1F464} ${phone.t('Apps.messages.contact')}`
|
||||
}
|
||||
if (conversation.lastMessageType === 'share') {
|
||||
return `\u{1F517} ${conversation.lastMessage}`
|
||||
}
|
||||
return conversation.lastMessageType === 'voice'
|
||||
? `🎙️ ${phone.t('Apps.messages.voiceMessage')}`
|
||||
: conversation.lastMessage
|
||||
@@ -286,6 +299,8 @@ function errorText(error?: string): string {
|
||||
'invalid_message',
|
||||
'invalid_voice',
|
||||
'invalid_attachment',
|
||||
'invalid_contact',
|
||||
'contact_not_found',
|
||||
'media_provider_unconfigured',
|
||||
'capture_provider_unavailable',
|
||||
'capture_failed',
|
||||
@@ -326,8 +341,8 @@ async function openConversation(conversation: SmsConversation): Promise<void> {
|
||||
return
|
||||
}
|
||||
composing.value = false
|
||||
draft.value = queuedShareBody.value
|
||||
queuedShareBody.value = ''
|
||||
shareDraft.value = queuedSharePayload.value
|
||||
queuedSharePayload.value = null
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
@@ -337,7 +352,7 @@ async function openEasyShareDraft(): Promise<void> {
|
||||
if (!shared.targetId) {
|
||||
messages.closeThread()
|
||||
composing.value = false
|
||||
queuedShareBody.value = shared.body
|
||||
queuedSharePayload.value = shared.payload
|
||||
draft.value = ''
|
||||
return
|
||||
}
|
||||
@@ -346,8 +361,9 @@ async function openEasyShareDraft(): Promise<void> {
|
||||
return
|
||||
}
|
||||
composing.value = false
|
||||
queuedShareBody.value = ''
|
||||
draft.value = shared.body
|
||||
queuedSharePayload.value = null
|
||||
shareDraft.value = shared.payload
|
||||
draft.value = ''
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
@@ -382,6 +398,8 @@ async function chooseRecipient(number: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
composing.value = false
|
||||
shareDraft.value = queuedSharePayload.value
|
||||
queuedSharePayload.value = null
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
@@ -396,6 +414,7 @@ function goBack(): void {
|
||||
composing.value = false
|
||||
composerNumber.value = ''
|
||||
draft.value = ''
|
||||
shareDraft.value = null
|
||||
emojiOpen.value = false
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
@@ -460,6 +479,12 @@ function openGifPicker(): void {
|
||||
if (!gifResults.value.length) void loadGifs(true)
|
||||
}
|
||||
|
||||
function openContactPicker(): void {
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = 'contacts'
|
||||
emojiOpen.value = false
|
||||
}
|
||||
|
||||
function openEmojiPicker(): void {
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
@@ -498,6 +523,51 @@ async function sendAttachment(
|
||||
await scrollToBottom()
|
||||
}
|
||||
|
||||
async function sendContact(contact: PhoneContact): Promise<void> {
|
||||
if (!messages.activeNumber || sending.value) return
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
const response = await messages.send({
|
||||
contact: {
|
||||
avatar_url: contact.avatar_url ?? null,
|
||||
name: contact.name,
|
||||
organization: contact.organization ?? null,
|
||||
phone_number: contact.phone_number,
|
||||
},
|
||||
contactId: contact.id,
|
||||
messageType: 'contact',
|
||||
})
|
||||
sending.value = false
|
||||
if (!response.success) showToast(errorText(response.error))
|
||||
await scrollToBottom()
|
||||
}
|
||||
|
||||
async function messageSharedContact(contact: SmsSharedContact): Promise<void> {
|
||||
if (!(await messages.openThread(contact.phone_number))) {
|
||||
showToast(errorText('invalid_number'))
|
||||
return
|
||||
}
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
emojiOpen.value = false
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
async function saveSharedContact(contact: SmsSharedContact): Promise<void> {
|
||||
if (knownContactNumbers.value.has(contact.phone_number)) return
|
||||
const response = await calls.saveContact({
|
||||
name: contact.name,
|
||||
organization: contact.organization ?? '',
|
||||
phoneNumber: contact.phone_number,
|
||||
})
|
||||
showToast(
|
||||
response.success
|
||||
? phone.t('Apps.messages.contactSaved')
|
||||
: phone.t('Apps.messages.contactSaveFailed'),
|
||||
)
|
||||
}
|
||||
|
||||
async function loadGifs(reset = false): Promise<void> {
|
||||
if (gifLoading.value || (!reset && !gifHasMore.value)) return
|
||||
gifError.value = null
|
||||
@@ -534,17 +604,31 @@ function queueGifSearch(): void {
|
||||
}
|
||||
|
||||
async function sendTextMessage(): Promise<void> {
|
||||
if (!messages.activeNumber || !draft.value.trim() || sending.value) return
|
||||
if (
|
||||
!messages.activeNumber ||
|
||||
(!draft.value.trim() && !shareDraft.value) ||
|
||||
sending.value
|
||||
) return
|
||||
const body = draft.value
|
||||
const shared = shareDraft.value
|
||||
draft.value = ''
|
||||
shareDraft.value = null
|
||||
emojiOpen.value = false
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
await scrollToBottom()
|
||||
const response = await messages.send({ body, messageType: 'text' })
|
||||
const response = await messages.send(
|
||||
shared
|
||||
? { body, messageType: 'share', sharePayload: shared }
|
||||
: { body, messageType: 'text' },
|
||||
)
|
||||
sending.value = false
|
||||
if (!response.success) showToast(errorText(response.error))
|
||||
if (!response.success) {
|
||||
draft.value = body
|
||||
shareDraft.value = shared
|
||||
showToast(errorText(response.error))
|
||||
}
|
||||
await scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -1170,6 +1254,21 @@ onBeforeUnmount(() => {
|
||||
v-if="message.message_type === 'voice'"
|
||||
:message="message"
|
||||
/>
|
||||
<MessageContactBubble
|
||||
v-else-if="message.message_type === 'contact' && message.contact"
|
||||
:add-label="phone.t('Apps.messages.addContact')"
|
||||
:contact="message.contact"
|
||||
:message-label="phone.t('Apps.messages.messageAction')"
|
||||
:saved="knownContactNumbers.has(message.contact.phone_number)"
|
||||
:saved-label="phone.t('Apps.messages.contactSaved')"
|
||||
@message="messageSharedContact(message.contact)"
|
||||
@save="saveSharedContact(message.contact)"
|
||||
/>
|
||||
<SharedContentCard
|
||||
v-else-if="message.message_type === 'share' && message.share"
|
||||
:payload="message.share"
|
||||
variant="messages"
|
||||
/>
|
||||
<MessageAttachmentBubble v-else :message="message" />
|
||||
</template>
|
||||
</k-message>
|
||||
@@ -1189,6 +1288,10 @@ onBeforeUnmount(() => {
|
||||
<span class="messages-action-emoji">😀</span>
|
||||
{{ phone.t('Apps.messages.emoji') }}
|
||||
</button>
|
||||
<button type="button" @click="openContactPicker">
|
||||
<span><ContactRound :size="20" /></span>
|
||||
{{ phone.t('Apps.messages.shareContact') }}
|
||||
</button>
|
||||
<button type="button" @click="openGifPicker">
|
||||
<span><ImagePlay :size="20" /></span>
|
||||
{{ phone.t('Apps.messages.attachGif') }}
|
||||
@@ -1202,13 +1305,51 @@ onBeforeUnmount(() => {
|
||||
<section v-if="attachmentPicker" class="messages-media-picker">
|
||||
<header>
|
||||
<strong>
|
||||
{{ phone.t('Apps.messages.gifs') }}
|
||||
{{
|
||||
phone.t(
|
||||
attachmentPicker === 'contacts'
|
||||
? 'Apps.messages.contacts'
|
||||
: 'Apps.messages.gifs',
|
||||
)
|
||||
}}
|
||||
</strong>
|
||||
<button type="button" @click="attachmentPicker = null">
|
||||
{{ phone.t('Common.done') }}
|
||||
</button>
|
||||
</header>
|
||||
<div class="messages-media-picker__gifs">
|
||||
<k-list
|
||||
v-if="attachmentPicker === 'contacts'"
|
||||
inset
|
||||
strong
|
||||
class="messages-media-picker__contacts"
|
||||
>
|
||||
<k-list-item
|
||||
v-for="contact in calls.contacts"
|
||||
:key="contact.id"
|
||||
link
|
||||
:title="contact.name"
|
||||
:subtitle="contact.organization || contact.phone_number"
|
||||
@click="sendContact(contact)"
|
||||
>
|
||||
<template #media>
|
||||
<span class="messages-avatar messages-avatar--small">
|
||||
<img
|
||||
v-if="contact.avatar_url"
|
||||
class="messages-avatar__image"
|
||||
:src="contact.avatar_url"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else class="messages-avatar__initials">{{
|
||||
contactInitials(contact.phone_number)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</k-list-item>
|
||||
<p v-if="!calls.contacts.length" class="messages-media-picker__empty">
|
||||
{{ phone.t('Apps.messages.noContactsToShare') }}
|
||||
</p>
|
||||
</k-list>
|
||||
<div v-else class="messages-media-picker__gifs">
|
||||
<label class="messages-gif-search">
|
||||
<Search :size="15" />
|
||||
<input
|
||||
@@ -1252,6 +1393,17 @@ onBeforeUnmount(() => {
|
||||
@pick="appendEmoji"
|
||||
/>
|
||||
|
||||
<div v-if="shareDraft && !recording" class="shared-composer-preview">
|
||||
<SharedContentCard compact :payload="shareDraft" variant="messages" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click="shareDraft = null"
|
||||
>
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="recording" class="messages-recorder">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1305,7 +1457,7 @@ onBeforeUnmount(() => {
|
||||
<template #right>
|
||||
<k-toolbar-pane class="ios:h-10">
|
||||
<k-link
|
||||
v-if="draft.trim()"
|
||||
v-if="draft.trim() || shareDraft"
|
||||
component="button"
|
||||
icon-only
|
||||
:disabled="sending"
|
||||
|
||||
+302
-20
@@ -1534,6 +1534,56 @@ const smsMessages = [
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5551110001',
|
||||
},
|
||||
{
|
||||
body: 'Samantha Cole',
|
||||
contact: {
|
||||
avatar_url: 'https://picsum.photos/seed/shared-samantha/240/240',
|
||||
name: 'Samantha Cole',
|
||||
organization: 'Downtown Cab Co.',
|
||||
phone_number: '5553330044',
|
||||
},
|
||||
created_at: isoTime(-8 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-contact-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: {
|
||||
avatar_url: 'https://picsum.photos/seed/shared-samantha/240/240',
|
||||
name: 'Samantha Cole',
|
||||
organization: 'Downtown Cab Co.',
|
||||
phone_number: '5553330044',
|
||||
},
|
||||
media_waveform: null,
|
||||
message_type: 'contact',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5551110001',
|
||||
},
|
||||
{
|
||||
body: 'Neon nights in Vinewood',
|
||||
created_at: isoTime(-4 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-share-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: {
|
||||
appId: 'picstagram',
|
||||
copyText: 'Die besten Lichter der Stadt – direkt aus Vinewood.',
|
||||
id: 'picstagram-post-neon-nights',
|
||||
imageUrl: 'https://picsum.photos/seed/easyshare-neon/720/720',
|
||||
kind: 'post',
|
||||
link: 'skyphone://picstagram/post/picstagram-post-neon-nights',
|
||||
subtitle: '@morgan',
|
||||
title: 'Neon nights in Vinewood',
|
||||
},
|
||||
media_waveform: null,
|
||||
message_type: 'share',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5551110001',
|
||||
},
|
||||
]
|
||||
const darkChatProfile = {
|
||||
id: 1,
|
||||
@@ -1613,6 +1663,27 @@ const darkChatMessages = [
|
||||
createdAt: '2026-08-06 22:44:00',
|
||||
readAt: null,
|
||||
},
|
||||
{
|
||||
id: 'dc-message-00000000-0000-000000000004',
|
||||
conversationId: darkChatConversations[0].id,
|
||||
direction: 'received',
|
||||
senderProfileId: 2,
|
||||
messageType: 'share',
|
||||
body: 'Downtown is awake',
|
||||
reactions: {},
|
||||
sharePayload: {
|
||||
appId: 'feather',
|
||||
copyText: 'Vinewood after midnight. No filters, just city light.',
|
||||
id: 'feather-post-downtown-awake',
|
||||
imageUrl: 'https://picsum.photos/seed/easyshare-downtown/900/600',
|
||||
kind: 'post',
|
||||
link: 'skyphone://feather/post/feather-post-downtown-awake',
|
||||
subtitle: '@nightowl',
|
||||
title: 'Downtown is awake',
|
||||
},
|
||||
createdAt: isoTime(-3 * 60_000),
|
||||
readAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
function darkChatBootstrap() {
|
||||
@@ -2478,9 +2549,9 @@ const flareMatches = [
|
||||
lookingFor: 'dates',
|
||||
photoUrls: [],
|
||||
},
|
||||
lastMessage: 'That place sounds perfect. Friday?',
|
||||
lastMessageAt: isoTime(-38 * 60 * 1000),
|
||||
lastMessageType: 'text',
|
||||
lastMessage: 'Friday night jazz',
|
||||
lastMessageAt: isoTime(-18 * 60 * 1000),
|
||||
lastMessageType: 'share',
|
||||
unread: 1,
|
||||
},
|
||||
]
|
||||
@@ -2506,6 +2577,25 @@ const flareMessages = {
|
||||
mediaUrl: null,
|
||||
messageType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'flare-message-3',
|
||||
direction: 'received',
|
||||
body: 'Friday night jazz',
|
||||
createdAt: isoTime(-18 * 60 * 1000),
|
||||
mediaDurationMs: null,
|
||||
mediaUrl: null,
|
||||
messageType: 'share',
|
||||
sharePayload: {
|
||||
appId: 'music',
|
||||
copyText: 'A late-night playlist for the drive to Vinewood.',
|
||||
id: 'music-playlist-friday-jazz',
|
||||
imageUrl: 'https://picsum.photos/seed/easyshare-jazz/720/720',
|
||||
kind: 'playlist',
|
||||
link: 'skyphone://music/playlist/music-playlist-friday-jazz',
|
||||
subtitle: '12 tracks · 48 min',
|
||||
title: 'Friday night jazz',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -3076,9 +3166,150 @@ const featherTopics = [
|
||||
]
|
||||
let featherOnboarded = true
|
||||
|
||||
const easyShareHistory = []
|
||||
const easyShareTargets = [
|
||||
{ distance: 2.4, id: 41, name: 'Mia Santos' },
|
||||
{ distance: 7.8, id: 72, name: 'Noah Walker' },
|
||||
{ distance: 14.6, id: 105, name: 'Jamie Rivera' },
|
||||
]
|
||||
const easyShareHistory = [
|
||||
{
|
||||
createdAt: Date.now() - 2 * 60 * 1000,
|
||||
direction: 'incoming',
|
||||
id: 'easyshare-incoming-pending',
|
||||
otherName: 'Mia Santos',
|
||||
payload: {
|
||||
appId: 'notes',
|
||||
copyText: 'Meet at Mission Row at 20:30.',
|
||||
id: 'note-easyshare-meeting',
|
||||
kind: 'note',
|
||||
title: 'Mission Row meeting',
|
||||
},
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 8 * 60 * 1000,
|
||||
direction: 'outgoing',
|
||||
id: 'easyshare-outgoing-transferring',
|
||||
otherName: 'Noah Walker',
|
||||
payload: {
|
||||
appId: 'gallery',
|
||||
copyText: 'Sunset over Los Santos.',
|
||||
id: 3,
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
|
||||
kind: 'photo',
|
||||
title: 'Los Santos sunset',
|
||||
},
|
||||
progress: 58,
|
||||
status: 'transferring',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 22 * 60 * 1000,
|
||||
direction: 'incoming',
|
||||
id: 'easyshare-completed',
|
||||
otherName: 'Jamie Rivera',
|
||||
payload: {
|
||||
appId: 'map',
|
||||
copyText: 'Legion Square',
|
||||
kind: 'location',
|
||||
link: 'https://maps.sky/legion-square',
|
||||
title: 'Legion Square',
|
||||
},
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 48 * 60 * 1000,
|
||||
direction: 'outgoing',
|
||||
id: 'easyshare-accepted',
|
||||
otherName: 'Mia Santos',
|
||||
payload: {
|
||||
appId: 'music',
|
||||
copyText: 'Night Drive by Neon Coast',
|
||||
kind: 'track',
|
||||
title: 'Night Drive',
|
||||
},
|
||||
progress: 15,
|
||||
status: 'accepted',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
direction: 'outgoing',
|
||||
id: 'easyshare-declined',
|
||||
otherName: 'Noah Walker',
|
||||
payload: {
|
||||
appId: 'feather',
|
||||
copyText: 'Road closure near Alta Street.',
|
||||
id: 'feather-post-3',
|
||||
kind: 'post',
|
||||
title: 'Road closure',
|
||||
},
|
||||
progress: 0,
|
||||
status: 'declined',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 4 * 60 * 60 * 1000,
|
||||
direction: 'outgoing',
|
||||
id: 'easyshare-cancelled',
|
||||
otherName: 'Jamie Rivera',
|
||||
payload: {
|
||||
appId: 'phone',
|
||||
copyText: 'Mia Santos\n5550142',
|
||||
kind: 'contact',
|
||||
title: 'Mia Santos',
|
||||
},
|
||||
progress: 31,
|
||||
status: 'cancelled',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 7 * 60 * 60 * 1000,
|
||||
direction: 'incoming',
|
||||
id: 'easyshare-expired',
|
||||
otherName: 'Mia Santos',
|
||||
payload: {
|
||||
appId: 'picstagram',
|
||||
copyText: 'New post from @mia.santos',
|
||||
id: 'picstagram-post-1',
|
||||
kind: 'post',
|
||||
title: 'Vespucci evening',
|
||||
},
|
||||
progress: 0,
|
||||
status: 'expired',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 24 * 60 * 60 * 1000,
|
||||
direction: 'outgoing',
|
||||
id: 'easyshare-failed',
|
||||
otherName: 'Noah Walker',
|
||||
payload: {
|
||||
appId: 'gallery',
|
||||
copyText: 'Vehicle walkaround video.',
|
||||
id: 7,
|
||||
kind: 'video',
|
||||
title: 'Vehicle walkaround',
|
||||
},
|
||||
progress: 73,
|
||||
status: 'failed',
|
||||
},
|
||||
]
|
||||
let easyShareVisibility = 'everyone'
|
||||
|
||||
function easyShareHistoryForScenario(testScenario) {
|
||||
if (testScenario === 'easyshare-empty') return []
|
||||
if (testScenario === 'easyshare-incoming') {
|
||||
return easyShareHistory.filter(
|
||||
(transfer) => transfer.id === 'easyshare-incoming-pending',
|
||||
)
|
||||
}
|
||||
if (testScenario === 'easyshare-history') {
|
||||
return easyShareHistory.filter(
|
||||
(transfer) => !['pending', 'transferring'].includes(transfer.status),
|
||||
)
|
||||
}
|
||||
return easyShareHistory
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
@@ -3996,7 +4227,9 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
!match ||
|
||||
(messageType === 'text'
|
||||
? !body
|
||||
: !['image', 'gif', 'video'].includes(messageType) || !mediaUrl)
|
||||
: messageType === 'share'
|
||||
? !request.body.sharePayload
|
||||
: !['image', 'gif', 'video'].includes(messageType) || !mediaUrl)
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_message' })
|
||||
return
|
||||
@@ -4004,11 +4237,18 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const message = {
|
||||
id: `flare-message-${Date.now()}`,
|
||||
direction: 'sent',
|
||||
body: messageType === 'text' ? body : '',
|
||||
body:
|
||||
messageType === 'share'
|
||||
? body || request.body.sharePayload.title
|
||||
: messageType === 'text'
|
||||
? body
|
||||
: '',
|
||||
createdAt: Date.now(),
|
||||
mediaDurationMs: request.body.mediaDurationMs ?? null,
|
||||
mediaUrl: messageType === 'text' ? null : mediaUrl,
|
||||
messageType,
|
||||
sharePayload:
|
||||
messageType === 'share' ? request.body.sharePayload : null,
|
||||
}
|
||||
flareMessages[match.id] ??= []
|
||||
flareMessages[match.id].push(message)
|
||||
@@ -5330,7 +5570,10 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
const messageType = request.body.messageType ?? 'text'
|
||||
const body = String(request.body.body ?? '')
|
||||
if ((messageType === 'text' || messageType === 'emoji') && !body.trim()) {
|
||||
if (
|
||||
((messageType === 'text' || messageType === 'emoji') && !body.trim()) ||
|
||||
(messageType === 'share' && !request.body.sharePayload)
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_message' })
|
||||
return
|
||||
}
|
||||
@@ -5343,7 +5586,10 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
direction: 'sent',
|
||||
senderProfileId: darkChatProfile.id,
|
||||
messageType,
|
||||
body,
|
||||
body:
|
||||
messageType === 'share'
|
||||
? body.trim() || request.body.sharePayload.title
|
||||
: body,
|
||||
mediaPayload:
|
||||
messageType === 'gif' ? request.body.mediaPayload : undefined,
|
||||
mediaSecret:
|
||||
@@ -5354,6 +5600,8 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
replyToId: request.body.replyToId,
|
||||
replyBody: reply?.body,
|
||||
reactions: {},
|
||||
sharePayload:
|
||||
messageType === 'share' ? request.body.sharePayload : null,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
readAt: null,
|
||||
}
|
||||
@@ -5494,17 +5742,15 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:bootstrap') {
|
||||
const history = easyShareHistoryForScenario(testScenario)
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
history: easyShareHistory,
|
||||
pending: easyShareHistory.filter((item) =>
|
||||
history,
|
||||
pending: history.filter((item) =>
|
||||
['pending', 'transferring'].includes(item.status),
|
||||
),
|
||||
targets: [
|
||||
{ distance: 2.4, id: 41, name: 'Mia Santos' },
|
||||
{ distance: 7.8, id: 72, name: 'Noah Walker' },
|
||||
],
|
||||
targets: testScenario === 'easyshare-empty' ? [] : easyShareTargets,
|
||||
visibility: easyShareVisibility,
|
||||
},
|
||||
})
|
||||
@@ -5516,21 +5762,26 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:request') {
|
||||
const target = easyShareTargets.find(
|
||||
(candidate) => candidate.id === Number(request.body.targetId),
|
||||
)
|
||||
const transfer = {
|
||||
createdAt: Date.now(),
|
||||
direction: 'outgoing',
|
||||
id: `easyshare-${Date.now()}`,
|
||||
otherName: request.body.targetId === 72 ? 'Noah Walker' : 'Mia Santos',
|
||||
otherName: target?.name ?? 'Unknown device',
|
||||
payload: request.body.payload,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
progress: target?.id === 72 ? 58 : 0,
|
||||
status: target?.id === 72 ? 'transferring' : 'pending',
|
||||
}
|
||||
easyShareHistory.unshift(transfer)
|
||||
response.json({ success: true, data: transfer })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'easyshare:respond' || endpoint === 'easyshare:cancel') {
|
||||
const transfer = easyShareHistory.find((item) => item.id === request.body.id)
|
||||
const transfer = easyShareHistory.find(
|
||||
(item) => item.id === request.body.id,
|
||||
)
|
||||
if (!transfer) {
|
||||
response.json({ success: false, error: 'transfer_not_found' })
|
||||
return
|
||||
@@ -5541,7 +5792,8 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
: request.body.accepted
|
||||
? 'completed'
|
||||
: 'declined'
|
||||
transfer.progress = transfer.status === 'completed' ? 100 : transfer.progress
|
||||
transfer.progress =
|
||||
transfer.status === 'completed' ? 100 : transfer.progress
|
||||
response.json({ success: true, data: transfer })
|
||||
return
|
||||
}
|
||||
@@ -5608,6 +5860,8 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
success: true,
|
||||
data: thread.map(({ media_payload, ...message }) => ({
|
||||
...message,
|
||||
contact: message.message_type === 'contact' ? media_payload : null,
|
||||
share: message.message_type === 'share' ? media_payload : null,
|
||||
media_asset_id: ['image', 'gif', 'video'].includes(message.message_type)
|
||||
? media_payload
|
||||
: null,
|
||||
@@ -5637,6 +5891,10 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '')
|
||||
const messageType = request.body.messageType ?? 'text'
|
||||
const isAttachment = ['image', 'gif', 'video'].includes(messageType)
|
||||
const selectedContact =
|
||||
messageType === 'contact'
|
||||
? contacts.find((contact) => contact.id === request.body.contactId)
|
||||
: null
|
||||
const requestedAttachmentId = String(request.body.mediaAssetId ?? '')
|
||||
const selectedMedia = /^\d+$/.test(requestedAttachmentId)
|
||||
? mockMedia.find((item) => String(item.id) === requestedAttachmentId)
|
||||
@@ -5646,6 +5904,8 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
!phoneNumber ||
|
||||
(messageType === 'text' && !body) ||
|
||||
(messageType === 'voice' && !request.body.mediaPayload) ||
|
||||
(messageType === 'contact' && !selectedContact) ||
|
||||
(messageType === 'share' && !request.body.sharePayload) ||
|
||||
(isAttachment &&
|
||||
!attachmentAssets[messageType].has(attachmentId) &&
|
||||
!attachmentId.startsWith('https://') &&
|
||||
@@ -5655,7 +5915,19 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
const message = {
|
||||
body,
|
||||
body:
|
||||
selectedContact?.name ??
|
||||
(messageType === 'share'
|
||||
? body || request.body.sharePayload.title
|
||||
: body),
|
||||
contact: selectedContact
|
||||
? {
|
||||
avatar_url: selectedContact.avatar_url ?? null,
|
||||
name: selectedContact.name,
|
||||
organization: selectedContact.organization ?? null,
|
||||
phone_number: selectedContact.phone_number,
|
||||
}
|
||||
: null,
|
||||
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
direction: 'sent',
|
||||
id: `sms-${Date.now()}`,
|
||||
@@ -5675,6 +5947,15 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
media_payload:
|
||||
messageType === 'voice'
|
||||
? request.body.mediaPayload
|
||||
: selectedContact
|
||||
? {
|
||||
avatar_url: selectedContact.avatar_url ?? null,
|
||||
name: selectedContact.name,
|
||||
organization: selectedContact.organization ?? null,
|
||||
phone_number: selectedContact.phone_number,
|
||||
}
|
||||
: messageType === 'share'
|
||||
? request.body.sharePayload
|
||||
: isAttachment
|
||||
? attachmentId
|
||||
: null,
|
||||
@@ -5685,6 +5966,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
read_at: null,
|
||||
recipient_number: phoneNumber,
|
||||
sender_number: '5551234567',
|
||||
share: messageType === 'share' ? request.body.sharePayload : null,
|
||||
}
|
||||
smsMessages.push(message)
|
||||
const { media_payload, ...publicMessage } = message
|
||||
|
||||
@@ -345,8 +345,9 @@ Locales["en"] = {
|
||||
name = "Messages", newMessage = "New message from {sender}", compose = "New Message",
|
||||
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
|
||||
filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS",
|
||||
photo = "Photo", gif = "GIF", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
|
||||
photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
|
||||
attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos",
|
||||
contacts = "Contacts", shareContact = "Share Contact", noContactsToShare = "Save a contact first to share it here.", contactSaved = "Contact Saved",
|
||||
noPhotos = "Take a photo first to attach it here.", noVideos = "Record a video in Camera first.", searchGifs = "Search GIPHY", loadMore = "Load More",
|
||||
moreActions = "More Actions", contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number",
|
||||
call = "Call", messageAction = "Message", addContact = "Add Contact", deleteContact = "Delete Contact",
|
||||
@@ -363,7 +364,8 @@ Locales["en"] = {
|
||||
noResults = "No Results", today = "Today", yesterday = "Yesterday",
|
||||
errors = {
|
||||
invalid_number = "Enter a valid phone number.", invalid_message = "Enter a message.", invalid_voice = "The audio message is invalid.",
|
||||
invalid_attachment = "The attachment is invalid.",
|
||||
invalid_attachment = "The attachment is invalid.", invalid_contact = "The contact is invalid.",
|
||||
contact_not_found = "This contact is no longer available.",
|
||||
media_provider_unconfigured = "Photo uploads are not configured.",
|
||||
capture_provider_unavailable = "The screenshot resource is unavailable.", capture_failed = "The photo could not be captured.",
|
||||
video_provider_unavailable = "Video capture requires the screencapture resource.", video_capture_failed = "The video could not be recorded.",
|
||||
@@ -1019,6 +1021,11 @@ Locales["en"] = {
|
||||
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
|
||||
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
|
||||
shareProfile = "Share Profile", chooseConversation = "Choose a conversation",
|
||||
kinds = {
|
||||
contact = "Contact", document = "Document", link = "Link", location = "Location",
|
||||
note = "Note", photo = "Photo", playlist = "Playlist", post = "Post",
|
||||
profile = "Profile", text = "Message", track = "Track", video = "Video",
|
||||
},
|
||||
savedToNotes = "Saved to Notes.", copied = "Copied.", copy = "Copy", copyLink = "Copy Link",
|
||||
nearby = "People Nearby", history = "Transfer History", noHistory = "No transfers yet.",
|
||||
noNearby = "No visible players are nearby.", visibility = "Visibility", requestSent = "Waiting for acceptance...",
|
||||
|
||||
@@ -181,6 +181,10 @@ local function message_payload(row, profile_id)
|
||||
local deleted_everywhere = row.deleted_for_everyone == 1 or row.deleted_for_everyone == true
|
||||
local reactions = decode_array(row.reactions, "reactions")
|
||||
local waveform = row.media_waveform and decode_array(row.media_waveform, "media_waveform") or nil
|
||||
local share_payload = row.message_type == "share" and json.decode(row.media_payload or "") or nil
|
||||
if row.message_type == "share" and type(share_payload) ~= "table" then
|
||||
error(("[sky_phone] DarkChat message %s has an invalid share payload."):format(tostring(row.id)))
|
||||
end
|
||||
return {
|
||||
id = row.id,
|
||||
conversationId = row.conversation_id,
|
||||
@@ -189,12 +193,14 @@ local function message_payload(row, profile_id)
|
||||
messageType = deleted_everywhere and "system" or row.message_type,
|
||||
body = deleted_everywhere and "message_deleted" or row.body,
|
||||
mediaMime = deleted_everywhere and nil or row.media_mime,
|
||||
mediaPayload = not deleted_everywhere and row.message_type ~= "voice" and row.media_payload or nil,
|
||||
mediaPayload = not deleted_everywhere and row.message_type ~= "voice"
|
||||
and row.message_type ~= "share" and row.media_payload or nil,
|
||||
mediaDurationMs = deleted_everywhere and nil or tonumber(row.media_duration_ms),
|
||||
mediaWaveform = deleted_everywhere and nil or waveform,
|
||||
replyToId = row.reply_to_id,
|
||||
replyBody = row.reply_body,
|
||||
reactions = reactions,
|
||||
sharePayload = not deleted_everywhere and share_payload or nil,
|
||||
expiresAt = row.expires_at,
|
||||
createdAt = row.created_at,
|
||||
readAt = row.peer_last_read_at and row.peer_last_read_at >= row.created_at and row.peer_last_read_at or nil,
|
||||
@@ -538,6 +544,17 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
|
||||
end
|
||||
media_payload = media_url
|
||||
media_mime = message_type == "image" and "image/jpeg" or "video/mp4"
|
||||
elseif message_type == "share" then
|
||||
local share
|
||||
local share_error
|
||||
share, share_error, media_payload = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
|
||||
if not share then
|
||||
return { success = false, error = share_error or "invalid_payload" }
|
||||
end
|
||||
if body ~= "" and #body > Config.DarkChat.BodyMaxLength then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
body = body ~= "" and body or share.title
|
||||
else
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
@@ -579,7 +596,8 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
|
||||
conversationId = conversation_id,
|
||||
sender = profile.alias,
|
||||
messageType = message_type,
|
||||
preview = (message_type == "text" or message_type == "emoji") and body or message_type,
|
||||
preview = (message_type == "text" or message_type == "emoji" or message_type == "share")
|
||||
and body or message_type,
|
||||
})
|
||||
return { success = true, data = message }
|
||||
end)
|
||||
|
||||
@@ -521,7 +521,7 @@ local schema = {
|
||||
{ name = "recipient_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "recipient_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "message_type", type = "ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'" },
|
||||
{ name = "message_type", type = "ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text'" },
|
||||
{ name = "body", type = "VARCHAR(2000) NOT NULL" },
|
||||
{ name = "media_payload", type = "MEDIUMTEXT NULL" },
|
||||
{ name = "media_mime", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
@@ -1022,7 +1022,7 @@ local schema = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_profile_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'" },
|
||||
{ name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'" },
|
||||
{ name = "body", type = "TEXT NOT NULL" },
|
||||
{ name = "media_payload", type = "LONGTEXT NULL" },
|
||||
{ name = "media_mime", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
@@ -1680,8 +1680,9 @@ local schema = {
|
||||
{ name = "match_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "body", type = "VARCHAR(1000) NOT NULL" },
|
||||
{ name = "message_type", type = "ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'" },
|
||||
{ name = "message_type", type = "ENUM('text', 'image', 'gif', 'video', 'share') NOT NULL DEFAULT 'text'" },
|
||||
{ name = "media_url", type = "VARCHAR(2048) NULL" },
|
||||
{ name = "share_payload", type = "LONGTEXT NULL" },
|
||||
{ name = "media_duration_ms", type = "INT UNSIGNED NULL" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
@@ -2173,15 +2174,15 @@ Bridge.Database.Query([[
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_flare_messages`
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'image', 'gif', 'video', 'share') NOT NULL DEFAULT 'text'
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_sms_messages`
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text'
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_darkchat_messages`
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_marketplace_images`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
SkyPhoneEasyShare = {}
|
||||
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local active_transfers = {}
|
||||
local valid_kinds = {
|
||||
@@ -259,6 +261,14 @@ local function sanitize_payload(source, device, data)
|
||||
return payload, nil, encoded
|
||||
end
|
||||
|
||||
function SkyPhoneEasyShare.SanitizeChatPayload(source, data)
|
||||
local device, error_response = current_device(source)
|
||||
if not device then
|
||||
return nil, error_response.error
|
||||
end
|
||||
return sanitize_payload(source, device, data)
|
||||
end
|
||||
|
||||
local function transfer_for_source(transfer, source)
|
||||
local incoming = transfer.recipient_source == source
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local genders = { woman = true, man = true, nonbinary = true }
|
||||
local interests = { woman = true, man = true, nonbinary = true, everyone = true }
|
||||
local looking_for = { longTerm = true, dates = true, friends = true }
|
||||
local message_types = { text = true, image = true, gif = true, video = true }
|
||||
local message_types = { text = true, image = true, gif = true, video = true, share = true }
|
||||
|
||||
local function allowed_gif_url(value)
|
||||
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then
|
||||
@@ -386,6 +386,22 @@ local function validate_message(source, data)
|
||||
return { body = body, message_type = message_type }
|
||||
end
|
||||
|
||||
if message_type == "share" then
|
||||
local share, share_error, encoded = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
|
||||
if not share then
|
||||
return nil, share_error or "invalid_payload"
|
||||
end
|
||||
local body = trim(data.body) or ""
|
||||
if #body > 1000 then
|
||||
return nil, "invalid_message"
|
||||
end
|
||||
return {
|
||||
body = body ~= "" and body or share.title,
|
||||
message_type = message_type,
|
||||
share_payload = encoded,
|
||||
}
|
||||
end
|
||||
|
||||
if type(data.mediaAssetId) ~= "string" then
|
||||
return nil, "invalid_attachment"
|
||||
end
|
||||
@@ -427,6 +443,10 @@ local function validate_message(source, data)
|
||||
end
|
||||
|
||||
local function message_payload(row, account_id)
|
||||
local share_payload = row.message_type == "share" and json.decode(row.share_payload or "") or nil
|
||||
if row.message_type == "share" and type(share_payload) ~= "table" then
|
||||
error(("[sky_phone] Flare message %s has an invalid share payload."):format(tostring(row.id)))
|
||||
end
|
||||
return {
|
||||
id = row.id,
|
||||
direction = tonumber(row.sender_account_id) == tonumber(account_id) and "sent" or "received",
|
||||
@@ -435,6 +455,7 @@ local function message_payload(row, account_id)
|
||||
messageType = row.message_type or "text",
|
||||
mediaUrl = row.media_url,
|
||||
mediaDurationMs = tonumber(row.media_duration_ms),
|
||||
sharePayload = share_payload,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -670,7 +691,7 @@ Bridge.Callbacks.Register("sky_phone:flare:thread", function(source, data)
|
||||
WHERE `match_id` = ? AND `sender_account_id` <> ? AND `read_at` IS NULL
|
||||
]], { match.id, account.id })
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `sender_account_id`, `body`, `message_type`, `media_url`,
|
||||
SELECT `id`, `sender_account_id`, `body`, `message_type`, `media_url`, `share_payload`,
|
||||
`media_duration_ms`, UNIX_TIMESTAMP(`created_at`) * 1000 AS `created_at_ms`
|
||||
FROM `sky_phone_flare_messages`
|
||||
WHERE `match_id` = ?
|
||||
@@ -705,11 +726,11 @@ Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_flare_messages`
|
||||
(`id`, `match_id`, `sender_account_id`, `body`, `message_type`, `media_url`,
|
||||
`media_duration_ms`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`media_duration_ms`, `share_payload`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
]], {
|
||||
id, match.id, account.id, message.body, message.message_type, message.media_url,
|
||||
message.media_duration_ms,
|
||||
message.media_duration_ms, message.share_payload,
|
||||
})
|
||||
local recipient_account_id = tonumber(match.account_a_id) == tonumber(account.id)
|
||||
and tonumber(match.account_b_id) or tonumber(match.account_a_id)
|
||||
@@ -728,6 +749,7 @@ Bridge.Callbacks.Register("sky_phone:flare:send", function(source, data)
|
||||
message_type = message.message_type,
|
||||
media_url = message.media_url,
|
||||
media_duration_ms = message.media_duration_ms,
|
||||
share_payload = message.share_payload,
|
||||
}, account.id),
|
||||
}
|
||||
end)
|
||||
|
||||
@@ -97,9 +97,59 @@ local function current_device(source)
|
||||
return device
|
||||
end
|
||||
|
||||
local function shared_contact(device, contact_id)
|
||||
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
|
||||
return nil, "invalid_contact"
|
||||
end
|
||||
local condition
|
||||
local params
|
||||
if device.account_id then
|
||||
condition = "contact.`account_id` = ?"
|
||||
params = { contact_id, tonumber(device.account_id) }
|
||||
else
|
||||
condition = "contact.`account_id` IS NULL AND contact.`device_imei` = ?"
|
||||
params = { contact_id, device.imei }
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT contact.`name`, contact.`organization`, contact.`phone_number`, media.`url` AS `avatar_url`
|
||||
FROM `sky_phone_contacts` contact
|
||||
LEFT JOIN `sky_phone_media` media ON media.`id` = contact.`avatar_media_id`
|
||||
WHERE contact.`contact_id` = ? AND %s
|
||||
LIMIT 1
|
||||
]]):format(condition), params)
|
||||
local contact = rows[1]
|
||||
if not contact then
|
||||
return nil, "contact_not_found"
|
||||
end
|
||||
local name = trim(contact.name)
|
||||
local organization = trim(contact.organization)
|
||||
local number = SkyPhoneSimNumber.Normalize(
|
||||
contact.phone_number,
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
)
|
||||
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength
|
||||
or (organization and #organization > Config.Calls.ContactNameMaxLength) or not number
|
||||
then
|
||||
error(("[sky_phone] Contact %s cannot be shared because its stored data is invalid."):format(contact_id))
|
||||
end
|
||||
local snapshot = {
|
||||
avatar_url = contact.avatar_url,
|
||||
name = name,
|
||||
organization = organization ~= "" and organization or nil,
|
||||
phone_number = number,
|
||||
}
|
||||
return {
|
||||
data = snapshot,
|
||||
payload = json.encode(snapshot),
|
||||
}
|
||||
end
|
||||
|
||||
local function format_message(row)
|
||||
row.media_duration_ms = tonumber(row.media_duration_ms)
|
||||
row.media_asset_id = nil
|
||||
row.contact = nil
|
||||
row.share = nil
|
||||
if row.message_type == "voice" then
|
||||
local waveform = row.media_waveform and json.decode(row.media_waveform) or nil
|
||||
if type(waveform) ~= "table" then
|
||||
@@ -107,6 +157,33 @@ local function format_message(row)
|
||||
end
|
||||
row.media_waveform = waveform
|
||||
row.media_payload = nil
|
||||
elseif row.message_type == "contact" then
|
||||
local contact = row.media_payload and json.decode(row.media_payload) or nil
|
||||
if type(contact) ~= "table" or type(contact.name) ~= "string"
|
||||
or type(contact.phone_number) ~= "string"
|
||||
or (contact.avatar_url ~= nil and type(contact.avatar_url) ~= "string")
|
||||
or (contact.organization ~= nil and type(contact.organization) ~= "string")
|
||||
then
|
||||
error(("[sky_phone] Message %s has an invalid contact payload."):format(tostring(row.id)))
|
||||
end
|
||||
row.contact = contact
|
||||
row.media_payload = nil
|
||||
row.media_duration_ms = nil
|
||||
row.media_mime = nil
|
||||
row.media_waveform = nil
|
||||
elseif row.message_type == "share" then
|
||||
local share = row.media_payload and json.decode(row.media_payload) or nil
|
||||
if type(share) ~= "table" or type(share.appId) ~= "string"
|
||||
or type(share.kind) ~= "string" or type(share.title) ~= "string"
|
||||
or type(share.copyText) ~= "string"
|
||||
then
|
||||
error(("[sky_phone] Message %s has an invalid share payload."):format(tostring(row.id)))
|
||||
end
|
||||
row.share = share
|
||||
row.media_payload = nil
|
||||
row.media_duration_ms = nil
|
||||
row.media_mime = nil
|
||||
row.media_waveform = nil
|
||||
elseif attachment_assets[row.message_type] then
|
||||
if not valid_stored_attachment(row.message_type, row.media_payload) then
|
||||
error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id)))
|
||||
@@ -414,6 +491,8 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
local body = trim(data.body) or ""
|
||||
local voice = nil
|
||||
local attachment = nil
|
||||
local contact = nil
|
||||
local share = nil
|
||||
if message_type == "text" then
|
||||
if body == "" or #body > Config.Messages.BodyMaxLength then
|
||||
return { success = false, error = "invalid_message" }
|
||||
@@ -424,6 +503,25 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
return { success = false, error = "invalid_voice" }
|
||||
end
|
||||
body = ""
|
||||
elseif message_type == "contact" then
|
||||
local contact_error
|
||||
contact, contact_error = shared_contact(device, data.contactId)
|
||||
if not contact then
|
||||
return { success = false, error = contact_error }
|
||||
end
|
||||
body = contact.data.name
|
||||
elseif message_type == "share" then
|
||||
local share_error
|
||||
local encoded
|
||||
share, share_error, encoded = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
|
||||
if not share then
|
||||
return { success = false, error = share_error or "invalid_payload" }
|
||||
end
|
||||
if body ~= "" and #body > Config.Messages.BodyMaxLength then
|
||||
return { success = false, error = "invalid_message" }
|
||||
end
|
||||
share = { data = share, payload = encoded }
|
||||
body = body ~= "" and body or share.data.title
|
||||
elseif attachment_assets[message_type] then
|
||||
attachment = validate_attachment(source, device, message_type, data)
|
||||
if not attachment then
|
||||
@@ -459,7 +557,8 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
number,
|
||||
message_type,
|
||||
body,
|
||||
voice and voice.payload or attachment and attachment.payload or nil,
|
||||
voice and voice.payload or attachment and attachment.payload or contact and contact.payload
|
||||
or share and share.payload or nil,
|
||||
voice and voice.mime or attachment and attachment.mime or nil,
|
||||
voice and voice.duration or attachment and attachment.duration or nil,
|
||||
voice and voice.waveform or nil,
|
||||
|
||||
@@ -292,7 +292,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
|
||||
`recipient_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`sender_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`recipient_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text',
|
||||
`message_type` ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text',
|
||||
`body` VARCHAR(2000) NOT NULL,
|
||||
`media_payload` MEDIUMTEXT NULL,
|
||||
`media_mime` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL,
|
||||
@@ -540,8 +540,9 @@ CREATE TABLE IF NOT EXISTS `sky_phone_flare_messages` (
|
||||
`match_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`sender_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`body` VARCHAR(1000) NOT NULL,
|
||||
`message_type` ENUM('text', 'image', 'gif', 'video') NOT NULL DEFAULT 'text',
|
||||
`message_type` ENUM('text', 'image', 'gif', 'video', 'share') NOT NULL DEFAULT 'text',
|
||||
`media_url` VARCHAR(2048) NULL,
|
||||
`share_payload` LONGTEXT NULL,
|
||||
`media_duration_ms` INT UNSIGNED NULL,
|
||||
`read_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
Reference in New Issue
Block a user