mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 00:03:23 +00:00
1488 lines
45 KiB
Vue
1488 lines
45 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
kBlock,
|
|
kGlass,
|
|
kLink,
|
|
kList,
|
|
kListInput,
|
|
kListItem,
|
|
kMessage,
|
|
kMessagebar,
|
|
kMessages,
|
|
kMessagesTitle,
|
|
kNavbar,
|
|
kNavbarBackLink,
|
|
kPage,
|
|
kPreloader,
|
|
kSearchbar,
|
|
kToast,
|
|
kToolbarPane,
|
|
} from 'konsta/vue'
|
|
import {
|
|
ArrowUpCircle,
|
|
Camera,
|
|
Check,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ImagePlay,
|
|
Images,
|
|
ListFilter,
|
|
MessageCircle,
|
|
Mic,
|
|
Pencil,
|
|
Phone as PhoneIcon,
|
|
Plus,
|
|
Search,
|
|
SquarePen,
|
|
Trash2,
|
|
ContactRound,
|
|
UserPlus,
|
|
Video,
|
|
X,
|
|
} from 'lucide-vue-next'
|
|
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'
|
|
import { useEasyShareStore } from '@/stores/easyshare'
|
|
import { useMessagesStore } from '@/stores/messages'
|
|
import { useMessageMediaStore } from '@/stores/messageMedia'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
|
import type {
|
|
GifSearchResult,
|
|
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
|
|
const WAVEFORM_SAMPLES = 48
|
|
|
|
const phone = usePhoneStore()
|
|
const calls = useCallsStore()
|
|
const easyShare = useEasyShareStore()
|
|
const messages = useMessagesStore()
|
|
const messageMedia = useMessageMediaStore()
|
|
const router = useRouter()
|
|
const search = ref('')
|
|
const showUnreadOnly = ref(false)
|
|
const editingList = ref(false)
|
|
const selectedNumbers = ref<string[]>([])
|
|
const composerNumber = ref('')
|
|
const draft = 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<'contacts' | 'gifs' | null>(null)
|
|
const contactDetailsOpen = ref(false)
|
|
const contactEditing = ref(false)
|
|
const contactNameDraft = ref('')
|
|
const contactNumberDraft = ref('')
|
|
const gifQuery = ref('')
|
|
const gifResults = ref<GifSearchResult[]>([])
|
|
const gifLoading = ref(false)
|
|
const gifError = ref<string | null>(null)
|
|
const gifHasMore = ref(true)
|
|
const gifNextOffset = ref(0)
|
|
const recording = ref(false)
|
|
const recordingElapsedMs = ref(0)
|
|
const recordingLevels = ref<number[]>(Array(32).fill(0.16))
|
|
let toastTimer: ReturnType<typeof setTimeout> | undefined
|
|
let gifSearchTimer: ReturnType<typeof setTimeout> | undefined
|
|
let recordingTimer: ReturnType<typeof setInterval> | undefined
|
|
let mediaRecorder: MediaRecorder | undefined
|
|
let mediaStream: MediaStream | undefined
|
|
let audioContext: AudioContext | undefined
|
|
let analyser: AnalyserNode | undefined
|
|
let recordingStartedAt = 0
|
|
let recordingChunks: Blob[] = []
|
|
let recordingSamples: number[] = []
|
|
let recordingBytes = 0
|
|
let discardRecording = false
|
|
|
|
const hasSim = computed(() => Boolean(phone.device?.sim))
|
|
const filteredConversations = computed(() => {
|
|
const query = search.value.trim().toLocaleLowerCase(phone.lang)
|
|
return messages.conversations.filter((conversation) => {
|
|
if (showUnreadOnly.value && conversation.unread === 0) return false
|
|
if (!query) return true
|
|
return `${contactName(conversation.phoneNumber)} ${conversation.phoneNumber} ${conversationPreview(conversation)}`
|
|
.toLocaleLowerCase(phone.lang)
|
|
.includes(query)
|
|
})
|
|
})
|
|
const knownContactNumbers = computed(
|
|
() => new Set(calls.contacts.map((contact) => contact.phone_number)),
|
|
)
|
|
const contactAvatarUrls = computed(
|
|
() =>
|
|
new Map(
|
|
calls.contacts
|
|
.filter((contact) => Boolean(contact.avatar_url))
|
|
.map((contact) => [contact.phone_number, contact.avatar_url as string]),
|
|
),
|
|
)
|
|
const contactSuggestions = computed(() => {
|
|
const query = composerNumber.value.trim().toLocaleLowerCase(phone.lang)
|
|
if (!query) return calls.contacts.slice(0, 8)
|
|
return calls.contacts
|
|
.filter((contact) =>
|
|
`${contact.name} ${contact.phone_number}`
|
|
.toLocaleLowerCase(phone.lang)
|
|
.includes(query),
|
|
)
|
|
.slice(0, 8)
|
|
})
|
|
const activeTitle = computed(() =>
|
|
messages.activeNumber ? contactName(messages.activeNumber) : '',
|
|
)
|
|
const activeContact = computed(() =>
|
|
calls.contacts.find(
|
|
(contact) => contact.phone_number === messages.activeNumber,
|
|
),
|
|
)
|
|
const attachmentPanelOpen = computed(
|
|
() => emojiOpen.value || attachmentPicker.value !== null,
|
|
)
|
|
function contactName(number: string): string {
|
|
return (
|
|
calls.contacts.find((contact) => contact.phone_number === number)?.name ??
|
|
number
|
|
)
|
|
}
|
|
|
|
function conversationPreview(conversation: SmsConversation): string {
|
|
if (conversation.lastMessageType === 'image') {
|
|
return `📷 ${phone.t('Apps.messages.photo')}`
|
|
}
|
|
if (conversation.lastMessageType === 'gif') {
|
|
return `GIF ${phone.t('Apps.messages.gif')}`
|
|
}
|
|
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
|
|
}
|
|
|
|
function contactInitials(number: string): string {
|
|
const contact = calls.contacts.find(
|
|
(entry) => entry.phone_number === number,
|
|
)
|
|
if (!contact) return ''
|
|
return contact.name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase())
|
|
.join('')
|
|
}
|
|
|
|
function formatConversationDate(value: DatabaseDateValue): string {
|
|
const date = parseDatabaseDate(value)
|
|
if (Number.isNaN(date.getTime())) return String(value)
|
|
const today = new Date()
|
|
if (date.toDateString() === today.toDateString()) {
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
hour: '2-digit',
|
|
hourCycle: 'h23',
|
|
minute: '2-digit',
|
|
}).format(date)
|
|
}
|
|
const yesterday = new Date(today)
|
|
yesterday.setDate(today.getDate() - 1)
|
|
if (date.toDateString() === yesterday.toDateString()) {
|
|
return phone.t('Apps.messages.yesterday')
|
|
}
|
|
const daysAgo = Math.floor((today.getTime() - date.getTime()) / 86_400_000)
|
|
if (daysAgo < 7) {
|
|
return new Intl.DateTimeFormat(phone.lang, { weekday: 'long' }).format(date)
|
|
}
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
day: 'numeric',
|
|
month: 'numeric',
|
|
}).format(date)
|
|
}
|
|
|
|
function dayLabel(value: DatabaseDateValue): string {
|
|
const date = parseDatabaseDate(value)
|
|
if (Number.isNaN(date.getTime())) return String(value)
|
|
const today = new Date()
|
|
const yesterday = new Date(today)
|
|
yesterday.setDate(today.getDate() - 1)
|
|
if (date.toDateString() === today.toDateString()) {
|
|
return phone.t('Apps.messages.today')
|
|
}
|
|
if (date.toDateString() === yesterday.toDateString()) {
|
|
return phone.t('Apps.messages.yesterday')
|
|
}
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: date.getFullYear() === today.getFullYear() ? undefined : 'numeric',
|
|
}).format(date)
|
|
}
|
|
|
|
function timeLabel(value: DatabaseDateValue): string {
|
|
const date = parseDatabaseDate(value)
|
|
if (Number.isNaN(date.getTime())) return ''
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
hour: '2-digit',
|
|
hourCycle: 'h23',
|
|
minute: '2-digit',
|
|
}).format(date)
|
|
}
|
|
|
|
function formatRecordingTime(milliseconds: number): string {
|
|
const seconds = Math.floor(milliseconds / 1000)
|
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
function startsDay(message: SmsMessage, index: number): boolean {
|
|
if (index === 0) return true
|
|
return (
|
|
parseDatabaseDate(
|
|
messages.messages[index - 1].created_at,
|
|
).toDateString() !== parseDatabaseDate(message.created_at).toDateString()
|
|
)
|
|
}
|
|
|
|
function messageFooter(message: SmsMessage, index: number): string | undefined {
|
|
if (message.direction !== 'sent' || index !== messages.messages.length - 1) {
|
|
return undefined
|
|
}
|
|
if (message.delivery_status === 'sending') {
|
|
return phone.t('Apps.messages.sending')
|
|
}
|
|
if (message.delivery_status === 'failed') {
|
|
return phone.t('Apps.messages.notDelivered')
|
|
}
|
|
return phone.t('Apps.messages.delivered')
|
|
}
|
|
|
|
function eventValue(event: Event): string {
|
|
return (event.target as HTMLInputElement).value
|
|
}
|
|
|
|
function showToast(message: string): void {
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
toastText.value = message
|
|
toastOpened.value = true
|
|
toastTimer = setTimeout(() => (toastOpened.value = false), 2800)
|
|
}
|
|
|
|
function errorText(error?: string): string {
|
|
const known = [
|
|
'invalid_number',
|
|
'invalid_message',
|
|
'invalid_voice',
|
|
'invalid_attachment',
|
|
'invalid_contact',
|
|
'contact_not_found',
|
|
'media_provider_unconfigured',
|
|
'capture_provider_unavailable',
|
|
'capture_failed',
|
|
'gif_provider_unconfigured',
|
|
'gif_provider_unauthorized',
|
|
'gif_provider_rate_limited',
|
|
'gif_provider_failed',
|
|
'self_message',
|
|
'recipient_not_found',
|
|
'no_sim',
|
|
'rate_limited',
|
|
'request_failed',
|
|
]
|
|
return phone.t(
|
|
`Apps.messages.errors.${error && known.includes(error) ? error : 'default'}`,
|
|
)
|
|
}
|
|
|
|
async function scrollToBottom(animate = true): Promise<void> {
|
|
await nextTick()
|
|
const page = document.querySelector<HTMLElement>('.messages-thread-page')
|
|
if (!page) return
|
|
page.scrollTo({
|
|
behavior: animate ? 'smooth' : 'auto',
|
|
top: page.scrollHeight,
|
|
})
|
|
}
|
|
|
|
async function openConversation(conversation: SmsConversation): Promise<void> {
|
|
if (editingList.value) {
|
|
const index = selectedNumbers.value.indexOf(conversation.phoneNumber)
|
|
if (index >= 0) selectedNumbers.value.splice(index, 1)
|
|
else selectedNumbers.value.push(conversation.phoneNumber)
|
|
return
|
|
}
|
|
if (!(await messages.openThread(conversation.phoneNumber))) {
|
|
showToast(errorText())
|
|
return
|
|
}
|
|
composing.value = false
|
|
shareDraft.value = queuedSharePayload.value
|
|
queuedSharePayload.value = null
|
|
await scrollToBottom(false)
|
|
}
|
|
|
|
async function openEasyShareDraft(): Promise<void> {
|
|
const shared = easyShare.consumeChatDraft('messages')
|
|
if (!shared) return
|
|
if (!shared.targetId) {
|
|
messages.closeThread()
|
|
composing.value = false
|
|
queuedSharePayload.value = shared.payload
|
|
draft.value = ''
|
|
return
|
|
}
|
|
if (!(await messages.openThread(shared.targetId))) {
|
|
showToast(errorText('invalid_number'))
|
|
return
|
|
}
|
|
composing.value = false
|
|
queuedSharePayload.value = null
|
|
shareDraft.value = shared.payload
|
|
draft.value = ''
|
|
await scrollToBottom(false)
|
|
}
|
|
|
|
function toggleListEditing(): void {
|
|
editingList.value = !editingList.value
|
|
selectedNumbers.value = []
|
|
showUnreadOnly.value = false
|
|
}
|
|
|
|
async function deleteSelectedConversations(): Promise<void> {
|
|
if (!selectedNumbers.value.length) return
|
|
const deleted = await messages.deleteConversations(selectedNumbers.value)
|
|
if (!deleted) {
|
|
showToast(errorText())
|
|
return
|
|
}
|
|
editingList.value = false
|
|
selectedNumbers.value = []
|
|
}
|
|
|
|
function beginCompose(): void {
|
|
messages.closeThread()
|
|
composing.value = true
|
|
composerNumber.value = ''
|
|
draft.value = ''
|
|
}
|
|
|
|
async function chooseRecipient(number: string): Promise<void> {
|
|
composerNumber.value = number
|
|
if (!(await messages.openThread(number))) {
|
|
showToast(errorText('invalid_number'))
|
|
return
|
|
}
|
|
composing.value = false
|
|
shareDraft.value = queuedSharePayload.value
|
|
queuedSharePayload.value = null
|
|
await scrollToBottom(false)
|
|
}
|
|
|
|
function goBack(): void {
|
|
if (contactDetailsOpen.value) {
|
|
contactDetailsOpen.value = false
|
|
contactEditing.value = false
|
|
return
|
|
}
|
|
cancelVoiceRecording()
|
|
if (messages.activeNumber) messages.closeThread()
|
|
composing.value = false
|
|
composerNumber.value = ''
|
|
draft.value = ''
|
|
shareDraft.value = null
|
|
emojiOpen.value = false
|
|
attachmentMenuOpen.value = false
|
|
attachmentPicker.value = null
|
|
}
|
|
|
|
function appendEmoji(emoji: string): void {
|
|
draft.value += emoji
|
|
}
|
|
|
|
function openContactDetails(): void {
|
|
if (!messages.activeNumber) return
|
|
contactNameDraft.value = activeContact.value?.name ?? ''
|
|
contactNumberDraft.value = messages.activeNumber
|
|
contactEditing.value = false
|
|
contactDetailsOpen.value = true
|
|
}
|
|
|
|
async function saveContactDetails(): Promise<void> {
|
|
if (!contactNameDraft.value.trim() || !contactNumberDraft.value.trim()) return
|
|
const response = await calls.saveContact({
|
|
id: activeContact.value?.id,
|
|
name: contactNameDraft.value.trim(),
|
|
notes: activeContact.value?.notes ?? '',
|
|
organization: activeContact.value?.organization ?? '',
|
|
phoneNumber: contactNumberDraft.value,
|
|
})
|
|
if (!response.success) {
|
|
showToast(phone.t('Apps.messages.contactSaveFailed'))
|
|
return
|
|
}
|
|
contactEditing.value = false
|
|
contactNumberDraft.value =
|
|
response.data?.phone_number ?? contactNumberDraft.value
|
|
}
|
|
|
|
async function deleteActiveContact(): Promise<void> {
|
|
if (!activeContact.value) return
|
|
if (!(await calls.deleteContact(activeContact.value.id))) {
|
|
showToast(phone.t('Apps.messages.contactDeleteFailed'))
|
|
return
|
|
}
|
|
contactDetailsOpen.value = false
|
|
contactEditing.value = false
|
|
}
|
|
|
|
async function callActiveContact(): Promise<void> {
|
|
if (!messages.activeNumber) return
|
|
const response = await calls.dial(messages.activeNumber)
|
|
if (!response.success) showToast(phone.t('Apps.messages.callFailed'))
|
|
}
|
|
|
|
function toggleAttachmentMenu(): void {
|
|
attachmentMenuOpen.value = !attachmentMenuOpen.value
|
|
emojiOpen.value = false
|
|
attachmentPicker.value = null
|
|
}
|
|
|
|
function openGifPicker(): void {
|
|
attachmentMenuOpen.value = false
|
|
attachmentPicker.value = 'gifs'
|
|
emojiOpen.value = false
|
|
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
|
|
emojiOpen.value = true
|
|
}
|
|
|
|
function openMediaApp(
|
|
app: 'camera' | 'photos',
|
|
mediaType: 'photo' | 'video',
|
|
): void {
|
|
if (!messages.activeNumber) return
|
|
attachmentMenuOpen.value = false
|
|
messageMedia.begin(messages.activeNumber, mediaType)
|
|
void router.push({
|
|
path: `/apps/${app}`,
|
|
query: { messageAttachment: mediaType },
|
|
})
|
|
}
|
|
|
|
async function sendAttachment(
|
|
messageType: SmsAttachmentType,
|
|
mediaAssetId: string,
|
|
mediaDurationMs?: number,
|
|
): Promise<void> {
|
|
if (!messages.activeNumber || sending.value) return
|
|
attachmentMenuOpen.value = false
|
|
attachmentPicker.value = null
|
|
sending.value = true
|
|
const response = await messages.send({
|
|
mediaAssetId,
|
|
mediaDurationMs,
|
|
messageType,
|
|
})
|
|
sending.value = false
|
|
if (!response.success) showToast(errorText(response.error))
|
|
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
|
|
gifLoading.value = true
|
|
const response = await messages.searchGifs(
|
|
gifQuery.value,
|
|
reset ? 0 : gifNextOffset.value,
|
|
)
|
|
gifLoading.value = false
|
|
if (!response.success || !response.data) {
|
|
if (reset) gifResults.value = []
|
|
gifError.value = response.error ?? 'gif_provider_failed'
|
|
showToast(errorText(response.error))
|
|
return
|
|
}
|
|
const existingIds = new Set(
|
|
reset ? [] : gifResults.value.map((result) => result.id),
|
|
)
|
|
const uniqueResults = response.data.results.filter((result) => {
|
|
if (existingIds.has(result.id)) return false
|
|
existingIds.add(result.id)
|
|
return true
|
|
})
|
|
gifResults.value = reset
|
|
? uniqueResults
|
|
: [...gifResults.value, ...uniqueResults]
|
|
gifHasMore.value = response.data.hasMore
|
|
gifNextOffset.value = response.data.nextOffset
|
|
}
|
|
|
|
function queueGifSearch(): void {
|
|
if (gifSearchTimer) clearTimeout(gifSearchTimer)
|
|
gifSearchTimer = setTimeout(() => void loadGifs(true), 320)
|
|
}
|
|
|
|
async function sendTextMessage(): Promise<void> {
|
|
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(
|
|
shared
|
|
? { body, messageType: 'share', sharePayload: shared }
|
|
: { body, messageType: 'text' },
|
|
)
|
|
sending.value = false
|
|
if (!response.success) {
|
|
draft.value = body
|
|
shareDraft.value = shared
|
|
showToast(errorText(response.error))
|
|
}
|
|
await scrollToBottom()
|
|
}
|
|
|
|
function recordingMime(): string | null {
|
|
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
return 'audio/webm;codecs=opus'
|
|
}
|
|
if (MediaRecorder.isTypeSupported('audio/webm')) return 'audio/webm'
|
|
return null
|
|
}
|
|
|
|
function sampleMicrophone(): void {
|
|
if (!analyser) return
|
|
const values = new Uint8Array(analyser.fftSize)
|
|
analyser.getByteTimeDomainData(values)
|
|
let total = 0
|
|
for (const value of values) total += Math.abs(value - 128) / 128
|
|
const level = Math.max(0.08, Math.min(1, (total / values.length) * 4.5))
|
|
recordingSamples.push(level)
|
|
recordingLevels.value = [...recordingLevels.value.slice(1), level]
|
|
recordingElapsedMs.value = performance.now() - recordingStartedAt
|
|
if (recordingElapsedMs.value >= VOICE_MAX_DURATION_MS) {
|
|
stopVoiceRecording()
|
|
}
|
|
}
|
|
|
|
async function startVoiceRecording(): Promise<void> {
|
|
emojiOpen.value = false
|
|
if (
|
|
!navigator.mediaDevices?.getUserMedia ||
|
|
typeof MediaRecorder === 'undefined'
|
|
) {
|
|
showToast(phone.t('Apps.messages.microphoneUnavailable'))
|
|
return
|
|
}
|
|
const mime = recordingMime()
|
|
if (!mime) {
|
|
showToast(phone.t('Apps.messages.microphoneUnavailable'))
|
|
return
|
|
}
|
|
try {
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
autoGainControl: true,
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
},
|
|
})
|
|
recordingChunks = []
|
|
recordingSamples = []
|
|
recordingBytes = 0
|
|
discardRecording = false
|
|
mediaRecorder = new MediaRecorder(mediaStream, {
|
|
audioBitsPerSecond: 24_000,
|
|
mimeType: mime,
|
|
})
|
|
mediaRecorder.addEventListener('dataavailable', (event) => {
|
|
if (!event.data.size) return
|
|
recordingChunks.push(event.data)
|
|
recordingBytes += event.data.size
|
|
if (recordingBytes > VOICE_MAX_BYTES) stopVoiceRecording()
|
|
})
|
|
mediaRecorder.addEventListener('stop', () => void finishVoiceRecording())
|
|
audioContext = new AudioContext()
|
|
analyser = audioContext.createAnalyser()
|
|
analyser.fftSize = 128
|
|
audioContext.createMediaStreamSource(mediaStream).connect(analyser)
|
|
recordingStartedAt = performance.now()
|
|
recordingElapsedMs.value = 0
|
|
recordingLevels.value = Array(32).fill(0.16)
|
|
recording.value = true
|
|
mediaRecorder.start(250)
|
|
recordingTimer = setInterval(sampleMicrophone, 100)
|
|
} catch (error) {
|
|
console.error('[Messages] Could not start audio recording:', error)
|
|
cleanupRecorder()
|
|
showToast(phone.t('Apps.messages.microphoneUnavailable'))
|
|
}
|
|
}
|
|
|
|
function stopVoiceRecording(): void {
|
|
if (!mediaRecorder || mediaRecorder.state === 'inactive') return
|
|
mediaRecorder.stop()
|
|
}
|
|
|
|
function cancelVoiceRecording(): void {
|
|
discardRecording = true
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
|
|
else cleanupRecorder()
|
|
}
|
|
|
|
function cleanupRecorder(): void {
|
|
if (recordingTimer) clearInterval(recordingTimer)
|
|
recordingTimer = undefined
|
|
mediaStream?.getTracks().forEach((track) => track.stop())
|
|
void audioContext?.close()
|
|
mediaRecorder = undefined
|
|
mediaStream = undefined
|
|
audioContext = undefined
|
|
analyser = undefined
|
|
recording.value = false
|
|
}
|
|
|
|
function compressedWaveform(): number[] {
|
|
if (!recordingSamples.length) return Array(16).fill(0.12)
|
|
const result: number[] = []
|
|
const bucketSize = recordingSamples.length / WAVEFORM_SAMPLES
|
|
const count = Math.min(WAVEFORM_SAMPLES, recordingSamples.length)
|
|
for (let index = 0; index < count; index += 1) {
|
|
const start = Math.floor(index * bucketSize)
|
|
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
|
|
const bucket = recordingSamples.slice(start, end)
|
|
result.push(
|
|
Math.max(
|
|
0.08,
|
|
Math.min(
|
|
1,
|
|
bucket.reduce((sum, value) => sum + value, 0) / bucket.length,
|
|
),
|
|
),
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function blobBase64(blob: Blob): Promise<string> {
|
|
const buffer = await blob.arrayBuffer()
|
|
const bytes = new Uint8Array(buffer)
|
|
let binary = ''
|
|
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
|
|
}
|
|
return btoa(binary)
|
|
}
|
|
|
|
async function finishVoiceRecording(): Promise<void> {
|
|
const duration = Math.min(
|
|
VOICE_MAX_DURATION_MS,
|
|
Math.max(300, performance.now() - recordingStartedAt),
|
|
)
|
|
const mime = mediaRecorder?.mimeType ?? 'audio/webm'
|
|
const chunks = recordingChunks
|
|
const waveform = compressedWaveform()
|
|
const shouldDiscard = discardRecording
|
|
cleanupRecorder()
|
|
if (shouldDiscard) return
|
|
const blob = new Blob(chunks, { type: mime })
|
|
if (!blob.size || blob.size > VOICE_MAX_BYTES) {
|
|
showToast(phone.t('Apps.messages.recordingTooLarge'))
|
|
return
|
|
}
|
|
const payload = await blobBase64(blob)
|
|
sending.value = true
|
|
const response = await messages.send({
|
|
mediaDurationMs: Math.floor(duration),
|
|
mediaMime: mime,
|
|
mediaPayload: payload,
|
|
mediaWaveform: waveform,
|
|
messageType: 'voice',
|
|
})
|
|
sending.value = false
|
|
if (!response.success) showToast(errorText(response.error))
|
|
await scrollToBottom()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([messages.loadConversations(), calls.loadContacts()])
|
|
await openEasyShareDraft()
|
|
if (messages.activeNumber) {
|
|
const media = messageMedia.consume(messages.activeNumber)
|
|
if (media) {
|
|
void sendAttachment(
|
|
media.mediaType === 'photo' ? 'image' : 'video',
|
|
import.meta.env.DEV ? media.url : String(media.id),
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => easyShare.chatDraft,
|
|
(shared) => {
|
|
if (shared?.appId === 'messages') void openEasyShareDraft()
|
|
},
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
discardRecording = true
|
|
cleanupRecorder()
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
if (gifSearchTimer) clearTimeout(gifSearchTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<k-page
|
|
v-if="!hasSim"
|
|
class="messages-page !pt-[44px] !pb-[25px]"
|
|
:aria-label="phone.t('Apps.messages.name')"
|
|
>
|
|
<k-navbar large transparent :title="phone.t('Apps.messages.name')" />
|
|
<div class="messages-empty-state">
|
|
<span class="messages-empty-state__icon"
|
|
><MessageCircle :size="35"
|
|
/></span>
|
|
<h2>{{ phone.t('Apps.messages.noSim') }}</h2>
|
|
<p>{{ phone.t('Apps.messages.noSimBody') }}</p>
|
|
</div>
|
|
</k-page>
|
|
|
|
<k-page
|
|
v-else-if="!messages.activeNumber && !composing"
|
|
class="messages-page messages-inbox-page"
|
|
:aria-label="phone.t('Apps.messages.name')"
|
|
>
|
|
<header class="messages-inbox-header">
|
|
<k-glass
|
|
component="button"
|
|
type="button"
|
|
class="messages-inbox-header__edit"
|
|
@click="toggleListEditing"
|
|
>
|
|
<span>{{ phone.t(editingList ? 'Common.done' : 'Common.edit') }}</span>
|
|
</k-glass>
|
|
<strong>
|
|
{{
|
|
editingList
|
|
? phone.t('Apps.messages.selectedCount', {
|
|
count: String(selectedNumbers.length),
|
|
})
|
|
: phone.t('Apps.messages.name')
|
|
}}
|
|
</strong>
|
|
<k-glass
|
|
v-if="!editingList"
|
|
component="button"
|
|
type="button"
|
|
:class="{ active: showUnreadOnly }"
|
|
:aria-label="phone.t('Apps.messages.filterUnread')"
|
|
@click="showUnreadOnly = !showUnreadOnly"
|
|
>
|
|
<ListFilter :size="24" :stroke-width="2.25" />
|
|
</k-glass>
|
|
<k-glass
|
|
v-else
|
|
component="button"
|
|
type="button"
|
|
class="messages-inbox-header__delete"
|
|
:disabled="!selectedNumbers.length"
|
|
:aria-label="phone.t('Apps.messages.deleteSelected')"
|
|
@click="deleteSelectedConversations"
|
|
>
|
|
<Trash2 :size="18" />
|
|
</k-glass>
|
|
</header>
|
|
|
|
<div v-if="filteredConversations.length" class="messages-conversation-list">
|
|
<button
|
|
v-for="conversation in filteredConversations"
|
|
:key="conversation.phoneNumber"
|
|
type="button"
|
|
class="messages-conversation"
|
|
:class="{
|
|
'messages-conversation--unread': conversation.unread > 0,
|
|
'messages-conversation--selected': selectedNumbers.includes(
|
|
conversation.phoneNumber,
|
|
),
|
|
}"
|
|
@click="openConversation(conversation)"
|
|
>
|
|
<span v-if="editingList" class="messages-conversation__selection">
|
|
<Check
|
|
v-if="selectedNumbers.includes(conversation.phoneNumber)"
|
|
:size="14"
|
|
/>
|
|
</span>
|
|
<span
|
|
v-else-if="conversation.unread"
|
|
class="messages-conversation__dot"
|
|
/>
|
|
<span
|
|
class="messages-avatar"
|
|
:class="{
|
|
'messages-avatar--unknown': !knownContactNumbers.has(
|
|
conversation.phoneNumber,
|
|
),
|
|
}"
|
|
aria-hidden="true"
|
|
>
|
|
<img
|
|
v-if="contactAvatarUrls.get(conversation.phoneNumber)"
|
|
class="messages-avatar__image"
|
|
:src="contactAvatarUrls.get(conversation.phoneNumber)"
|
|
alt=""
|
|
/>
|
|
<span
|
|
v-else-if="knownContactNumbers.has(conversation.phoneNumber)"
|
|
class="messages-avatar__initials"
|
|
>
|
|
{{ contactInitials(conversation.phoneNumber) }}
|
|
</span>
|
|
<span v-else class="messages-avatar__placeholder">
|
|
<i />
|
|
<b />
|
|
</span>
|
|
</span>
|
|
<span class="messages-conversation__body">
|
|
<span class="messages-conversation__headline">
|
|
<strong>{{ contactName(conversation.phoneNumber) }}</strong>
|
|
<time>{{
|
|
formatConversationDate(conversation.lastMessageAt)
|
|
}}</time>
|
|
<ChevronRight :size="13" />
|
|
</span>
|
|
<span class="messages-conversation__preview">
|
|
{{ conversationPreview(conversation) }}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="messages-empty-state messages-empty-state--list">
|
|
<span class="messages-empty-state__icon"
|
|
><MessageCircle :size="35"
|
|
/></span>
|
|
<h2>
|
|
{{
|
|
phone.t(
|
|
search ? 'Apps.messages.noResults' : 'Apps.messages.noMessages',
|
|
)
|
|
}}
|
|
</h2>
|
|
<p v-if="!search">{{ phone.t('Apps.messages.noMessagesBody') }}</p>
|
|
<button v-if="!search" type="button" @click="beginCompose">
|
|
{{ phone.t('Apps.messages.compose') }}
|
|
</button>
|
|
</div>
|
|
|
|
<footer v-if="!editingList" class="messages-inbox-toolbar">
|
|
<div class="messages-inbox-search">
|
|
<k-searchbar
|
|
:value="search"
|
|
:placeholder="phone.t('Apps.messages.search')"
|
|
:colors="{
|
|
inputBgIos: 'bg-transparent',
|
|
placeholderIos: 'placeholder-[#8e8e93]',
|
|
}"
|
|
:clear-button="false"
|
|
:input-style="{
|
|
color: phone.isDarkMode ? '#f5f5f7' : '#111',
|
|
paddingRight: '48px',
|
|
}"
|
|
@input="search = eventValue($event)"
|
|
@clear="search = ''"
|
|
/>
|
|
<k-link
|
|
component="button"
|
|
icon-only
|
|
class="messages-inbox-search__voice"
|
|
:aria-label="phone.t('Apps.messages.search')"
|
|
>
|
|
<Mic :size="20" />
|
|
</k-link>
|
|
</div>
|
|
<k-glass
|
|
component="button"
|
|
type="button"
|
|
:aria-label="phone.t('Apps.messages.compose')"
|
|
@click="beginCompose"
|
|
>
|
|
<SquarePen :size="20" />
|
|
</k-glass>
|
|
</footer>
|
|
<footer v-else class="messages-edit-toolbar">
|
|
<span>{{
|
|
phone.t('Apps.messages.selectedCount', {
|
|
count: String(selectedNumbers.length),
|
|
})
|
|
}}</span>
|
|
<button
|
|
type="button"
|
|
:disabled="!selectedNumbers.length"
|
|
@click="deleteSelectedConversations"
|
|
>
|
|
<Trash2 :size="18" />
|
|
{{ phone.t('Apps.messages.deleteSelected') }}
|
|
</button>
|
|
</footer>
|
|
</k-page>
|
|
|
|
<k-page
|
|
v-else-if="composing"
|
|
class="messages-page messages-compose-page !pt-[44px] !pb-[25px]"
|
|
:aria-label="phone.t('Apps.messages.compose')"
|
|
>
|
|
<k-navbar :title="phone.t('Apps.messages.compose')">
|
|
<template #left>
|
|
<k-navbar-back-link
|
|
component="button"
|
|
:text="phone.t('Common.cancel')"
|
|
@click="goBack"
|
|
/>
|
|
</template>
|
|
</k-navbar>
|
|
<k-list class="messages-recipient-field" strong>
|
|
<k-list-input
|
|
:value="composerNumber"
|
|
:label="phone.t('Apps.messages.to')"
|
|
inputmode="tel"
|
|
autofocus
|
|
clear-button
|
|
@input="composerNumber = eventValue($event)"
|
|
@clear="composerNumber = ''"
|
|
@keyup.enter="chooseRecipient(composerNumber)"
|
|
/>
|
|
</k-list>
|
|
<k-list
|
|
v-if="contactSuggestions.length"
|
|
class="messages-contact-list"
|
|
strong
|
|
>
|
|
<k-list-item
|
|
v-for="contact in contactSuggestions"
|
|
:key="contact.id"
|
|
link
|
|
:title="contact.name"
|
|
:subtitle="contact.phone_number"
|
|
@click="chooseRecipient(contact.phone_number)"
|
|
>
|
|
<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>
|
|
</k-list>
|
|
<k-block v-else-if="messages.loading"><k-preloader /></k-block>
|
|
<button
|
|
v-else-if="composerNumber.trim()"
|
|
class="messages-number-action"
|
|
type="button"
|
|
@click="chooseRecipient(composerNumber)"
|
|
>
|
|
<MessageCircle :size="17" />
|
|
{{ composerNumber }}
|
|
</button>
|
|
</k-page>
|
|
|
|
<k-page
|
|
v-else
|
|
class="messages-page messages-thread-page"
|
|
:class="{ 'messages-thread-page--emoji': attachmentPanelOpen }"
|
|
:aria-label="activeTitle"
|
|
>
|
|
<header class="messages-chat-header">
|
|
<k-link
|
|
component="button"
|
|
icon-only
|
|
type="button"
|
|
class="messages-chat-header__back"
|
|
:aria-label="phone.t('Apps.messages.name')"
|
|
@click="goBack"
|
|
>
|
|
<ChevronLeft :size="28" :stroke-width="2.35" />
|
|
</k-link>
|
|
<div class="messages-chat-header__contact">
|
|
<span
|
|
class="messages-avatar messages-avatar--header"
|
|
:class="{ 'messages-avatar--unknown': !activeContact }"
|
|
>
|
|
<img
|
|
v-if="activeContact?.avatar_url"
|
|
class="messages-avatar__image"
|
|
:src="activeContact.avatar_url"
|
|
alt=""
|
|
/>
|
|
<span v-else-if="activeContact" class="messages-avatar__initials">{{
|
|
contactInitials(messages.activeNumber ?? '')
|
|
}}</span>
|
|
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
|
|
<i />
|
|
<b />
|
|
</span>
|
|
</span>
|
|
<k-link
|
|
component="button"
|
|
type="button"
|
|
class="messages-chat-header__name"
|
|
:aria-label="phone.t('Apps.messages.contactDetails')"
|
|
@click="openContactDetails"
|
|
>
|
|
<strong>{{ activeTitle }}</strong>
|
|
<ChevronRight :size="13" />
|
|
</k-link>
|
|
</div>
|
|
</header>
|
|
|
|
<section v-if="contactDetailsOpen" class="messages-contact-details">
|
|
<header>
|
|
<button
|
|
type="button"
|
|
:aria-label="phone.t('Common.back')"
|
|
@click="contactDetailsOpen = false"
|
|
>
|
|
<ChevronLeft :size="24" :stroke-width="2.35" />
|
|
</button>
|
|
<strong>{{ phone.t('Apps.messages.contactDetails') }}</strong>
|
|
<button
|
|
type="button"
|
|
@click="
|
|
contactEditing ? saveContactDetails() : (contactEditing = true)
|
|
"
|
|
>
|
|
<Check v-if="contactEditing" :size="19" />
|
|
<Pencil v-else :size="18" />
|
|
</button>
|
|
</header>
|
|
<div class="messages-contact-details__hero">
|
|
<span
|
|
class="messages-avatar messages-avatar--contact"
|
|
:class="{ 'messages-avatar--unknown': !activeContact }"
|
|
>
|
|
<img
|
|
v-if="activeContact?.avatar_url"
|
|
class="messages-avatar__image"
|
|
:src="activeContact.avatar_url"
|
|
alt=""
|
|
/>
|
|
<span v-else-if="activeContact" class="messages-avatar__initials">{{
|
|
contactInitials(messages.activeNumber ?? '')
|
|
}}</span>
|
|
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
|
|
<i />
|
|
<b />
|
|
</span>
|
|
</span>
|
|
<h2>{{ activeTitle }}</h2>
|
|
<small>{{ messages.activeNumber }}</small>
|
|
</div>
|
|
<div class="messages-contact-details__actions">
|
|
<button type="button" @click="callActiveContact">
|
|
<PhoneIcon :size="20" />
|
|
<span>{{ phone.t('Apps.messages.call') }}</span>
|
|
</button>
|
|
<button type="button" @click="contactDetailsOpen = false">
|
|
<MessageCircle :size="20" />
|
|
<span>{{ phone.t('Apps.messages.messageAction') }}</span>
|
|
</button>
|
|
</div>
|
|
<div class="messages-contact-details__fields">
|
|
<label>
|
|
<span>{{ phone.t('Apps.messages.contactName') }}</span>
|
|
<input
|
|
v-model="contactNameDraft"
|
|
:readonly="!contactEditing"
|
|
:placeholder="phone.t('Apps.messages.contactName')"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>{{ phone.t('Apps.messages.phoneNumber') }}</span>
|
|
<input
|
|
v-model="contactNumberDraft"
|
|
:readonly="!contactEditing"
|
|
inputmode="tel"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<button
|
|
v-if="!activeContact"
|
|
type="button"
|
|
class="messages-contact-details__primary"
|
|
@click="contactEditing = true"
|
|
>
|
|
<UserPlus :size="18" />
|
|
{{ phone.t('Apps.messages.addContact') }}
|
|
</button>
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="messages-contact-details__delete"
|
|
@click="deleteActiveContact"
|
|
>
|
|
<Trash2 :size="18" />
|
|
{{ phone.t('Apps.messages.deleteContact') }}
|
|
</button>
|
|
</section>
|
|
|
|
<k-messages class="messages-bubbles">
|
|
<template
|
|
v-for="(message, index) in messages.messages"
|
|
:key="message.client_id ?? message.id"
|
|
>
|
|
<k-messages-title v-if="startsDay(message, index)">
|
|
<span class="messages-thread-timestamp">
|
|
<span>{{ phone.t('Apps.messages.smsLabel') }}</span>
|
|
<b
|
|
>{{ dayLabel(message.created_at) }},
|
|
{{ timeLabel(message.created_at) }}</b
|
|
>
|
|
</span>
|
|
</k-messages-title>
|
|
<k-message
|
|
:class="{
|
|
'messages-message--sending': message.delivery_status === 'sending',
|
|
'messages-message--failed': message.delivery_status === 'failed',
|
|
}"
|
|
:type="message.direction"
|
|
:text="message.message_type === 'text' ? message.body : undefined"
|
|
:text-footer="messageFooter(message, index)"
|
|
>
|
|
<template v-if="message.message_type !== 'text'" #text>
|
|
<VoiceMessageBubble
|
|
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>
|
|
</template>
|
|
</k-messages>
|
|
|
|
<section v-if="attachmentMenuOpen" class="messages-attachment-menu">
|
|
<button type="button" @click="openMediaApp('photos', 'photo')">
|
|
<span><Images :size="20" /></span>
|
|
{{ phone.t('Apps.messages.attachPhoto') }}
|
|
</button>
|
|
<button type="button" @click="openMediaApp('camera', 'photo')">
|
|
<span><Camera :size="20" /></span>
|
|
{{ phone.t('Apps.messages.takePhoto') }}
|
|
</button>
|
|
<button type="button" @click="openEmojiPicker">
|
|
<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') }}
|
|
</button>
|
|
<button type="button" @click="openMediaApp('photos', 'video')">
|
|
<span><Video :size="20" /></span>
|
|
{{ phone.t('Apps.messages.attachVideo') }}
|
|
</button>
|
|
</section>
|
|
|
|
<section v-if="attachmentPicker" class="messages-media-picker">
|
|
<header>
|
|
<strong>
|
|
{{
|
|
phone.t(
|
|
attachmentPicker === 'contacts'
|
|
? 'Apps.messages.contacts'
|
|
: 'Apps.messages.gifs',
|
|
)
|
|
}}
|
|
</strong>
|
|
<button type="button" @click="attachmentPicker = null">
|
|
{{ phone.t('Common.done') }}
|
|
</button>
|
|
</header>
|
|
<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
|
|
v-model="gifQuery"
|
|
type="search"
|
|
:placeholder="phone.t('Apps.messages.searchGifs')"
|
|
@input="queueGifSearch"
|
|
/>
|
|
</label>
|
|
<button
|
|
v-for="gif in gifResults"
|
|
:key="gif.id"
|
|
type="button"
|
|
:aria-label="gif.title"
|
|
@click="sendAttachment('gif', gif.url)"
|
|
>
|
|
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
|
|
</button>
|
|
<button
|
|
v-if="gifResults.length && gifHasMore && !gifLoading"
|
|
type="button"
|
|
class="messages-gif-more"
|
|
@click="loadGifs()"
|
|
>
|
|
{{ phone.t('Apps.messages.loadMore') }}
|
|
</button>
|
|
<div v-if="gifError && !gifLoading" class="messages-gif-error">
|
|
<ImagePlay :size="24" />
|
|
<strong>{{ errorText(gifError) }}</strong>
|
|
<button type="button" @click="loadGifs(true)">
|
|
{{ phone.t('Apps.messages.retryGifs') }}
|
|
</button>
|
|
</div>
|
|
<k-preloader v-if="gifLoading" class="messages-gif-loading" />
|
|
</div>
|
|
</section>
|
|
|
|
<FullEmojiPicker
|
|
v-if="emojiOpen"
|
|
@close="emojiOpen = false"
|
|
@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"
|
|
class="messages-recorder__cancel"
|
|
:aria-label="phone.t('Apps.messages.cancelRecording')"
|
|
@click="cancelVoiceRecording"
|
|
>
|
|
<X :size="20" />
|
|
</button>
|
|
<span class="messages-recorder__dot" />
|
|
<time>{{ formatRecordingTime(recordingElapsedMs) }}</time>
|
|
<div class="messages-recorder__wave" aria-hidden="true">
|
|
<i
|
|
v-for="(level, index) in recordingLevels"
|
|
:key="index"
|
|
:style="{ height: `${Math.max(3, level * 24)}px` }"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="messages-recorder__send"
|
|
:aria-label="phone.t('Apps.messages.stopAndSend')"
|
|
@click="stopVoiceRecording"
|
|
>
|
|
<ArrowUpCircle :size="29" fill="currentColor" />
|
|
</button>
|
|
</section>
|
|
|
|
<k-messagebar
|
|
v-else
|
|
class="messages-messagebar"
|
|
:placeholder="phone.t('Apps.messages.message')"
|
|
:value="draft"
|
|
:disabled="sending"
|
|
@input="draft = eventValue($event)"
|
|
@keydown.enter.exact.prevent="sendTextMessage"
|
|
>
|
|
<template #left>
|
|
<k-toolbar-pane class="ios:h-10 messages-messagebar__tools">
|
|
<k-link
|
|
component="button"
|
|
icon-only
|
|
:aria-label="phone.t('Apps.messages.moreActions')"
|
|
:class="{ active: attachmentMenuOpen || attachmentPanelOpen }"
|
|
@click="toggleAttachmentMenu"
|
|
>
|
|
<Plus :size="25" />
|
|
</k-link>
|
|
</k-toolbar-pane>
|
|
</template>
|
|
<template #right>
|
|
<k-toolbar-pane class="ios:h-10">
|
|
<k-link
|
|
v-if="draft.trim() || shareDraft"
|
|
component="button"
|
|
icon-only
|
|
:disabled="sending"
|
|
:aria-label="phone.t('Apps.messages.send')"
|
|
@click="sendTextMessage"
|
|
>
|
|
<ArrowUpCircle :size="29" :stroke-width="2.4" />
|
|
</k-link>
|
|
<k-link
|
|
v-else
|
|
component="button"
|
|
icon-only
|
|
:disabled="sending"
|
|
:aria-label="phone.t('Apps.messages.recordVoice')"
|
|
@click="startVoiceRecording"
|
|
>
|
|
<Mic :size="21" :stroke-width="2.3" />
|
|
</k-link>
|
|
</k-toolbar-pane>
|
|
</template>
|
|
</k-messagebar>
|
|
</k-page>
|
|
|
|
<k-toast :opened="toastOpened" position="center" @click="toastOpened = false">
|
|
{{ toastText }}
|
|
</k-toast>
|
|
</template>
|