mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-03 11:01:02 +00:00
2473 lines
68 KiB
Vue
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>
|