Files
sky_phone-sky-systems/frontend/src/views/apps/MessagesApp.vue
T
2026-08-17 22:19:43 +02:00

2473 lines
68 KiB
Vue

<script setup lang="ts">
import {
ArrowUpCircle,
Ban,
Camera,
Check,
ChevronRight,
Ellipsis,
ImagePlay,
Images,
Mail,
MessageCircle,
Mic,
Phone as PhoneIcon,
Plus,
SquarePen,
Trash2,
ContactRound,
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,
type MediaSelectionResult,
} from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import { handleEnterAction } from '@/utils/keyboard'
import { normalizeMailAddress } from '@/utils/mail'
import { compressWaveformSamples } from '@/utils/mediaRecorder'
import { sortContactsByMessageRecency } from '@/utils/messages'
import type {
GifSearchResult,
SmsAttachmentType,
SmsConversation,
SmsMessage,
SmsSharedContact,
} from '@/types/messages'
import type { PhoneContact } from '@/types/phone'
import type { EasySharePayload } from '@/types/easyshare'
import type { PhoneMedia } from '@/types/media'
import {
SkyAppPage,
SkyButton,
SkyDialog,
SkyDialogButton,
SkyDropdown,
SkyEmptyState,
SkyFab,
SkyField,
SkyGlass,
SkyLink,
SkyList,
SkyListItem,
SkyMessage,
SkyMessagebar,
SkyMessages,
SkyMessagesTitle,
SkyNavbar,
SkyPillNavigation,
SkyScrollArea,
SkySearchbar,
SkySettingsGroup,
SkySettingsRow,
SkySheet,
SkySpinner,
SkyNotification,
SkyToolbar,
} from '@/ui'
const VOICE_MAX_DURATION_MS = 30_000
const VOICE_MAX_BYTES = 135_000
const WAVEFORM_SAMPLES = 48
const MAX_PENDING_ATTACHMENTS = 6
type MessagesMediaContext = {
draft: string
pendingAttachments: PhoneMedia[]
shareDraft: EasySharePayload | null
}
type ConversationSort = 'newest' | 'oldest'
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 conversationSort = ref<ConversationSort>('newest')
const editingList = ref(false)
const selectedNumbers = ref<string[]>([])
const inboxMenuOpened = ref(false)
const inboxMenuTarget = ref<HTMLElement | null>(null)
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 pendingAttachments = ref<PhoneMedia[]>([])
const contactDetailsOpen = ref(false)
const blockDialogOpened = ref(false)
const blockingContact = ref(false)
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 recordingStarting = ref(false)
const recordingElapsedMs = ref(0)
const recordingLevels = ref<number[]>(Array(32).fill(0.16))
const threadBottom = ref<HTMLElement | null>(null)
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
let recordingRequestId = 0
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)
})
.sort((left, right) => {
const direction = conversationSort.value === 'newest' ? -1 : 1
return (
direction *
(parseDatabaseDate(left.lastMessageAt).getTime() -
parseDatabaseDate(right.lastMessageAt).getTime())
)
})
})
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)
const contacts = sortContactsByMessageRecency(
calls.contacts.filter((contact) => contact.canMessage !== false),
messages.conversations,
)
if (!query) return contacts.slice(0, 8)
return 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 activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const activeContactEmail = computed(() =>
normalizeMailAddress(activeContact.value?.email ?? ''),
)
const inboxMenuItems = computed(() => [
{
checked: conversationSort.value === 'newest',
group: 'sort',
groupLabel: phone.t('Apps.messages.sortLabel'),
id: 'sort-newest',
label: phone.t('Apps.messages.sortNewest'),
},
{
checked: conversationSort.value === 'oldest',
group: 'sort',
groupLabel: phone.t('Apps.messages.sortLabel'),
id: 'sort-oldest',
label: phone.t('Apps.messages.sortOldest'),
},
{
checked: !showUnreadOnly.value,
group: 'filter',
groupLabel: phone.t('Apps.messages.filterLabel'),
id: 'filter-all',
label: phone.t('Apps.messages.allMessages'),
separatorBefore: true,
},
{
checked: showUnreadOnly.value,
group: 'filter',
groupLabel: phone.t('Apps.messages.filterLabel'),
id: 'filter-unread',
label: phone.t('Apps.messages.unreadMessages'),
},
{
id: 'edit',
label: phone.t('Common.edit'),
separatorBefore: true,
},
])
const attachmentPanelOpen = computed(
() => emojiOpen.value || attachmentPicker.value !== null,
)
const composerHasContent = computed(
() =>
Boolean(draft.value.trim() || shareDraft.value) ||
pendingAttachments.value.length > 0,
)
function contactName(number: string): string {
return (
calls.contacts.find((contact) => contact.phone_number === number)?.name ??
number
)
}
function conversationLabel(conversation: SmsConversation): string {
const name = contactName(conversation.phoneNumber)
return conversation.unread > 0
? phone.t('Apps.messages.unreadConversation', {
count: String(conversation.unread),
name,
})
: name
}
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 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',
'blocked',
'request_failed',
]
return phone.t(
`Apps.messages.errors.${error && known.includes(error) ? error : 'default'}`,
)
}
async function scrollToBottom(animate = true): Promise<void> {
await nextTick()
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
threadBottom.value?.scrollIntoView({
block: 'end',
behavior: animate ? 'smooth' : 'auto',
})
}
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 = []
inboxMenuOpened.value = false
}
function openInboxMenu(event: MouseEvent): void {
inboxMenuTarget.value = event.currentTarget as HTMLElement
inboxMenuOpened.value = true
}
function dismissInboxMenu(): void {
inboxMenuOpened.value = false
}
function selectInboxMenuItem(id: string): void {
dismissInboxMenu()
if (id === 'sort-newest' || id === 'sort-oldest') {
conversationSort.value = id === 'sort-newest' ? 'newest' : 'oldest'
return
}
if (id === 'filter-all' || id === 'filter-unread') {
showUnreadOnly.value = id === 'filter-unread'
return
}
if (id === 'edit') toggleListEditing()
}
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 = ''
pendingAttachments.value = []
}
async function chooseRecipient(number: string): Promise<void> {
const contact = calls.contacts.find(
(candidate) => candidate.phone_number === number,
)
if (contact?.canMessage === false) {
showToast(phone.t('Apps.messages.messagingUnavailable'))
return
}
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
return
}
cancelVoiceRecording()
if (messages.activeNumber) messages.closeThread()
composing.value = false
composerNumber.value = ''
draft.value = ''
shareDraft.value = null
pendingAttachments.value = []
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
}
function appendEmoji(emoji: string): void {
draft.value += emoji
}
function openContactDetails(): void {
if (!messages.activeNumber) return
contactDetailsOpen.value = true
}
async function openActiveContactInPhone(): Promise<void> {
if (!messages.activeNumber) return
await router.push({
path: '/apps/phone',
query: activeContact.value
? { contactId: activeContact.value.id }
: { newContactNumber: messages.activeNumber },
})
}
async function mailActiveContact(): Promise<void> {
if (!activeContactEmail.value) return
await router.push({
path: '/apps/mail',
query: { compose: '1', to: activeContactEmail.value },
})
}
function confirmBlockActiveContact(): void {
if (!messages.activeNumber) return
blockDialogOpened.value = true
}
async function blockActiveContact(): Promise<void> {
if (!messages.activeNumber || blockingContact.value) return
blockingContact.value = true
const response = await calls.blockNumber(messages.activeNumber)
blockingContact.value = false
if (!response.success) {
showToast(phone.t('Apps.messages.blockContactFailed'))
return
}
blockDialogOpened.value = false
contactDetailsOpen.value = false
messages.closeThread()
await messages.loadConversations()
showToast(phone.t('Apps.messages.contactBlocked'))
}
async function callActiveContact(): Promise<void> {
if (!messages.activeNumber || activeContact.value?.canCall === false) 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 closeAttachmentPicker(): void {
attachmentPicker.value = null
}
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
const remainingSlots =
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
if (remainingSlots < 1) {
showToast(
phone.t('Apps.messages.attachmentLimit', {
count: String(MAX_PENDING_ATTACHMENTS),
}),
)
return
}
attachmentMenuOpen.value = false
messageMedia.begin(
messages.activeNumber,
mediaType,
'/apps/messages',
app === 'photos' && mediaType === 'photo' ? remainingSlots : 1,
{
draft: draft.value,
pendingAttachments: [...pendingAttachments.value],
shareDraft: shareDraft.value,
} satisfies MessagesMediaContext,
)
void router.push({
path: `/apps/${app}`,
query: { messageAttachment: mediaType },
})
}
function removePendingAttachment(id: number): void {
if (sending.value) return
pendingAttachments.value = pendingAttachments.value.filter(
(media) => media.id !== id,
)
}
function restoreMediaSelection(
selection: MediaSelectionResult<MessagesMediaContext> | null,
): void {
if (!selection) return
draft.value = selection.context?.draft ?? ''
shareDraft.value = selection.context?.shareDraft ?? null
const combined = [
...(selection.context?.pendingAttachments ?? []),
...selection.media,
]
const seen = new Set<number>()
pendingAttachments.value = combined
.filter((media) => {
if (seen.has(media.id)) return false
seen.add(media.id)
return true
})
.slice(0, MAX_PENDING_ATTACHMENTS)
}
async function sendAttachment(
messageType: SmsAttachmentType,
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (!messages.activeNumber || !activeCanMessage.value || 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 ||
!activeCanMessage.value ||
!composerHasContent.value ||
sending.value
) {
return
}
const body = draft.value
const shared = shareDraft.value
const queuedAttachments = [...pendingAttachments.value]
emojiOpen.value = false
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
await scrollToBottom()
let sendError: string | undefined
for (const [index, media] of queuedAttachments.entries()) {
const response = await messages.send(
{
body:
!shared && index === queuedAttachments.length - 1 ? body : undefined,
mediaAssetId: import.meta.env.DEV ? media.url : String(media.id),
messageType: media.mediaType === 'photo' ? 'image' : 'video',
},
{ discardFailedOptimistic: true },
)
if (!response.success) {
sendError = response.error ?? 'request_failed'
break
}
pendingAttachments.value = pendingAttachments.value.filter(
(pending) => pending.id !== media.id,
)
}
if (!sendError && shared) {
const response = await messages.send({
body,
messageType: 'share',
sharePayload: shared,
})
if (!response.success) sendError = response.error ?? 'request_failed'
} else if (!sendError && queuedAttachments.length === 0) {
const response = await messages.send({ body, messageType: 'text' })
if (!response.success) sendError = response.error ?? 'request_failed'
}
sending.value = false
if (sendError) {
showToast(errorText(sendError))
} else {
draft.value = ''
shareDraft.value = null
}
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> {
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
return
}
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
}
const requestId = ++recordingRequestId
recordingStarting.value = true
let requestedStream: MediaStream | undefined
try {
requestedStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
if (requestId !== recordingRequestId) {
requestedStream.getTracks().forEach((track) => track.stop())
return
}
mediaStream = requestedStream
requestedStream = undefined
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) {
requestedStream?.getTracks().forEach((track) => track.stop())
if (requestId !== recordingRequestId) return
console.error('[Messages] Could not start audio recording:', error)
cleanupRecorder()
showToast(phone.t('Apps.messages.microphoneUnavailable'))
} finally {
if (requestId === recordingRequestId) recordingStarting.value = false
}
}
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 {
recordingRequestId += 1
recordingStarting.value = false
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[] {
return compressWaveformSamples(recordingSamples, WAVEFORM_SAMPLES)
}
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 || !activeCanMessage.value) 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) {
restoreMediaSelection(
messageMedia.consumeMany<MessagesMediaContext>(messages.activeNumber),
)
await scrollToBottom(false)
}
})
watch(
() => easyShare.chatDraft,
(shared) => {
if (shared?.appId === 'messages') void openEasyShareDraft()
},
)
onBeforeUnmount(() => {
discardRecording = true
cleanupRecorder()
if (toastTimer) clearTimeout(toastTimer)
if (gifSearchTimer) clearTimeout(gifSearchTimer)
})
</script>
<template>
<SkyAppPage
v-if="!hasSim"
class="messages-sky-page"
:label="phone.t('Apps.messages.name')"
:dark="phone.isDarkMode"
accent="#34c759"
accent-soft="rgba(52, 199, 89, 0.16)"
>
<SkyNavbar variant="large" :title="phone.t('Apps.messages.name')" />
<SkyScrollArea padded class="messages-sky-state-scroll">
<SkyEmptyState
:title="phone.t('Apps.messages.noSim')"
:body="phone.t('Apps.messages.noSimBody')"
>
<template #icon><MessageCircle :size="35" /></template>
</SkyEmptyState>
</SkyScrollArea>
</SkyAppPage>
<SkyAppPage
v-else-if="!messages.activeNumber && !composing"
class="messages-sky-page messages-sky-inbox"
:label="phone.t('Apps.messages.name')"
:dark="phone.isDarkMode"
accent="#34c759"
accent-soft="rgba(52, 199, 89, 0.16)"
>
<SkyNavbar
variant="large"
:title="
editingList
? phone.t('Apps.messages.selectedCount', {
count: String(selectedNumbers.length),
})
: phone.t('Apps.messages.name')
"
>
<template #right>
<SkyLink
v-if="editingList"
class="messages-sky-edit"
@click="toggleListEditing"
>
{{ phone.t('Common.done') }}
</SkyLink>
<SkyLink
v-else
icon-only
class="messages-sky-inbox-menu-trigger"
aria-haspopup="menu"
:aria-expanded="inboxMenuOpened"
aria-controls="messages-inbox-menu"
:aria-label="phone.t('Apps.messages.inboxActions')"
@click="openInboxMenu"
>
<Ellipsis :size="22" />
</SkyLink>
</template>
</SkyNavbar>
<SkyScrollArea
padded
:with-tabbar="editingList"
class="messages-sky-inbox-scroll"
>
<SkyList
v-if="filteredConversations.length"
flush
class="messages-sky-conversation-list"
>
<SkyListItem
v-for="conversation in filteredConversations"
:key="conversation.phoneNumber"
link
link-component="button"
:chevron="false"
:active="selectedNumbers.includes(conversation.phoneNumber)"
:link-props="
editingList
? {
'aria-pressed': selectedNumbers.includes(
conversation.phoneNumber,
),
}
: {}
"
:aria-label="conversationLabel(conversation)"
class="messages-sky-conversation"
:class="{
'messages-sky-conversation--unread': conversation.unread > 0,
}"
@click="openConversation(conversation)"
>
<template #media>
<span class="messages-conversation-media" aria-hidden="true">
<span
class="messages-sky-unread-slot"
:class="{
'is-visible': !editingList && conversation.unread > 0,
}"
/>
<span
class="messages-avatar"
:class="{
'messages-avatar--unknown': !knownContactNumbers.has(
conversation.phoneNumber,
),
}"
>
<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
v-if="editingList"
class="messages-sky-selection"
:class="{
'is-selected': selectedNumbers.includes(
conversation.phoneNumber,
),
}"
>
<Check
v-if="selectedNumbers.includes(conversation.phoneNumber)"
:size="13"
/>
</span>
</span>
</span>
</template>
<template #title>
{{ contactName(conversation.phoneNumber) }}
</template>
<template #after>
<span class="messages-sky-conversation-time">
<time>{{
formatConversationDate(conversation.lastMessageAt)
}}</time>
<ChevronRight :size="15" />
</span>
</template>
<template #subtitle>
{{ conversationPreview(conversation) }}
</template>
</SkyListItem>
</SkyList>
<SkyEmptyState
v-else
class="messages-sky-empty"
:title="
phone.t(
search || showUnreadOnly
? 'Apps.messages.noResults'
: 'Apps.messages.noMessages',
)
"
:body="
search || showUnreadOnly
? ''
: phone.t('Apps.messages.noMessagesBody')
"
>
<template #icon><MessageCircle :size="32" /></template>
<template v-if="!search && !showUnreadOnly" #actions>
<SkyButton rounded @click="beginCompose">
{{ phone.t('Apps.messages.compose') }}
</SkyButton>
</template>
</SkyEmptyState>
</SkyScrollArea>
<SkyToolbar
v-if="!editingList"
class="messages-sky-inbox-toolbar"
component="footer"
:aria-label="phone.t('Apps.messages.search')"
>
<SkySearchbar
v-model="search"
class="messages-sky-search"
:label="phone.t('Apps.messages.search')"
:placeholder="phone.t('Apps.messages.search')"
:clear-label="phone.t('Common.clear')"
/>
<SkyFab
variant="neutral"
:aria-label="phone.t('Apps.messages.compose')"
@click="beginCompose"
>
<template #icon><SquarePen :size="21" /></template>
</SkyFab>
</SkyToolbar>
<SkyPillNavigation
v-if="editingList"
layout="compact"
align="center"
class="messages-sky-edit-navigation"
:label="phone.t('Apps.messages.deleteSelected')"
>
<SkyButton
rounded
variant="danger"
:disabled="!selectedNumbers.length"
@click="deleteSelectedConversations"
>
<Trash2 :size="18" />
{{ phone.t('Apps.messages.deleteSelected') }}
</SkyButton>
</SkyPillNavigation>
<SkyDropdown
id="messages-inbox-menu"
:items="inboxMenuItems"
:label="phone.t('Apps.messages.inboxActions')"
:opened="inboxMenuOpened"
:target="inboxMenuTarget"
@backdropclick="dismissInboxMenu"
@escape="dismissInboxMenu"
@positionerror="dismissInboxMenu"
@select="selectInboxMenuItem"
/>
</SkyAppPage>
<SkyAppPage
v-else-if="composing"
class="messages-sky-page messages-sky-compose"
:label="phone.t('Apps.messages.compose')"
:dark="phone.isDarkMode"
accent="#34c759"
accent-soft="rgba(52, 199, 89, 0.16)"
>
<SkyNavbar :title="phone.t('Apps.messages.compose')">
<template #right>
<SkyLink @click="goBack">
{{ phone.t('Common.cancel') }}
</SkyLink>
</template>
</SkyNavbar>
<SkyScrollArea padded class="messages-sky-compose-scroll">
<SkyList class="messages-recipient-field" density="compact" flush>
<SkyField
v-model="composerNumber"
:label="phone.t('Apps.messages.to')"
layout="inline"
input-mode="tel"
type="tel"
:clear-button="true"
:clear-label="phone.t('Common.clear')"
@clear="composerNumber = ''"
@keyup.enter="chooseRecipient(composerNumber)"
/>
</SkyList>
<SkyList
v-if="contactSuggestions.length"
class="messages-contact-list"
flush
>
<SkyListItem
v-for="contact in contactSuggestions"
:key="contact.id"
link
link-component="button"
strong-title="auto"
: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>
</SkyListItem>
</SkyList>
<div v-else-if="messages.loading" class="messages-sky-loading">
<SkySpinner :label="phone.t('Common.loading')" />
</div>
<SkyButton
v-else-if="composerNumber.trim()"
rounded
tonal
class="messages-number-action"
@click="chooseRecipient(composerNumber)"
>
<MessageCircle :size="17" />
{{ composerNumber }}
</SkyButton>
</SkyScrollArea>
</SkyAppPage>
<SkyAppPage
v-else
class="messages-sky-page messages-sky-thread"
:class="{
'messages-sky-thread--panel': attachmentPanelOpen,
'messages-sky-thread--share': Boolean(shareDraft),
}"
:label="activeTitle"
:dark="phone.isDarkMode"
accent="#34c759"
accent-soft="rgba(52, 199, 89, 0.16)"
>
<SkyNavbar
class="messages-sky-thread-navbar"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
:title="activeTitle"
show-back
back-appearance="surface"
:back-label="phone.t('Common.back')"
@back="goBack"
>
<template #title>
<button
type="button"
class="messages-sky-thread-contact"
:aria-label="phone.t('Apps.messages.contactDetails')"
@click="openContactDetails"
>
<span
class="messages-avatar messages-avatar--thread"
: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>
<span>{{ activeTitle }}</span>
<ChevronRight :size="13" />
</button>
</template>
</SkyNavbar>
<SkyAppPage
v-if="contactDetailsOpen"
class="messages-contact-overlay"
component="section"
:label="phone.t('Apps.messages.contactDetails')"
:dark="phone.isDarkMode"
accent="#34c759"
accent-soft="rgba(52, 199, 89, 0.16)"
>
<SkyNavbar
:title="phone.t('Apps.messages.contactDetails')"
show-back
back-appearance="surface"
:back-label="phone.t('Common.back')"
@back="contactDetailsOpen = false"
/>
<SkyScrollArea padded class="messages-contact-overlay__scroll">
<div class="messages-contact-profile__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 v-if="activeContact?.readonly">
{{ phone.t('Apps.phone.officialContact') }}
</small>
</div>
<div class="messages-contact-profile__actions">
<SkyButton
v-if="activeContact?.canCall !== false"
icon-only
rounded
tonal
:aria-label="phone.t('Apps.messages.call')"
@click="callActiveContact"
>
<PhoneIcon :size="22" />
</SkyButton>
<SkyButton
v-if="activeCanMessage"
icon-only
rounded
tonal
:aria-label="phone.t('Apps.messages.messageAction')"
@click="contactDetailsOpen = false"
>
<MessageCircle :size="22" />
</SkyButton>
<SkyButton
v-if="activeContactEmail"
icon-only
rounded
tonal
:aria-label="phone.t('Apps.phone.mail')"
@click="mailActiveContact"
>
<Mail :size="22" />
</SkyButton>
</div>
<SkySettingsGroup
class="messages-contact-profile__details"
:title="phone.t('Apps.messages.details')"
>
<SkySettingsRow
:title="phone.t('Apps.phone.mobile')"
:value="messages.activeNumber ?? ''"
/>
<SkySettingsRow
v-if="activeContact?.organization"
:title="phone.t('Apps.messages.company')"
:value="activeContact.organization"
/>
<SkySettingsRow
v-if="activeContactEmail"
:title="phone.t('Apps.phone.mail')"
:value="activeContactEmail"
/>
<SkySettingsRow
v-if="activeContact?.notes"
:title="phone.t('Apps.phone.notes')"
:description="activeContact.notes"
/>
</SkySettingsGroup>
<SkySettingsGroup
class="messages-contact-profile__options"
:aria-label="phone.t('Apps.messages.contactActions')"
>
<SkySettingsRow
kind="navigation"
:title="
phone.t(
activeContact
? 'Apps.messages.showInContacts'
: 'Apps.messages.addContact',
)
"
@activate="openActiveContactInPhone"
>
<template #leading><ContactRound :size="20" /></template>
</SkySettingsRow>
<SkySettingsRow
kind="action"
tone="danger"
:title="phone.t('Apps.messages.blockContact')"
@activate="confirmBlockActiveContact"
>
<template #leading><Ban :size="20" /></template>
</SkySettingsRow>
</SkySettingsGroup>
</SkyScrollArea>
</SkyAppPage>
<SkyScrollArea
padded
class="messages-sky-thread-scroll"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<SkyMessages class="messages-bubbles">
<template
v-for="(message, index) in messages.messages"
:key="message.client_id ?? message.id"
>
<SkyMessagesTitle 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>
</SkyMessagesTitle>
<SkyMessage
: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>
</SkyMessage>
</template>
</SkyMessages>
<span
ref="threadBottom"
class="messages-thread-bottom"
aria-hidden="true"
/>
</SkyScrollArea>
<section
v-if="activeCanMessage && attachmentMenuOpen"
class="messages-attachment-menu"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<SkyGlass
component="button"
type="button"
@click="openMediaApp('photos', 'photo')"
>
<span><Images :size="20" /></span>
{{ phone.t('Apps.messages.attachPhoto') }}
</SkyGlass>
<SkyGlass
component="button"
type="button"
@click="openMediaApp('camera', 'photo')"
>
<span><Camera :size="20" /></span>
{{ phone.t('Apps.messages.takePhoto') }}
</SkyGlass>
<SkyGlass component="button" type="button" @click="openEmojiPicker">
<span class="messages-action-emoji">😀</span>
{{ phone.t('Apps.messages.emoji') }}
</SkyGlass>
<SkyGlass component="button" type="button" @click="openContactPicker">
<span><ContactRound :size="20" /></span>
{{ phone.t('Apps.messages.shareContact') }}
</SkyGlass>
<SkyGlass component="button" type="button" @click="openGifPicker">
<span><ImagePlay :size="20" /></span>
{{ phone.t('Apps.messages.attachGif') }}
</SkyGlass>
<SkyGlass
component="button"
type="button"
@click="openMediaApp('photos', 'video')"
>
<span><Video :size="20" /></span>
{{ phone.t('Apps.messages.attachVideo') }}
</SkyGlass>
</section>
<SkySheet
class="messages-media-picker-sheet"
:opened="activeCanMessage && attachmentPicker !== null"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
"
swipe-to-close
grabber-clickable
:grabber-label="phone.t('Common.close')"
@backdropclick="closeAttachmentPicker"
@escape="closeAttachmentPicker"
@grabberclick="closeAttachmentPicker"
@swipeclose="closeAttachmentPicker"
>
<section
class="messages-media-picker"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<header>
<strong>
{{
phone.t(
attachmentPicker === 'contacts'
? 'Apps.messages.contacts'
: 'Apps.messages.gifs',
)
}}
</strong>
<SkyLink @click="closeAttachmentPicker">
{{ phone.t('Common.done') }}
</SkyLink>
</header>
<SkyList
v-if="attachmentPicker === 'contacts'"
inset
strong
class="messages-media-picker__contacts"
>
<SkyListItem
v-for="contact in calls.contacts"
:key="contact.id"
link
link-component="button"
: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>
</SkyListItem>
<p v-if="!calls.contacts.length" class="messages-media-picker__empty">
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div v-else class="messages-media-picker__gifs">
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
:label="phone.t('Apps.messages.searchGifs')"
:placeholder="phone.t('Apps.messages.searchGifs')"
:clear-label="phone.t('Common.clear')"
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@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>
<SkySpinner
v-if="gifLoading"
class="messages-gif-loading"
:label="phone.t('Common.loading')"
/>
</div>
</section>
</SkySheet>
<FullEmojiPicker
v-if="activeCanMessage && emojiOpen"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@close="emojiOpen = false"
@pick="appendEmoji"
/>
<div
v-if="activeCanMessage && shareDraft && !recording"
class="shared-composer-preview"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<SharedContentCard compact :payload="shareDraft" variant="messages" />
<SkyLink
icon-only
:aria-label="phone.t('Common.close')"
@click="shareDraft = null"
>
<X :size="15" />
</SkyLink>
</div>
<section
v-if="activeCanMessage && recording"
class="messages-recorder"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<SkyLink
icon-only
class="messages-recorder__cancel"
:aria-label="phone.t('Apps.messages.cancelRecording')"
@click="cancelVoiceRecording"
>
<X :size="20" />
</SkyLink>
<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>
<SkyButton
icon-only
rounded
tonal
class="messages-recorder__send"
:aria-label="phone.t('Apps.messages.stopAndSend')"
@click="stopVoiceRecording"
>
<ArrowUpCircle :size="27" :stroke-width="2.4" />
</SkyButton>
</section>
<div
v-else-if="activeCanMessage"
class="messages-sky-composer-shell"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
<div
v-if="pendingAttachments.length"
class="messages-pending-media"
role="list"
:aria-label="phone.t('Apps.messages.attachmentPreview')"
>
<div
v-for="(media, index) in pendingAttachments"
:key="media.id"
class="messages-pending-media__item"
role="listitem"
>
<img
v-if="media.mediaType === 'photo' || media.thumbnailUrl"
:src="media.thumbnailUrl ?? media.url"
:alt="
phone.t(
media.mediaType === 'video'
? 'Apps.photos.videoAlt'
: 'Apps.photos.photoAlt',
)
"
/>
<video
v-else
:src="media.url"
:aria-label="phone.t('Apps.photos.videoAlt')"
muted
playsinline
preload="metadata"
/>
<button
type="button"
:disabled="sending"
:aria-label="
phone.t('Apps.messages.removeAttachment', {
number: String(index + 1),
})
"
@click="removePendingAttachment(media.id)"
>
<X :size="13" :stroke-width="2.5" />
</button>
</div>
</div>
<div class="messages-sky-composer-row">
<SkyGlass
component="button"
type="button"
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
:class="{ active: attachmentMenuOpen || attachmentPanelOpen }"
:disabled="sending"
:aria-label="phone.t('Apps.messages.moreActions')"
@click="toggleAttachmentMenu"
>
<Plus :size="24" />
</SkyGlass>
<SkyGlass
component="div"
:highlight="false"
class="messages-sky-composer-pill"
>
<SkyMessagebar
v-model="draft"
class="messages-sky-messagebar"
embedded
:outline="false"
:placeholder="phone.t('Apps.messages.message')"
:disabled="sending"
@keydown.enter.exact="handleEnterAction($event, sendTextMessage)"
>
<template #right>
<SkyLink
v-if="composerHasContent"
icon-only
class="messages-sky-messagebar__send"
:disabled="sending"
:aria-label="phone.t('Apps.messages.send')"
@click="sendTextMessage"
>
<ArrowUpCircle :size="29" :stroke-width="2.4" />
</SkyLink>
<SkyLink
v-else
icon-only
class="messages-sky-messagebar__send"
:disabled="sending || recordingStarting"
:aria-busy="recordingStarting"
:aria-label="phone.t('Apps.messages.recordVoice')"
@click="startVoiceRecording"
>
<Mic :size="21" :stroke-width="2.3" />
</SkyLink>
</template>
</SkyMessagebar>
</SkyGlass>
</div>
</div>
<div
v-else
class="messages-sky-unavailable"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
>
{{ phone.t('Apps.messages.messagingUnavailable') }}
</div>
</SkyAppPage>
<SkyDialog
:opened="blockDialogOpened"
@backdropclick="blockDialogOpened = false"
@escape="blockDialogOpened = false"
>
<template #title>{{ phone.t('Apps.messages.blockContactTitle') }}</template>
<p>
{{
phone.t('Apps.messages.blockContactBody', {
name: activeTitle,
})
}}
</p>
<template #buttons>
<SkyDialogButton
:disabled="blockingContact"
@click="blockDialogOpened = false"
>
{{ phone.t('Common.cancel') }}
</SkyDialogButton>
<SkyDialogButton
strong
:disabled="blockingContact"
@click="blockActiveContact"
>
{{ phone.t('Apps.messages.blockContact') }}
</SkyDialogButton>
</template>
</SkyDialog>
<SkyNotification
:opened="toastOpened"
:text="toastText"
@click="toastOpened = false"
/>
</template>
<style scoped>
.messages-sky-state-scroll,
.messages-sky-loading {
display: grid;
place-items: center;
}
.messages-sky-state-scroll :deep(.sky-empty-state) {
margin: auto;
}
.messages-sky-edit {
color: var(--sky-app-accent);
font-size: 15px;
font-weight: 650;
}
.messages-sky-search {
min-width: 0;
width: 100%;
flex: 1;
}
.messages-sky-inbox-toolbar :deep(.sky-toolbar__inner) {
width: 100%;
}
.messages-sky-inbox-scroll {
padding-top: 4px;
}
.messages-sky-conversation-list {
margin: 0 calc(var(--sky-page-gutter) * -1);
}
.messages-sky-conversation :deep(.sky-list-item__row) {
min-height: 72px;
}
.messages-sky-conversation :deep(.sky-list-item__content) {
padding-top: 9px;
padding-bottom: 9px;
}
.messages-sky-conversation :deep(.sky-list-item__media) {
position: relative;
}
.messages-conversation-media {
display: grid;
grid-template-columns: 10px 50px;
align-items: center;
gap: var(--sky-space-2);
}
.messages-sky-conversation :deep(.sky-list-item__title) {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages-sky-conversation--unread :deep(.sky-list-item__title) {
font-weight: 760;
}
.messages-sky-conversation :deep(.sky-list-item__subtitle) {
overflow: hidden;
display: -webkit-box;
color: var(--sky-muted);
font-size: 12px;
line-height: 16px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.messages-sky-conversation-time {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--sky-muted);
font-size: 11px;
white-space: nowrap;
}
.messages-sky-selection {
position: absolute;
z-index: 2;
border-radius: 50%;
}
.messages-sky-selection {
right: -3px;
bottom: -2px;
width: 20px;
height: 20px;
display: grid;
place-items: center;
border: 2px solid var(--sky-bg);
background: var(--sky-surface-muted);
color: #fff;
}
.messages-sky-selection.is-selected {
background: var(--sky-app-accent);
}
.messages-sky-unread-slot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--sky-app-accent);
opacity: 0;
}
.messages-sky-unread-slot.is-visible {
opacity: 1;
}
.messages-sky-empty {
min-height: 65%;
}
.messages-sky-edit-navigation :deep(.sky-button) {
min-width: 132px;
}
.messages-sky-compose-scroll {
padding-top: 2px;
}
.messages-recipient-field,
.messages-contact-list {
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
border-top: 1px solid var(--sky-hairline);
border-bottom: 1px solid var(--sky-hairline);
background: transparent;
}
.messages-recipient-field :deep(.sky-field) {
min-height: 52px;
padding: 0 14px;
display: flex;
align-items: center;
background: transparent;
}
.messages-recipient-field :deep(.sky-field__inner) {
min-width: 0;
display: flex;
flex: 1;
align-items: center;
gap: 10px;
padding: 0;
}
.messages-contact-list :deep(.sky-list-item__row) {
background: transparent;
}
.messages-recipient-field :deep(.sky-field__label) {
margin: 0;
flex: none;
color: var(--sky-muted);
font-size: 14px;
line-height: 20px;
}
.messages-recipient-field :deep(.sky-field__control),
.messages-recipient-field :deep(.sky-field__input) {
min-height: 52px;
}
.messages-recipient-field :deep(.sky-field__control) {
flex: 1;
margin: 0;
}
.messages-recipient-field :deep(.sky-field__input) {
font-size: 15px;
}
.messages-number-action {
margin: 0;
}
.messages-sky-loading {
min-height: 120px;
color: var(--sky-app-accent);
}
.messages-sky-thread-navbar :deep(.sky-navbar__heading) {
overflow: visible;
}
.messages-sky-thread-contact {
width: 100%;
min-width: 0;
height: var(--sky-navbar-height);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 0;
background: transparent;
color: var(--sky-text);
font: inherit;
font-size: 15px;
font-weight: 700;
}
.messages-sky-thread-contact > span:not(.messages-avatar) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages-avatar--thread {
width: 32px;
height: 32px;
border: 1px solid var(--sky-hairline);
box-shadow: none;
font-size: 12px;
}
.messages-sky-thread-scroll {
padding: 4px 14px 10px;
transition: padding-bottom 240ms ease;
}
.messages-bubbles {
min-height: 100%;
justify-content: flex-end;
padding: 0;
}
.messages-bubbles :deep(.sky-message) {
max-width: 86%;
margin-bottom: 7px;
}
.messages-bubbles :deep(.sky-message__bubble) {
padding: 8px 12px;
border-radius: 19px;
font-size: 15px;
line-height: 19px;
}
.messages-bubbles :deep(.sky-message--received .sky-message__bubble) {
background: var(--sky-message-received-background);
color: var(--sky-text);
}
.messages-bubbles :deep(.sky-message--sent .sky-message__bubble) {
background: var(--sky-app-accent);
color: #fff;
}
.messages-bubbles :deep(.sky-message__text-footer) {
margin-top: 2px;
font-size: 10px;
line-height: 13px;
}
.messages-bubbles :deep(.messages-message--failed .sky-message__bubble) {
box-shadow: 0 0 0 1px var(--sky-danger);
}
.messages-sky-composer-shell {
z-index: 42;
display: flex;
flex: none;
flex-direction: column;
gap: var(--sky-space-2);
padding: 6px var(--sky-page-gutter) calc(var(--sky-safe-area-bottom) + 6px);
background: var(--sky-bg);
}
.messages-sky-composer-row {
min-width: 0;
display: flex;
align-items: center;
gap: var(--sky-space-2);
}
.messages-sky-composer-pill {
min-width: 0;
min-height: 56px;
padding: 5px 7px;
display: flex;
flex: 1;
align-items: center;
gap: 6px;
overflow: hidden;
border-radius: var(--sky-radius-pill);
}
.messages-sky-messagebar {
min-width: 0;
padding: 0;
flex: 1;
gap: 4px;
background: transparent;
}
.messages-sky-composer-pill > .messages-sky-messagebar {
-webkit-backdrop-filter: none;
backdrop-filter: none;
background: transparent;
box-shadow: none;
}
.messages-sky-messagebar :deep(textarea) {
min-height: 44px;
padding: 11px 8px 9px 10px;
border: 0;
background: transparent;
color: var(--sky-text);
font-size: 15px;
line-height: 20px;
}
.messages-sky-messagebar__action,
.messages-sky-messagebar__send {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
}
.messages-sky-messagebar__action {
width: 46px;
height: 46px;
flex: 0 0 46px;
border-color: transparent;
background: var(--sky-surface-variant);
color: var(--sky-text);
box-shadow: none;
}
.messages-sky-messagebar__plus {
transition: transform 180ms ease;
}
.messages-sky-messagebar__plus.active {
color: var(--sky-app-accent);
transform: rotate(45deg);
}
.messages-sky-messagebar__send {
color: var(--sky-app-accent);
}
.messages-pending-media {
display: flex;
gap: var(--sky-space-2);
overflow-x: auto;
padding: 2px;
scrollbar-width: none;
}
.messages-pending-media::-webkit-scrollbar {
display: none;
}
.messages-pending-media__item {
position: relative;
width: 72px;
height: 72px;
flex: 0 0 72px;
overflow: visible;
border-radius: var(--sky-radius-card);
background: var(--sky-surface-muted);
}
.messages-pending-media__item > img,
.messages-pending-media__item > video {
width: 100%;
height: 100%;
display: block;
overflow: hidden;
border-radius: inherit;
object-fit: cover;
}
.messages-pending-media__item > button {
position: absolute;
top: -4px;
right: -4px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 2px solid var(--sky-bg);
border-radius: 50%;
background: var(--sky-text);
color: var(--sky-bg);
}
.messages-pending-media__item > button:disabled {
opacity: 0.45;
}
.messages-recorder__send {
width: 38px;
height: 38px;
flex: 0 0 38px;
padding: 0;
color: var(--sky-app-accent);
}
.messages-recorder__wave i {
width: auto;
min-width: 1px;
flex: 1 1 2px;
}
.messages-sky-unavailable {
padding: 12px 18px calc(var(--sky-safe-area-bottom) + 12px);
color: var(--sky-muted);
font-size: 13px;
text-align: center;
}
.messages-sky-thread--panel .messages-sky-thread-scroll {
padding-bottom: 326px;
}
.messages-sky-thread--share .messages-sky-thread-scroll {
padding-bottom: 150px;
}
.messages-contact-overlay {
position: absolute;
z-index: 58;
inset: 0;
}
.messages-contact-overlay__scroll {
padding-top: 4px;
}
.messages-contact-profile__hero {
padding: var(--sky-space-3) 0 var(--sky-space-4);
display: grid;
justify-items: center;
gap: var(--sky-space-2);
text-align: center;
}
.messages-contact-profile__hero h2 {
margin: 0;
font-size: 22px;
line-height: 28px;
}
.messages-contact-profile__hero small {
color: var(--sky-muted);
font-size: 12px;
}
.messages-contact-profile__actions {
width: min(100%, 256px);
margin: 0 auto var(--sky-space-5);
display: flex;
justify-content: center;
gap: var(--sky-space-3);
}
.messages-contact-profile__actions :deep(.sky-button) {
width: 56px;
min-width: 56px;
height: 56px;
min-height: 56px;
padding: 0;
border-radius: 50%;
}
.messages-contact-profile__details,
.messages-contact-profile__options {
margin-right: calc(var(--sky-page-gutter) * -1);
margin-left: calc(var(--sky-page-gutter) * -1);
}
.messages-gif-search {
grid-column: 1 / -1;
width: 100%;
}
@media (prefers-reduced-motion: reduce) {
.messages-sky-thread-scroll,
.messages-sky-messagebar__plus {
transition: none;
}
}
</style>