mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 01:03:25 +00:00
* FIX - repair inventory metadata and ESX contracts Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure. * FIX - clarify phone opening without SIM * FIX - harden inventory and device contracts Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors. * FIX - resolve reported phone interaction issues Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage. * FIX - detect missing packaged phone UI Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
2884 lines
75 KiB
Vue
2884 lines
75 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ArrowUpCircle,
|
|
Bell,
|
|
BellOff,
|
|
Camera,
|
|
Check,
|
|
Clock3,
|
|
Copy,
|
|
ImagePlay,
|
|
Images,
|
|
LockKeyhole,
|
|
LogOut,
|
|
MessageCirclePlus,
|
|
Mic,
|
|
Plus,
|
|
QrCode,
|
|
Reply,
|
|
Share2,
|
|
ShieldCheck,
|
|
ShieldOff,
|
|
Smile,
|
|
SquarePen,
|
|
Trash2,
|
|
UserRound,
|
|
UserMinus,
|
|
UserPlus,
|
|
Video,
|
|
X,
|
|
} from 'lucide-vue-next'
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
|
|
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
|
import SharedContentCard from '@/components/SharedContentCard.vue'
|
|
import { useAccountStore } from '@/stores/account'
|
|
import { useDarkChatStore } from '@/stores/darkchat'
|
|
import { useEasyShareStore } from '@/stores/easyshare'
|
|
import { useMessagesStore } from '@/stores/messages'
|
|
import { useMessageMediaStore } from '@/stores/messageMedia'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import type {
|
|
DarkChatConversationSummary,
|
|
DarkChatMessage,
|
|
DarkChatNotificationMode,
|
|
} from '@/types/darkchat'
|
|
import type { GifSearchResult } from '@/types/messages'
|
|
import type { EasySharePayload } from '@/types/easyshare'
|
|
import type { MediaType, PhoneMedia } from '@/types/media'
|
|
import { copyText } from '@/utils/clipboard'
|
|
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
|
import { easyShareDarkChatInviteCode } from '@/utils/easyshare'
|
|
import { handleEnterAction } from '@/utils/keyboard'
|
|
import {
|
|
SkyAppPage,
|
|
SkyButton,
|
|
SkyCard,
|
|
SkyDialog,
|
|
SkyDialogButton,
|
|
SkyEmptyState,
|
|
SkyField,
|
|
SkyGlass,
|
|
SkyLink,
|
|
SkyList,
|
|
SkyListItem,
|
|
SkyMessagebar,
|
|
SkyNavbar,
|
|
SkyScrollArea,
|
|
SkySearchbar,
|
|
SkySettingsGroup,
|
|
SkySettingsRow,
|
|
SkySheet,
|
|
SkySpinner,
|
|
SkyNotification,
|
|
} from '@/ui'
|
|
|
|
const VOICE_MAX_DURATION_MS = 60_000
|
|
const VOICE_MAX_BYTES = 270_000
|
|
const WAVEFORM_SAMPLES = 48
|
|
type DarkChatSelectOption = {
|
|
label: string
|
|
value: number | string
|
|
}
|
|
type SelectionSheet = 'disappearing' | 'notification' | 'report'
|
|
const account = useAccountStore()
|
|
const darkchat = useDarkChatStore()
|
|
const easyShare = useEasyShareStore()
|
|
const sms = useMessagesStore()
|
|
const messageMedia = useMessageMediaStore()
|
|
const phone = usePhoneStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const screen = ref<'inbox' | 'new' | 'thread' | 'contact' | 'profile'>('inbox')
|
|
const search = ref('')
|
|
const identifier = ref('')
|
|
const pendingIdentifier = ref('')
|
|
const safetyOpen = ref(false)
|
|
const draft = ref('')
|
|
const queuedSharePayload = ref<EasySharePayload | null>(null)
|
|
const shareDraft = ref<EasySharePayload | null>(null)
|
|
const aliasDraft = ref('')
|
|
const contactAliasDraft = ref('')
|
|
const notificationMode = ref<DarkChatNotificationMode>('private')
|
|
const activityVisible = ref(false)
|
|
const attachmentOpen = ref(false)
|
|
const emojiOpen = ref(false)
|
|
const gifOpen = ref(false)
|
|
const gifQuery = ref('')
|
|
const gifResults = ref<GifSearchResult[]>([])
|
|
const gifOffset = ref(0)
|
|
const gifHasMore = ref(true)
|
|
const gifLoading = ref(false)
|
|
const selectedMessage = ref<DarkChatMessage | null>(null)
|
|
const replyTo = ref<DarkChatMessage | null>(null)
|
|
const reportOpen = ref(false)
|
|
const reportReason = ref('spam')
|
|
const reportDetails = ref('')
|
|
const selectionSheet = ref<SelectionSheet | null>(null)
|
|
const signOutOpen = ref(false)
|
|
const deleteProfileOpen = ref(false)
|
|
const profileActionPending = ref(false)
|
|
const messagesArea = ref<HTMLElement | null>(null)
|
|
const toast = ref('')
|
|
const sending = ref(false)
|
|
const recording = ref(false)
|
|
const recordingElapsedMs = ref(0)
|
|
const recordingLevels = ref<number[]>(Array(32).fill(0.16))
|
|
let toastTimer: ReturnType<typeof setTimeout> | undefined
|
|
let gifTimer: ReturnType<typeof setTimeout> | undefined
|
|
let recordingTimer: ReturnType<typeof setInterval> | undefined
|
|
let mediaRecorder: MediaRecorder | undefined
|
|
let mediaStream: MediaStream | undefined
|
|
let audioContext: AudioContext | undefined
|
|
let analyser: AnalyserNode | undefined
|
|
let recordingStartedAt = 0
|
|
let recordingChunks: Blob[] = []
|
|
let recordingSamples: number[] = []
|
|
let recordingBytes = 0
|
|
let discardRecording = false
|
|
|
|
const signedIn = computed(() => Boolean(account.email))
|
|
const filteredConversations = computed(() => {
|
|
const needle = search.value.trim().toLocaleLowerCase(phone.lang)
|
|
if (!needle) return darkchat.conversations
|
|
return darkchat.conversations.filter((item) =>
|
|
`${item.peer.alias} ${item.peer.darkId} ${preview(item)}`
|
|
.toLocaleLowerCase(phone.lang)
|
|
.includes(needle),
|
|
)
|
|
})
|
|
const contactSuggestions = computed(() => {
|
|
const needle = identifier.value.trim().toLocaleLowerCase(phone.lang)
|
|
if (!needle) return darkchat.contacts.slice(0, 8)
|
|
return darkchat.contacts
|
|
.filter((contact) =>
|
|
`${contact.alias} ${contact.darkId}`
|
|
.toLocaleLowerCase(phone.lang)
|
|
.includes(needle),
|
|
)
|
|
.slice(0, 8)
|
|
})
|
|
const sharedInviteCode = ref(
|
|
easyShareDarkChatInviteCode(
|
|
route.query.easyShareKind,
|
|
route.query.easyShareLink,
|
|
),
|
|
)
|
|
const active = computed(() => darkchat.activeConversation)
|
|
const attachmentPanelOpen = computed(() => emojiOpen.value || gifOpen.value)
|
|
const timerOptions = [0, -1, 60, 300, 3600, 86400, 604800]
|
|
|
|
function t(key: string, params?: Record<string, string>): string {
|
|
return phone.t(`Apps.darkchat.${key}`, params)
|
|
}
|
|
|
|
function showToast(value: string): void {
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
toast.value = value
|
|
toastTimer = setTimeout(() => (toast.value = ''), 2800)
|
|
}
|
|
|
|
function errorText(error?: string): string {
|
|
return t(`errors.${error ?? 'default'}`)
|
|
}
|
|
|
|
function avatarGradient(seed: number): string {
|
|
const hue = Math.abs(seed || 1) % 360
|
|
return `hsl(${hue} 45% 45%)`
|
|
}
|
|
|
|
function avatarGlyph(seed: number): string {
|
|
const glyphs = ['◉', '◇', '△', '⬡', '✦', '◎', '◈', '⬢']
|
|
return glyphs[Math.abs(seed || 0) % glyphs.length]
|
|
}
|
|
|
|
function inputValue(event: Event): string {
|
|
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
|
}
|
|
|
|
function setIdentifier(event: Event): void {
|
|
identifier.value = inputValue(event)
|
|
}
|
|
|
|
function setDraft(event: Event): void {
|
|
draft.value = inputValue(event)
|
|
}
|
|
|
|
function setAliasDraft(event: Event): void {
|
|
aliasDraft.value = inputValue(event)
|
|
}
|
|
|
|
function setContactAliasDraft(event: Event): void {
|
|
contactAliasDraft.value = inputValue(event)
|
|
}
|
|
|
|
function setReportDetails(event: Event): void {
|
|
reportDetails.value = inputValue(event)
|
|
}
|
|
|
|
function setConversationNotifications(value: boolean): void {
|
|
if (!active.value) return
|
|
active.value.notificationsEnabled = value
|
|
void updateConversation()
|
|
}
|
|
|
|
function setReadReceipts(value: boolean): void {
|
|
if (!active.value) return
|
|
active.value.readReceipts = value
|
|
void updateConversation()
|
|
}
|
|
|
|
function setActivityVisible(value: boolean): void {
|
|
activityVisible.value = value
|
|
}
|
|
|
|
function preview(conversation: DarkChatConversationSummary): string {
|
|
if (conversation.lastMessage === 'message_deleted') return t('messageDeleted')
|
|
if (conversation.lastMessageType === 'voice') return `🎙 ${t('voiceMessage')}`
|
|
if (conversation.lastMessageType === 'gif') return `GIF · ${t('gif')}`
|
|
if (conversation.lastMessageType === 'image') return `📷 ${t('photo')}`
|
|
if (conversation.lastMessageType === 'video') return `▶ ${t('video')}`
|
|
if (conversation.lastMessageType === 'share')
|
|
return `🔗 ${conversation.lastMessage}`
|
|
if (conversation.lastMessageType === 'system') return t('securityUpdate')
|
|
return conversation.lastMessage
|
|
}
|
|
|
|
function formatDate(value: DatabaseDateValue): string {
|
|
const date = parseDatabaseDate(value)
|
|
if (Number.isNaN(date.getTime())) return ''
|
|
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)
|
|
}
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
}).format(date)
|
|
}
|
|
|
|
function dayLabel(value: DatabaseDateValue): string {
|
|
const date = parseDatabaseDate(value)
|
|
if (Number.isNaN(date.getTime())) return ''
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
}).format(date)
|
|
}
|
|
|
|
function timerLabel(seconds: number): string {
|
|
const keys: Record<number, string> = {
|
|
0: 'timerOff',
|
|
[-1]: 'timerAfterRead',
|
|
60: 'timerMinute',
|
|
300: 'timerFiveMinutes',
|
|
3600: 'timerHour',
|
|
86400: 'timerDay',
|
|
604800: 'timerWeek',
|
|
}
|
|
return t(keys[seconds] ?? 'timerOff')
|
|
}
|
|
|
|
const disappearingOptions = computed<DarkChatSelectOption[]>(() =>
|
|
timerOptions.map((value) => ({ label: timerLabel(value), value })),
|
|
)
|
|
const notificationOptions = computed<DarkChatSelectOption[]>(() => [
|
|
{ label: t('notificationFull'), value: 'full' },
|
|
{ label: t('notificationPrivate'), value: 'private' },
|
|
{ label: t('notificationHidden'), value: 'hidden' },
|
|
])
|
|
const reportOptions = computed<DarkChatSelectOption[]>(() => [
|
|
{ label: t('reportSpam'), value: 'spam' },
|
|
{ label: t('reportHarassment'), value: 'harassment' },
|
|
{ label: t('reportThreats'), value: 'threats' },
|
|
{ label: t('reportIllegal'), value: 'illegal' },
|
|
{ label: t('reportOther'), value: 'other' },
|
|
])
|
|
const selectionOptions = computed<DarkChatSelectOption[]>(() => {
|
|
if (selectionSheet.value === 'disappearing') return disappearingOptions.value
|
|
if (selectionSheet.value === 'notification') return notificationOptions.value
|
|
if (selectionSheet.value === 'report') return reportOptions.value
|
|
return []
|
|
})
|
|
const selectionTitle = computed(() => {
|
|
if (selectionSheet.value === 'disappearing') return t('disappearing')
|
|
if (selectionSheet.value === 'notification') return t('notificationPrivacy')
|
|
return t('reportUser')
|
|
})
|
|
const selectionValue = computed<number | string>(() => {
|
|
if (selectionSheet.value === 'disappearing')
|
|
return active.value?.disappearingSeconds ?? 0
|
|
if (selectionSheet.value === 'notification') return notificationMode.value
|
|
return reportReason.value
|
|
})
|
|
|
|
function systemText(body: string): string {
|
|
if (body === 'message_deleted') return t('messageDeleted')
|
|
if (body.startsWith('timer_changed:'))
|
|
return t('timerChanged', { timer: timerLabel(Number(body.split(':')[1])) })
|
|
return t('securityUpdate')
|
|
}
|
|
|
|
async function scrollBottom(animate = true): Promise<void> {
|
|
await nextTick()
|
|
const area = messagesArea.value
|
|
if (!area) return
|
|
if (!animate) {
|
|
area.scrollTop = area.scrollHeight
|
|
return
|
|
}
|
|
area.scrollTo({ behavior: 'smooth', top: area.scrollHeight })
|
|
}
|
|
|
|
async function openConversation(conversationId: string): Promise<void> {
|
|
if (!(await darkchat.openThread(conversationId))) {
|
|
showToast(errorText(darkchat.lastError ?? undefined))
|
|
return
|
|
}
|
|
screen.value = 'thread'
|
|
resetPanels()
|
|
if (queuedSharePayload.value) {
|
|
shareDraft.value = queuedSharePayload.value
|
|
queuedSharePayload.value = null
|
|
}
|
|
await scrollBottom(false)
|
|
}
|
|
|
|
function back(): void {
|
|
if (screen.value === 'contact') {
|
|
screen.value = 'thread'
|
|
return
|
|
}
|
|
if (screen.value === 'thread') darkchat.closeThread()
|
|
screen.value = 'inbox'
|
|
shareDraft.value = null
|
|
resetPanels()
|
|
}
|
|
|
|
function beginNewChat(): void {
|
|
identifier.value = ''
|
|
screen.value = 'new'
|
|
resetPanels()
|
|
}
|
|
|
|
function openSharedInvite(): void {
|
|
if (!sharedInviteCode.value || !darkchat.profile) return
|
|
identifier.value = sharedInviteCode.value
|
|
sharedInviteCode.value = null
|
|
screen.value = 'new'
|
|
resetPanels()
|
|
const query = { ...route.query }
|
|
delete query.easyShareId
|
|
delete query.easyShareKind
|
|
delete query.easyShareLink
|
|
void router.replace({ path: route.path, query })
|
|
}
|
|
|
|
function resetPanels(): void {
|
|
attachmentOpen.value = false
|
|
emojiOpen.value = false
|
|
gifOpen.value = false
|
|
selectedMessage.value = null
|
|
replyTo.value = null
|
|
safetyOpen.value = false
|
|
reportOpen.value = false
|
|
}
|
|
|
|
function openEmojiPanel(): void {
|
|
attachmentOpen.value = false
|
|
gifOpen.value = false
|
|
emojiOpen.value = true
|
|
}
|
|
|
|
function openGifPanel(): void {
|
|
attachmentOpen.value = false
|
|
emojiOpen.value = false
|
|
gifOpen.value = true
|
|
void loadGifs(true)
|
|
}
|
|
|
|
function updateGifSearch(event: Event): void {
|
|
gifQuery.value = inputValue(event)
|
|
queueGifSearch()
|
|
}
|
|
|
|
function clearGifSearch(): void {
|
|
gifQuery.value = ''
|
|
queueGifSearch()
|
|
}
|
|
|
|
function toggleAttachmentPanel(): void {
|
|
attachmentOpen.value = !attachmentOpen.value
|
|
emojiOpen.value = false
|
|
gifOpen.value = false
|
|
}
|
|
|
|
function closeReport(): void {
|
|
reportOpen.value = false
|
|
selectedMessage.value = null
|
|
}
|
|
|
|
function requestStart(value = identifier.value): void {
|
|
const clean = value.trim()
|
|
if (!clean) return
|
|
const ownIdentifiers = [
|
|
darkchat.profile?.darkId,
|
|
darkchat.profile?.inviteCode,
|
|
]
|
|
if (
|
|
ownIdentifiers.some(
|
|
(ownIdentifier) =>
|
|
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
|
|
clean.toLocaleLowerCase(phone.lang),
|
|
)
|
|
) {
|
|
showToast(errorText('self_chat'))
|
|
return
|
|
}
|
|
pendingIdentifier.value = clean
|
|
safetyOpen.value = true
|
|
}
|
|
|
|
async function confirmStart(): Promise<void> {
|
|
const response = await darkchat.start(pendingIdentifier.value)
|
|
safetyOpen.value = false
|
|
if (!response.success || !response.data) {
|
|
showToast(errorText(response.error))
|
|
return
|
|
}
|
|
identifier.value = ''
|
|
await openConversation(response.data.conversationId)
|
|
}
|
|
|
|
async function sendText(): Promise<void> {
|
|
const body = draft.value.trim()
|
|
if ((!body && !shareDraft.value) || sending.value) return
|
|
const shared = shareDraft.value
|
|
draft.value = ''
|
|
shareDraft.value = null
|
|
const outgoingReply = replyTo.value?.id
|
|
replyTo.value = null
|
|
resetPanels()
|
|
sending.value = true
|
|
const response = await darkchat.send({
|
|
body,
|
|
messageType: shared ? 'share' : 'text',
|
|
replyToId: outgoingReply,
|
|
sharePayload: shared ?? undefined,
|
|
})
|
|
sending.value = false
|
|
if (!response.success) {
|
|
draft.value = body
|
|
shareDraft.value = shared
|
|
showToast(errorText(response.error))
|
|
}
|
|
await scrollBottom()
|
|
}
|
|
|
|
function appendEmoji(emoji: string): void {
|
|
draft.value += emoji
|
|
}
|
|
|
|
async function loadGifs(reset = false): Promise<void> {
|
|
if (gifLoading.value || (!reset && !gifHasMore.value)) return
|
|
gifLoading.value = true
|
|
const response = await sms.searchGifs(
|
|
gifQuery.value,
|
|
reset ? 0 : gifOffset.value,
|
|
)
|
|
gifLoading.value = false
|
|
if (!response.success || !response.data) {
|
|
showToast(errorText(response.error))
|
|
return
|
|
}
|
|
const ids = new Set(reset ? [] : gifResults.value.map((gif) => gif.id))
|
|
const unique = response.data.results.filter(
|
|
(gif) => !ids.has(gif.id) && Boolean(ids.add(gif.id)),
|
|
)
|
|
gifResults.value = reset ? unique : [...gifResults.value, ...unique]
|
|
gifOffset.value = response.data.nextOffset
|
|
gifHasMore.value = response.data.hasMore
|
|
}
|
|
|
|
function queueGifSearch(): void {
|
|
if (gifTimer) clearTimeout(gifTimer)
|
|
gifTimer = setTimeout(() => void loadGifs(true), 320)
|
|
}
|
|
|
|
async function sendGif(gif: GifSearchResult): Promise<void> {
|
|
gifOpen.value = false
|
|
sending.value = true
|
|
const response = await darkchat.send({
|
|
messageType: 'gif',
|
|
mediaPayload: gif.url,
|
|
replyToId: replyTo.value?.id,
|
|
})
|
|
sending.value = false
|
|
replyTo.value = null
|
|
if (!response.success) showToast(errorText(response.error))
|
|
await scrollBottom()
|
|
}
|
|
|
|
function openMediaApp(app: 'camera' | 'photos', mediaType: MediaType): void {
|
|
if (!active.value) {
|
|
console.error(
|
|
'[DarkChat] Cannot attach media without an active conversation.',
|
|
)
|
|
return
|
|
}
|
|
attachmentOpen.value = false
|
|
emojiOpen.value = false
|
|
gifOpen.value = false
|
|
messageMedia.begin(`darkchat:${active.value.id}`, mediaType, '/apps/darkchat')
|
|
void router.push({
|
|
path: `/apps/${app}`,
|
|
query: { messageAttachment: mediaType },
|
|
})
|
|
}
|
|
|
|
async function sendAttachment(media: PhoneMedia): Promise<void> {
|
|
if (!active.value || sending.value) return
|
|
sending.value = true
|
|
const response = await darkchat.send({
|
|
mediaAssetId: import.meta.env.DEV ? media.url : String(media.id),
|
|
mediaPreviewUrl: media.url,
|
|
messageType: media.mediaType === 'photo' ? 'image' : 'video',
|
|
})
|
|
sending.value = false
|
|
if (!response.success) showToast(errorText(response.error))
|
|
await scrollBottom()
|
|
}
|
|
|
|
function copyMessage(message: DarkChatMessage): void {
|
|
selectedMessage.value = null
|
|
showToast(copyText(message.body) ? t('copied') : errorText())
|
|
}
|
|
|
|
async function openEasyShareDraft(): Promise<boolean> {
|
|
const shared = easyShare.consumeChatDraft('darkchat')
|
|
if (!shared) return false
|
|
if (!shared.targetId) {
|
|
darkchat.closeThread()
|
|
screen.value = 'inbox'
|
|
resetPanels()
|
|
queuedSharePayload.value = shared.payload
|
|
draft.value = ''
|
|
return true
|
|
}
|
|
if (!(await darkchat.openThread(shared.targetId))) {
|
|
showToast(errorText(darkchat.lastError ?? undefined))
|
|
return true
|
|
}
|
|
screen.value = 'thread'
|
|
resetPanels()
|
|
queuedSharePayload.value = null
|
|
shareDraft.value = shared.payload
|
|
draft.value = ''
|
|
await scrollBottom(false)
|
|
return true
|
|
}
|
|
|
|
function shareMessage(message: DarkChatMessage): void {
|
|
selectedMessage.value = null
|
|
useEasyShareStore().open({
|
|
appId: 'darkchat',
|
|
copyText: message.body || message.messageType,
|
|
id: message.id,
|
|
imageUrl:
|
|
message.messageType === 'image' || message.messageType === 'video'
|
|
? message.mediaPayload
|
|
: undefined,
|
|
kind: 'text',
|
|
subtitle: active.value?.peer.alias,
|
|
title: message.body || message.messageType,
|
|
})
|
|
}
|
|
|
|
async function react(
|
|
message: DarkChatMessage,
|
|
reaction: string,
|
|
): Promise<void> {
|
|
await darkchat.mutate('react', { messageId: message.id, reaction })
|
|
selectedMessage.value = null
|
|
if (active.value) await darkchat.openThread(active.value.id)
|
|
}
|
|
|
|
async function messageAction(
|
|
message: DarkChatMessage,
|
|
action: 'delete_me' | 'delete_all',
|
|
): Promise<void> {
|
|
const success = await darkchat.mutate('message-action', {
|
|
messageId: message.id,
|
|
action,
|
|
})
|
|
selectedMessage.value = null
|
|
if (!success) showToast(errorText())
|
|
else if (active.value) await darkchat.openThread(active.value.id)
|
|
}
|
|
|
|
function beginReply(message: DarkChatMessage): void {
|
|
replyTo.value = message
|
|
selectedMessage.value = null
|
|
}
|
|
|
|
function beginReport(message?: DarkChatMessage): void {
|
|
if (message) selectedMessage.value = message
|
|
reportReason.value = 'spam'
|
|
reportDetails.value = ''
|
|
reportOpen.value = true
|
|
}
|
|
|
|
async function submitReport(): Promise<void> {
|
|
if (!active.value) return
|
|
const success = await darkchat.mutate('report', {
|
|
conversationId: active.value.id,
|
|
details: reportDetails.value,
|
|
messageId: selectedMessage.value?.id,
|
|
reason: reportReason.value,
|
|
})
|
|
reportOpen.value = false
|
|
selectedMessage.value = null
|
|
showToast(success ? t('reported') : errorText())
|
|
}
|
|
|
|
function openContact(): void {
|
|
if (!active.value) return
|
|
contactAliasDraft.value = active.value.peer.alias
|
|
screen.value = 'contact'
|
|
}
|
|
|
|
async function updateConversation(): Promise<void> {
|
|
if (!active.value) return
|
|
const success = await darkchat.mutate('update-conversation', {
|
|
conversationId: active.value.id,
|
|
disappearingSeconds: active.value.disappearingSeconds,
|
|
notificationsEnabled: active.value.notificationsEnabled,
|
|
readReceipts: active.value.readReceipts,
|
|
})
|
|
if (!success) showToast(errorText())
|
|
else await darkchat.openThread(active.value.id)
|
|
}
|
|
|
|
async function saveContact(): Promise<void> {
|
|
if (!active.value) return
|
|
const endpoint = active.value.peer.isContact ? 'add-contact' : 'add-contact'
|
|
const success = await darkchat.mutate(endpoint, {
|
|
alias: contactAliasDraft.value,
|
|
conversationId: active.value.id,
|
|
})
|
|
showToast(success ? t('contactSaved') : errorText())
|
|
if (success) await darkchat.openThread(active.value.id)
|
|
}
|
|
|
|
async function removeContact(): Promise<void> {
|
|
if (!active.value) return
|
|
const success = await darkchat.mutate('remove-contact', {
|
|
conversationId: active.value.id,
|
|
})
|
|
if (success) await darkchat.openThread(active.value.id)
|
|
}
|
|
|
|
async function toggleBlock(): Promise<void> {
|
|
if (!active.value) return
|
|
const blocked = !active.value.peer.blocked
|
|
const success = await darkchat.mutate('block', {
|
|
blocked,
|
|
conversationId: active.value.id,
|
|
})
|
|
if (success) await darkchat.openThread(active.value.id)
|
|
}
|
|
|
|
async function clearChat(): Promise<void> {
|
|
if (!active.value) return
|
|
if (await darkchat.mutate('clear', { conversationId: active.value.id })) {
|
|
await darkchat.openThread(active.value.id)
|
|
showToast(t('chatCleared'))
|
|
}
|
|
}
|
|
|
|
function openProfile(): void {
|
|
if (!darkchat.profile) return
|
|
aliasDraft.value = darkchat.profile.alias
|
|
notificationMode.value = darkchat.profile.notificationMode
|
|
activityVisible.value = darkchat.profile.activityVisible
|
|
screen.value = 'profile'
|
|
}
|
|
|
|
async function saveProfile(): Promise<void> {
|
|
const response = await darkchat.mutate('update-profile', {
|
|
activityVisible: activityVisible.value,
|
|
alias: aliasDraft.value,
|
|
notificationMode: notificationMode.value,
|
|
})
|
|
if (!response) showToast(errorText('invalid_profile'))
|
|
else {
|
|
await darkchat.refreshInbox()
|
|
screen.value = 'inbox'
|
|
}
|
|
}
|
|
|
|
async function signOut(): Promise<void> {
|
|
if (profileActionPending.value) return
|
|
profileActionPending.value = true
|
|
const success = await account.logout()
|
|
profileActionPending.value = false
|
|
if (!success) {
|
|
showToast(t('errors.sign_out_failed'))
|
|
return
|
|
}
|
|
signOutOpen.value = false
|
|
darkchat.reset()
|
|
screen.value = 'inbox'
|
|
}
|
|
|
|
async function deleteProfile(): Promise<void> {
|
|
if (profileActionPending.value) return
|
|
profileActionPending.value = true
|
|
const success = await darkchat.deleteProfile()
|
|
profileActionPending.value = false
|
|
if (!success) {
|
|
showToast(errorText(darkchat.lastError ?? undefined))
|
|
return
|
|
}
|
|
deleteProfileOpen.value = false
|
|
screen.value = 'inbox'
|
|
}
|
|
|
|
async function createProfile(): Promise<void> {
|
|
if (profileActionPending.value) return
|
|
profileActionPending.value = true
|
|
const success = await darkchat.createProfile()
|
|
profileActionPending.value = false
|
|
if (!success) showToast(errorText(darkchat.lastError ?? undefined))
|
|
else openSharedInvite()
|
|
}
|
|
|
|
function selectDisappearing(value: number | string): void {
|
|
if (!active.value) {
|
|
console.error(
|
|
'[DarkChat] Cannot update the timer without an active conversation.',
|
|
)
|
|
return
|
|
}
|
|
active.value.disappearingSeconds = Number(value)
|
|
void updateConversation()
|
|
}
|
|
|
|
function selectNotificationMode(value: number | string): void {
|
|
notificationMode.value = value as DarkChatNotificationMode
|
|
}
|
|
|
|
function selectReportReason(value: number | string): void {
|
|
reportReason.value = String(value)
|
|
}
|
|
|
|
function chooseSelection(value: number | string): void {
|
|
if (selectionSheet.value === 'disappearing') selectDisappearing(value)
|
|
else if (selectionSheet.value === 'notification')
|
|
selectNotificationMode(value)
|
|
else if (selectionSheet.value === 'report') selectReportReason(value)
|
|
selectionSheet.value = null
|
|
}
|
|
|
|
function sharePrivateInvite(): void {
|
|
const profile = darkchat.profile
|
|
if (!profile) return
|
|
easyShare.open({
|
|
appId: 'darkchat',
|
|
copyText: `${profile.alias}\n${profile.darkId}\n${profile.inviteCode}`,
|
|
id: profile.id,
|
|
kind: 'profile',
|
|
link: `skyphone://darkchat/invite/${profile.inviteCode}`,
|
|
subtitle: profile.darkId,
|
|
title: profile.alias,
|
|
})
|
|
}
|
|
|
|
function copyIdentity(value: string): void {
|
|
showToast(copyText(value) ? t('copied') : errorText())
|
|
}
|
|
|
|
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) stopRecording()
|
|
}
|
|
|
|
async function startRecording(): Promise<void> {
|
|
resetPanels()
|
|
if (
|
|
!navigator.mediaDevices?.getUserMedia ||
|
|
typeof MediaRecorder === 'undefined'
|
|
) {
|
|
showToast(t('microphoneUnavailable'))
|
|
return
|
|
}
|
|
const mime = recordingMime()
|
|
if (!mime) {
|
|
showToast(t('microphoneUnavailable'))
|
|
return
|
|
}
|
|
try {
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
autoGainControl: true,
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
},
|
|
})
|
|
recordingChunks = []
|
|
recordingSamples = []
|
|
recordingBytes = 0
|
|
discardRecording = false
|
|
mediaRecorder = new MediaRecorder(mediaStream, {
|
|
audioBitsPerSecond: 32_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) stopRecording()
|
|
})
|
|
mediaRecorder.addEventListener('stop', () => void finishRecording())
|
|
audioContext = new AudioContext()
|
|
analyser = audioContext.createAnalyser()
|
|
analyser.fftSize = 128
|
|
audioContext.createMediaStreamSource(mediaStream).connect(analyser)
|
|
recordingStartedAt = performance.now()
|
|
recordingElapsedMs.value = 0
|
|
recordingLevels.value = Array(32).fill(0.16)
|
|
recording.value = true
|
|
mediaRecorder.start(250)
|
|
recordingTimer = setInterval(sampleMicrophone, 100)
|
|
} catch (error) {
|
|
console.error('[DarkChat] Could not start audio recording:', error)
|
|
cleanupRecording()
|
|
showToast(t('microphoneUnavailable'))
|
|
}
|
|
}
|
|
|
|
function stopRecording(): void {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
|
|
}
|
|
|
|
function cancelRecording(): void {
|
|
discardRecording = true
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
|
|
else cleanupRecording()
|
|
}
|
|
|
|
function cleanupRecording(): void {
|
|
if (recordingTimer) clearInterval(recordingTimer)
|
|
recordingTimer = undefined
|
|
mediaStream?.getTracks().forEach((track) => track.stop())
|
|
void audioContext?.close()
|
|
mediaRecorder = undefined
|
|
mediaStream = undefined
|
|
audioContext = undefined
|
|
analyser = undefined
|
|
recording.value = false
|
|
}
|
|
|
|
function waveform(): number[] {
|
|
if (!recordingSamples.length) return Array(16).fill(0.12)
|
|
const result: number[] = []
|
|
const count = Math.min(WAVEFORM_SAMPLES, recordingSamples.length)
|
|
const bucketSize = recordingSamples.length / count
|
|
for (let index = 0; index < count; index += 1) {
|
|
const bucket = recordingSamples.slice(
|
|
Math.floor(index * bucketSize),
|
|
Math.max(1, Math.floor((index + 1) * bucketSize)),
|
|
)
|
|
result.push(
|
|
Math.max(
|
|
0.08,
|
|
Math.min(
|
|
1,
|
|
bucket.reduce((sum, value) => sum + value, 0) / bucket.length,
|
|
),
|
|
),
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function blobBase64(blob: Blob): Promise<string> {
|
|
const bytes = new Uint8Array(await blob.arrayBuffer())
|
|
let binary = ''
|
|
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
|
|
}
|
|
return btoa(binary)
|
|
}
|
|
|
|
async function finishRecording(): 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 levels = waveform()
|
|
const discard = discardRecording
|
|
cleanupRecording()
|
|
if (discard) return
|
|
const blob = new Blob(chunks, { type: mime })
|
|
if (!blob.size || blob.size > VOICE_MAX_BYTES) {
|
|
showToast(t('recordingTooLarge'))
|
|
return
|
|
}
|
|
sending.value = true
|
|
const response = await darkchat.send({
|
|
mediaDurationMs: Math.floor(duration),
|
|
mediaMime: mime,
|
|
mediaPayload: await blobBase64(blob),
|
|
mediaWaveform: levels,
|
|
messageType: 'voice',
|
|
replyToId: replyTo.value?.id,
|
|
})
|
|
sending.value = false
|
|
replyTo.value = null
|
|
if (!response.success) showToast(errorText(response.error))
|
|
await scrollBottom()
|
|
}
|
|
|
|
function recordingTime(): string {
|
|
const seconds = Math.floor(recordingElapsedMs.value / 1000)
|
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (signedIn.value) await darkchat.bootstrap()
|
|
if (await openEasyShareDraft()) return
|
|
openSharedInvite()
|
|
if (!active.value) return
|
|
screen.value = 'thread'
|
|
const media = messageMedia.consume(`darkchat:${active.value.id}`)
|
|
if (media) await sendAttachment(media)
|
|
})
|
|
|
|
watch(
|
|
() => easyShare.chatDraft,
|
|
(shared) => {
|
|
if (shared?.appId === 'darkchat') void openEasyShareDraft()
|
|
},
|
|
)
|
|
|
|
watch(
|
|
() => darkchat.messages.length,
|
|
() => {
|
|
if (screen.value === 'thread') void scrollBottom(false)
|
|
},
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
discardRecording = true
|
|
cleanupRecording()
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
if (gifTimer) clearTimeout(gifTimer)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dc-ios {
|
|
--dc-accent: var(--sky-app-accent);
|
|
--dc-surface: var(--sky-surface);
|
|
--dc-surface-raised: var(--sky-surface-muted);
|
|
--dc-border: var(--sky-hairline);
|
|
--dc-label: var(--sky-text);
|
|
--dc-secondary: var(--sky-muted);
|
|
--dc-tertiary: var(--sky-muted-2);
|
|
}
|
|
|
|
.dc-ios button,
|
|
.dc-ios input,
|
|
.dc-ios textarea {
|
|
font: inherit;
|
|
}
|
|
|
|
.dc-screen,
|
|
.dc-inbox,
|
|
.dc-thread,
|
|
.dc-gate {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.dc-screen,
|
|
.dc-inbox,
|
|
.dc-thread {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dc-gate {
|
|
display: grid;
|
|
padding: 22px;
|
|
place-items: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.dc-gate-card {
|
|
width: 100%;
|
|
margin: 0;
|
|
border-radius: 24px;
|
|
}
|
|
|
|
.dc-gate-card :deep(.sky-card__content) {
|
|
padding: 26px 20px;
|
|
}
|
|
|
|
.dc-gate h1,
|
|
.dc-hero h2,
|
|
.dc-profile-hero h2 {
|
|
margin: 12px 0 5px;
|
|
color: var(--dc-label);
|
|
font-size: 21px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.35px;
|
|
}
|
|
|
|
.dc-gate p,
|
|
.dc-hero p {
|
|
margin: 0;
|
|
color: var(--dc-secondary);
|
|
font-size: 14px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.dc-gate small {
|
|
display: block;
|
|
margin-top: 12px;
|
|
color: var(--dc-tertiary);
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.dc-icon-tile {
|
|
display: inline-flex;
|
|
width: 54px;
|
|
height: 54px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 17px;
|
|
color: var(--dc-accent);
|
|
background: var(--sky-app-accent-soft);
|
|
}
|
|
|
|
.dc-preloader {
|
|
color: var(--dc-accent);
|
|
}
|
|
|
|
.dc-navbar-action {
|
|
width: 42px;
|
|
height: 42px;
|
|
display: grid;
|
|
padding: 0;
|
|
border: 1px solid var(--dc-border);
|
|
border-radius: 50%;
|
|
color: var(--dc-label);
|
|
place-items: center;
|
|
}
|
|
|
|
.dc-inbox-content,
|
|
.dc-scroll-content {
|
|
min-height: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.dc-inbox-content {
|
|
padding-top: 12px;
|
|
}
|
|
|
|
.dc-inbox-navbar :deep(.sky-navbar__title-container > div) {
|
|
transform: translateY(-14px);
|
|
}
|
|
|
|
.dc-scroll-content {
|
|
padding-top: 14px;
|
|
}
|
|
|
|
.dc-search {
|
|
width: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
.dc-search :deep(.sky-searchbar__control),
|
|
.dc-search :deep(.sky-searchbar__input) {
|
|
height: 38px;
|
|
min-height: 38px;
|
|
}
|
|
|
|
.dc-search :deep(.sky-searchbar__input) {
|
|
font-size: 15px;
|
|
}
|
|
|
|
.dc-search :deep(.sky-searchbar__clear) {
|
|
width: 38px;
|
|
height: 38px;
|
|
}
|
|
|
|
.dc-search :deep(.sky-searchbar__clear::before) {
|
|
position: absolute;
|
|
inset: -3px;
|
|
content: '';
|
|
}
|
|
|
|
.dc-security {
|
|
width: 100%;
|
|
min-height: 58px;
|
|
display: flex;
|
|
margin-bottom: 12px;
|
|
padding: 9px 13px;
|
|
align-items: center;
|
|
gap: 11px;
|
|
border: 1px solid var(--dc-border);
|
|
border-radius: 18px;
|
|
color: var(--dc-label);
|
|
text-align: left;
|
|
}
|
|
|
|
.dc-security > span {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dc-security strong {
|
|
font-size: 13px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.dc-security small {
|
|
margin-top: 2px;
|
|
overflow: hidden;
|
|
color: var(--dc-secondary);
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dc-security > svg {
|
|
flex: 0 0 auto;
|
|
color: var(--dc-accent);
|
|
}
|
|
|
|
.dc-conversation-list,
|
|
.dc-form-list,
|
|
.dc-action-list {
|
|
margin: 0 0 16px;
|
|
}
|
|
|
|
.dc-conversation-list :deep(.sky-list-item__row) {
|
|
min-height: 76px;
|
|
}
|
|
|
|
.dc-conversation-list :deep(.sky-list-item__after) {
|
|
color: var(--dc-tertiary);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.dc-conversation--unread :deep(.sky-list-item__title) {
|
|
font-weight: 760;
|
|
}
|
|
|
|
.dc-timer {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: var(--dc-tertiary);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.dc-avatar {
|
|
position: relative;
|
|
width: 48px;
|
|
height: 48px;
|
|
display: inline-flex;
|
|
flex: 0 0 auto;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
color: #fff;
|
|
font-size: 20px;
|
|
font-style: normal;
|
|
font-weight: 700;
|
|
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset;
|
|
}
|
|
|
|
.dc-avatar i {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 11px;
|
|
height: 11px;
|
|
border: 2px solid var(--dc-surface);
|
|
border-radius: 50%;
|
|
background: var(--dc-accent);
|
|
}
|
|
|
|
.dc-avatar--small {
|
|
width: 42px;
|
|
height: 42px;
|
|
font-size: 17px;
|
|
}
|
|
|
|
.dc-avatar--profile {
|
|
width: 78px;
|
|
height: 78px;
|
|
font-size: 30px;
|
|
}
|
|
|
|
.dc-empty {
|
|
min-height: 270px;
|
|
}
|
|
|
|
.dc-hero,
|
|
.dc-qr-card {
|
|
margin: 0 0 16px;
|
|
border-radius: 22px;
|
|
}
|
|
|
|
.dc-hero {
|
|
text-align: center;
|
|
}
|
|
|
|
.dc-primary {
|
|
margin: 0 2px 22px;
|
|
}
|
|
|
|
.dc-new-chat-scroll {
|
|
padding-top: 2px;
|
|
}
|
|
|
|
.dc-new-chat-intro {
|
|
margin: 2px 2px 12px;
|
|
color: var(--dc-muted);
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
}
|
|
|
|
.dc-recipient-field,
|
|
.dc-new-chat-contacts {
|
|
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
|
border-top: 1px solid var(--dc-border);
|
|
border-bottom: 1px solid var(--dc-border);
|
|
background: transparent;
|
|
}
|
|
|
|
.dc-recipient-field :deep(.sky-field) {
|
|
min-height: 52px;
|
|
padding: 0 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
background: transparent;
|
|
}
|
|
|
|
.dc-recipient-field :deep(.sky-field__inner) {
|
|
min-width: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex: 1;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.dc-recipient-field :deep(.sky-field__label) {
|
|
margin: 0;
|
|
flex: none;
|
|
color: var(--dc-secondary);
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.dc-recipient-field :deep(.sky-field__control),
|
|
.dc-recipient-field :deep(.sky-field__input) {
|
|
min-height: 52px;
|
|
}
|
|
|
|
.dc-recipient-field :deep(.sky-field__control) {
|
|
flex: 1;
|
|
margin: 0;
|
|
}
|
|
|
|
.dc-recipient-field :deep(.sky-field__input) {
|
|
font-size: 15px;
|
|
}
|
|
|
|
.dc-new-chat-contacts :deep(.sky-list-item__row) {
|
|
background: transparent;
|
|
}
|
|
|
|
.dc-identifier-action {
|
|
margin: 0;
|
|
}
|
|
|
|
.dc-contacts-group {
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.dc-contact-badge {
|
|
display: inline-flex;
|
|
padding: 4px 7px;
|
|
align-items: center;
|
|
gap: 3px;
|
|
border-radius: 999px;
|
|
color: var(--dc-accent);
|
|
background: var(--sky-app-accent-soft);
|
|
font-size: 10px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.dc-contacts-empty {
|
|
min-height: 78px;
|
|
display: flex;
|
|
padding: 16px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: var(--dc-secondary);
|
|
font-size: 13px;
|
|
list-style: none;
|
|
}
|
|
|
|
.dc-qr-card {
|
|
display: grid;
|
|
padding: 14px;
|
|
grid-template-columns: 72px 1fr;
|
|
align-items: center;
|
|
gap: 13px;
|
|
}
|
|
|
|
.dc-faux-qr {
|
|
width: 72px;
|
|
height: 72px;
|
|
display: grid;
|
|
border-radius: 16px;
|
|
color: #050505;
|
|
background: #f5f5f7;
|
|
place-items: center;
|
|
}
|
|
|
|
.dc-qr-card > div:nth-child(2) {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dc-qr-card strong {
|
|
overflow: hidden;
|
|
font-size: 15px;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.dc-qr-card small {
|
|
margin-top: 4px;
|
|
color: var(--dc-secondary);
|
|
font-size: 11px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.dc-qr-card :deep(.sky-button) {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.dc-invite-actions {
|
|
min-width: 0;
|
|
display: grid;
|
|
grid-column: 1 / -1;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.dc-invite-actions :deep(.sky-button) {
|
|
min-width: 0;
|
|
grid-column: auto;
|
|
}
|
|
|
|
.dc-thread-meta {
|
|
min-height: 28px;
|
|
display: flex;
|
|
padding: 5px 12px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
flex: 0 0 auto;
|
|
border-bottom: 1px solid var(--dc-border);
|
|
color: var(--dc-secondary);
|
|
background: var(--sky-surface);
|
|
font-size: 10px;
|
|
}
|
|
|
|
.dc-thread-meta span {
|
|
color: var(--dc-tertiary);
|
|
}
|
|
|
|
.dc-messages {
|
|
min-height: 0;
|
|
display: flex;
|
|
padding: 12px 10px 10px;
|
|
flex: 1 1 auto;
|
|
flex-direction: column;
|
|
gap: 7px;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.dc-messages::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.dc-day {
|
|
align-self: center;
|
|
margin: auto 0 8px;
|
|
padding: 5px 9px;
|
|
border-radius: 10px;
|
|
color: var(--dc-secondary);
|
|
background: var(--dc-surface-raised);
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.dc-system {
|
|
display: inline-flex;
|
|
margin: 4px auto;
|
|
padding: 5px 9px;
|
|
align-items: center;
|
|
gap: 5px;
|
|
border-radius: 10px;
|
|
color: var(--dc-secondary);
|
|
background: rgb(44 44 46 / 82%);
|
|
font-size: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.dc-message {
|
|
position: relative;
|
|
max-width: 82%;
|
|
display: flex;
|
|
padding: 9px 13px 7px;
|
|
flex: 0 0 auto;
|
|
flex-direction: column;
|
|
border: 0;
|
|
color: var(--dc-label);
|
|
text-align: left;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.dc-message--received {
|
|
align-self: flex-start;
|
|
border-radius: 20px 20px 20px 5px;
|
|
background: var(--dc-surface-raised);
|
|
}
|
|
|
|
.dc-message--sent {
|
|
align-self: flex-end;
|
|
border-radius: 20px 20px 5px;
|
|
background: var(--dc-accent);
|
|
}
|
|
|
|
.dc-message--failed {
|
|
box-shadow: 0 0 0 1px #ff453a inset;
|
|
}
|
|
|
|
.dc-message-body {
|
|
font-size: 15px;
|
|
line-height: 1.28;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.dc-message > img,
|
|
.dc-message > video {
|
|
width: 100%;
|
|
max-height: 220px;
|
|
margin-bottom: 4px;
|
|
border-radius: 12px;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.dc-message > small {
|
|
margin-top: 3px;
|
|
align-self: flex-end;
|
|
color: rgb(255 255 255 / 68%);
|
|
font-size: 9px;
|
|
}
|
|
|
|
.dc-reply-preview {
|
|
width: 100%;
|
|
display: flex;
|
|
margin-bottom: 5px;
|
|
padding: 5px 7px;
|
|
align-items: center;
|
|
gap: 5px;
|
|
overflow: hidden;
|
|
border-left: 2px solid currentColor;
|
|
border-radius: 5px;
|
|
background: rgb(0 0 0 / 14%);
|
|
font-size: 11px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dc-reactions {
|
|
position: absolute;
|
|
right: 4px;
|
|
bottom: -12px;
|
|
padding: 1px 5px;
|
|
border: 1px solid var(--dc-border);
|
|
border-radius: 9px;
|
|
background: var(--dc-surface-raised);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.dc-replying {
|
|
display: flex;
|
|
padding: 8px 12px;
|
|
align-items: center;
|
|
gap: 9px;
|
|
border-top: 1px solid var(--dc-border);
|
|
background: var(--dc-surface);
|
|
}
|
|
|
|
.dc-replying > span {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dc-replying small {
|
|
color: var(--dc-accent);
|
|
font-size: 10px;
|
|
}
|
|
|
|
.dc-replying strong {
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dc-attachments {
|
|
position: absolute;
|
|
z-index: 48;
|
|
bottom: calc(var(--sky-safe-area-bottom) + 78px);
|
|
left: calc(var(--sky-safe-area-left) + var(--sky-page-gutter));
|
|
display: flex;
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 7px;
|
|
}
|
|
|
|
.dc-attachment-action {
|
|
width: auto;
|
|
min-height: 42px;
|
|
display: flex;
|
|
padding: 4px 14px 4px 5px;
|
|
align-items: center;
|
|
gap: 9px;
|
|
border-radius: var(--sky-radius-pill);
|
|
color: var(--dc-label);
|
|
font-size: 12px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.dc-attachment-action > span {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: grid;
|
|
flex: 0 0 32px;
|
|
border-radius: 50%;
|
|
color: var(--dc-accent);
|
|
background: var(--sky-app-accent-soft);
|
|
place-items: center;
|
|
}
|
|
|
|
.dc-attachment-action > small {
|
|
width: auto;
|
|
color: inherit;
|
|
font-size: inherit;
|
|
font-weight: inherit;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dc-gif-panel {
|
|
max-height: 260px;
|
|
margin: 0 8px 8px;
|
|
overflow: auto;
|
|
flex: 0 1 auto;
|
|
}
|
|
|
|
.dc-gif-panel header {
|
|
display: flex;
|
|
margin-bottom: 8px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.dc-gif-grid {
|
|
display: grid;
|
|
margin-top: 8px;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 5px;
|
|
}
|
|
|
|
.dc-gif-grid button {
|
|
overflow: hidden;
|
|
border: 0;
|
|
border-radius: 7px;
|
|
aspect-ratio: 1;
|
|
background: var(--dc-surface-raised);
|
|
}
|
|
|
|
.dc-gif-grid img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.dc-recorder {
|
|
min-height: 56px;
|
|
display: flex;
|
|
padding: 6px 8px;
|
|
align-items: center;
|
|
gap: 9px;
|
|
}
|
|
|
|
.dc-recorder > i {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: #ff453a;
|
|
}
|
|
|
|
.dc-recorder time {
|
|
color: var(--dc-label);
|
|
font-size: 13px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.dc-recorder > span {
|
|
min-width: 0;
|
|
height: 25px;
|
|
display: flex;
|
|
flex: 1;
|
|
align-items: center;
|
|
gap: 1px;
|
|
}
|
|
|
|
.dc-recorder b {
|
|
min-width: 1px;
|
|
flex: 1;
|
|
border-radius: 1px;
|
|
background: var(--dc-accent);
|
|
}
|
|
|
|
.dc-composer-pill {
|
|
box-sizing: border-box;
|
|
min-width: 0;
|
|
margin: var(--sky-space-2)
|
|
calc(var(--sky-page-gutter) + var(--sky-safe-area-right))
|
|
calc(var(--sky-safe-area-bottom) + 10px)
|
|
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
|
|
overflow: hidden;
|
|
flex: 0 0 auto;
|
|
border-radius: var(--sky-radius-pill);
|
|
}
|
|
|
|
.dc-composer-row {
|
|
display: flex;
|
|
margin: var(--sky-space-2)
|
|
calc(var(--sky-page-gutter) + var(--sky-safe-area-right))
|
|
calc(var(--sky-safe-area-bottom) + 10px)
|
|
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
|
|
align-items: center;
|
|
gap: var(--sky-space-2);
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.dc-composer-row .dc-composer-pill {
|
|
width: 100%;
|
|
min-height: 56px;
|
|
display: flex;
|
|
margin: 0;
|
|
padding: 5px 7px;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.dc-composer-action {
|
|
box-sizing: border-box;
|
|
width: 46px;
|
|
height: 46px;
|
|
display: grid;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
flex: 0 0 46px;
|
|
border-radius: 50%;
|
|
color: var(--dc-accent);
|
|
place-items: center;
|
|
transition:
|
|
color 180ms ease,
|
|
transform 180ms ease;
|
|
}
|
|
|
|
.dc-composer-action.active {
|
|
color: #fff;
|
|
background: var(--dc-accent);
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
.dc-composer {
|
|
min-width: 0;
|
|
padding: 0;
|
|
flex: 1;
|
|
gap: 4px;
|
|
background: transparent;
|
|
}
|
|
|
|
.dc-composer :deep(textarea) {
|
|
min-height: var(--sky-touch-target);
|
|
border-radius: var(--sky-radius-pill);
|
|
padding: 12px 14px;
|
|
}
|
|
|
|
.dc-composer :deep(.sky-messagebar__right) {
|
|
margin-right: 0;
|
|
}
|
|
|
|
.dc-profile-hero {
|
|
display: flex;
|
|
padding: 18px 14px 22px;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
text-align: center;
|
|
}
|
|
|
|
.dc-profile-hero h2 {
|
|
margin-bottom: 1px;
|
|
}
|
|
|
|
.dc-profile-hero :deep(.sky-button) {
|
|
min-height: 34px;
|
|
padding: 5px 10px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.dc-profile-hero > small {
|
|
color: var(--dc-tertiary);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.dc-setting-value {
|
|
max-width: 120px;
|
|
display: block;
|
|
overflow: hidden;
|
|
color: var(--dc-tertiary);
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dc-danger-row :deep(.sky-list-item__title),
|
|
.dc-danger-row :deep(svg) {
|
|
color: var(--sky-danger);
|
|
}
|
|
|
|
.sky-dialog-button.dc-danger-text:not(:disabled) {
|
|
background: var(--sky-danger);
|
|
color: #ffffff;
|
|
}
|
|
|
|
.sky-dialog-button.dc-danger-text:active:not(:disabled) {
|
|
filter: brightness(0.86);
|
|
}
|
|
|
|
.dc-privacy-note {
|
|
display: flex;
|
|
margin: 4px 16px 22px;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
color: var(--dc-tertiary);
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.dc-privacy-note svg {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.dc-dialog p {
|
|
margin: 8px 0;
|
|
color: var(--dc-secondary);
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.dc-dialog-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
display: grid;
|
|
margin: 4px auto 10px;
|
|
border-radius: 50%;
|
|
color: var(--dc-accent);
|
|
background: var(--sky-app-accent-soft);
|
|
place-items: center;
|
|
}
|
|
|
|
.dc-dialog-icon--danger {
|
|
color: var(--sky-danger);
|
|
background: rgb(255 69 58 / 14%);
|
|
}
|
|
|
|
.dc-dialog-id {
|
|
display: block;
|
|
margin-top: 10px;
|
|
padding: 8px;
|
|
border-radius: 9px;
|
|
color: var(--dc-accent);
|
|
background: var(--dc-surface-raised);
|
|
font-size: 13px;
|
|
text-align: center;
|
|
}
|
|
|
|
.dc-action-sheet :deep(.sky-sheet__panel) {
|
|
padding: 6px 0 calc(var(--sky-safe-area-bottom) + 12px);
|
|
}
|
|
|
|
.dc-selection-sheet :deep(.sky-sheet__panel) {
|
|
max-height: 72%;
|
|
}
|
|
|
|
.dc-selection-sheet h3 {
|
|
margin: 5px 16px 11px;
|
|
color: var(--dc-label);
|
|
font-size: 17px;
|
|
text-align: center;
|
|
}
|
|
|
|
.dc-sheet-handle {
|
|
width: 36px;
|
|
height: 5px;
|
|
margin: 6px auto 10px;
|
|
border-radius: 3px;
|
|
background: rgb(255 255 255 / 24%);
|
|
}
|
|
|
|
.dc-reaction-row {
|
|
display: flex;
|
|
padding: 3px 11px 10px;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.dc-reaction-row button {
|
|
width: 42px;
|
|
height: 42px;
|
|
border: 1px solid var(--dc-border);
|
|
border-radius: 50%;
|
|
background: var(--dc-surface-raised);
|
|
font-size: 20px;
|
|
}
|
|
|
|
.dc-sheet-cancel {
|
|
width: calc(100% - 24px);
|
|
margin: 2px 12px 0;
|
|
}
|
|
|
|
.dc-report-reason {
|
|
margin: 12px 0 8px;
|
|
}
|
|
|
|
.dc-notification {
|
|
z-index: 150;
|
|
}
|
|
|
|
.dc-message :deep(.darkchat-voice) {
|
|
color: #fff;
|
|
}
|
|
|
|
.dc-message :deep(.darkchat-voice__time),
|
|
.dc-message :deep(.darkchat-voice__speed) {
|
|
color: rgb(255 255 255 / 72%);
|
|
}
|
|
|
|
@media (max-width: 360px) {
|
|
.dc-message {
|
|
max-width: 86%;
|
|
}
|
|
|
|
.dc-attachments :deep(.sky-button) {
|
|
padding-inline: 8px;
|
|
font-size: 10px;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<template>
|
|
<SkyAppPage
|
|
class="darkchat-page dc-ios"
|
|
:label="t('name')"
|
|
:dark="phone.isDarkMode"
|
|
accent="#0a84ff"
|
|
accent-soft="rgb(10 132 255 / 18%)"
|
|
>
|
|
<section v-if="!signedIn" class="dc-gate">
|
|
<SkyCard class="dc-gate-card" outline>
|
|
<span class="dc-icon-tile"><LockKeyhole :size="28" /></span>
|
|
<h1>{{ t('name') }}</h1>
|
|
<p>{{ t('signInBody') }}</p>
|
|
<small>{{ t('signInHint') }}</small>
|
|
</SkyCard>
|
|
</section>
|
|
|
|
<section v-else-if="darkchat.loading && !darkchat.profile" class="dc-gate">
|
|
<SkySpinner class="dc-preloader" />
|
|
<p>{{ phone.t('Common.loading') }}</p>
|
|
</section>
|
|
|
|
<section v-else-if="!darkchat.profile" class="dc-gate">
|
|
<SkyEmptyState
|
|
:title="t('createIdentity')"
|
|
:body="t('createIdentityBody')"
|
|
>
|
|
<template #icon><ShieldCheck :size="30" /></template>
|
|
<template #actions>
|
|
<SkyButton
|
|
large
|
|
rounded
|
|
:disabled="profileActionPending"
|
|
@click="createProfile"
|
|
>
|
|
{{
|
|
profileActionPending
|
|
? phone.t('Common.loading')
|
|
: t('createIdentity')
|
|
}}
|
|
</SkyButton>
|
|
</template>
|
|
</SkyEmptyState>
|
|
</section>
|
|
|
|
<template v-else>
|
|
<section v-if="screen === 'inbox'" class="dc-screen dc-inbox">
|
|
<SkyNavbar
|
|
class="dc-inbox-navbar"
|
|
:title="t('name')"
|
|
:subtitle="t('privateNetwork')"
|
|
variant="large"
|
|
>
|
|
<template #left>
|
|
<SkyLink
|
|
icon-only
|
|
:aria-label="t('myIdentity')"
|
|
@click="openProfile"
|
|
>
|
|
<UserRound :size="20" />
|
|
</SkyLink>
|
|
</template>
|
|
<template #right>
|
|
<SkyLink icon-only :aria-label="t('newChat')" @click="beginNewChat">
|
|
<SquarePen :size="21" />
|
|
</SkyLink>
|
|
</template>
|
|
<template #subnavbar>
|
|
<SkySearchbar
|
|
v-model="search"
|
|
class="dc-search"
|
|
:label="phone.t('Common.search')"
|
|
:placeholder="phone.t('Common.search')"
|
|
:clear-label="phone.t('Common.clear')"
|
|
/>
|
|
</template>
|
|
</SkyNavbar>
|
|
|
|
<SkyScrollArea padded class="dc-inbox-content">
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-security"
|
|
@click="copyIdentity(darkchat.profile.darkId)"
|
|
>
|
|
<LockKeyhole :size="17" />
|
|
<span>
|
|
<strong>{{ t('privateNetwork') }}</strong>
|
|
<small>{{ darkchat.profile.darkId }}</small>
|
|
</span>
|
|
<Copy :size="16" />
|
|
</SkyGlass>
|
|
|
|
<SkyList
|
|
v-if="filteredConversations.length"
|
|
inset
|
|
strong
|
|
class="dc-conversation-list"
|
|
>
|
|
<SkyListItem
|
|
v-for="conversation in filteredConversations"
|
|
:key="conversation.id"
|
|
link
|
|
link-component="button"
|
|
strong-title="auto"
|
|
:title="conversation.peer.alias"
|
|
:subtitle="preview(conversation)"
|
|
:class="{ 'dc-conversation--unread': conversation.unread }"
|
|
@click="openConversation(conversation.id)"
|
|
>
|
|
<template #media>
|
|
<span
|
|
class="dc-avatar"
|
|
:style="{
|
|
background: avatarGradient(conversation.peer.avatarSeed),
|
|
}"
|
|
>
|
|
{{ avatarGlyph(conversation.peer.avatarSeed) }}
|
|
<i v-if="conversation.unread" />
|
|
</span>
|
|
</template>
|
|
<template #after>
|
|
<time>{{ formatDate(conversation.lastMessageAt) }}</time>
|
|
</template>
|
|
<template v-if="conversation.disappearingSeconds !== 0" #footer>
|
|
<span class="dc-timer">
|
|
<Clock3 :size="11" />
|
|
{{ timerLabel(conversation.disappearingSeconds) }}
|
|
</span>
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
|
|
<SkyEmptyState
|
|
v-else
|
|
class="dc-empty"
|
|
:title="search ? t('noResults') : t('noChats')"
|
|
:body="search ? t('noResultsBody') : t('noChatsBody')"
|
|
>
|
|
<template #icon><ShieldCheck :size="27" /></template>
|
|
</SkyEmptyState>
|
|
</SkyScrollArea>
|
|
</section>
|
|
|
|
<section v-else-if="screen === 'new'" class="dc-screen">
|
|
<SkyNavbar :title="t('newChat')">
|
|
<template #right>
|
|
<SkyLink @click="back">{{ phone.t('Common.cancel') }}</SkyLink>
|
|
</template>
|
|
</SkyNavbar>
|
|
<SkyScrollArea padded class="dc-new-chat-scroll">
|
|
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
|
|
<SkyList class="dc-recipient-field" density="compact" flush>
|
|
<SkyField
|
|
:label="t('to')"
|
|
layout="inline"
|
|
:value="identifier"
|
|
autocomplete="off"
|
|
:placeholder="t('darkIdOrInvite')"
|
|
clear-button
|
|
:clear-label="phone.t('Common.clear')"
|
|
@input="setIdentifier"
|
|
@clear="identifier = ''"
|
|
@keyup.enter="requestStart()"
|
|
/>
|
|
</SkyList>
|
|
<SkyList
|
|
v-if="contactSuggestions.length"
|
|
class="dc-new-chat-contacts"
|
|
flush
|
|
>
|
|
<SkyListItem
|
|
v-for="contact in contactSuggestions"
|
|
:key="contact.id"
|
|
link
|
|
link-component="button"
|
|
contacts
|
|
strong-title="auto"
|
|
:title="contact.alias"
|
|
:subtitle="contact.darkId"
|
|
@click="requestStart(contact.darkId)"
|
|
>
|
|
<template #media>
|
|
<span
|
|
class="dc-avatar dc-avatar--small"
|
|
:style="{ background: avatarGradient(contact.avatarSeed) }"
|
|
>
|
|
{{ avatarGlyph(contact.avatarSeed) }}
|
|
</span>
|
|
</template>
|
|
<template #after>
|
|
<span class="dc-contact-badge">
|
|
<LockKeyhole :size="11" />{{ t('private') }}
|
|
</span>
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<SkyButton
|
|
v-else-if="identifier.trim()"
|
|
rounded
|
|
tonal
|
|
class="dc-identifier-action"
|
|
@click="requestStart()"
|
|
>
|
|
<MessageCirclePlus :size="17" />
|
|
{{ identifier }}
|
|
</SkyButton>
|
|
</SkyScrollArea>
|
|
</section>
|
|
|
|
<section
|
|
v-else-if="screen === 'thread' && active"
|
|
class="dc-screen dc-thread"
|
|
:class="{ 'dc-thread--panel': attachmentPanelOpen }"
|
|
>
|
|
<SkyNavbar
|
|
:title="active.peer.alias"
|
|
:subtitle="active.peer.activityVisible ? t('activeNow') : ''"
|
|
show-back
|
|
back-appearance="surface"
|
|
:back-label="phone.t('Common.back')"
|
|
@back="back"
|
|
>
|
|
<template #right>
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-navbar-action"
|
|
:aria-label="t('contactSecurity')"
|
|
@click="openContact"
|
|
>
|
|
<UserRound :size="19" />
|
|
</SkyGlass>
|
|
</template>
|
|
</SkyNavbar>
|
|
<div class="dc-thread-meta">
|
|
<LockKeyhole :size="13" />{{ t('serverPrivate') }}
|
|
<span v-if="active.disappearingSeconds !== 0">
|
|
· {{ timerLabel(active.disappearingSeconds) }}
|
|
</span>
|
|
</div>
|
|
<div ref="messagesArea" class="darkchat-thread__messages dc-messages">
|
|
<div class="dc-day">{{ dayLabel(active.createdAt) }}</div>
|
|
<template
|
|
v-for="message in darkchat.messages"
|
|
:key="message.clientId ?? message.id"
|
|
>
|
|
<div v-if="message.messageType === 'system'" class="dc-system">
|
|
<ShieldCheck :size="13" />{{ systemText(message.body) }}
|
|
</div>
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="dc-message"
|
|
:class="[
|
|
`dc-message--${message.direction}`,
|
|
{ 'dc-message--failed': message.deliveryStatus === 'failed' },
|
|
]"
|
|
@click="selectedMessage = message"
|
|
>
|
|
<span v-if="message.replyBody" class="dc-reply-preview">
|
|
<Reply :size="12" />{{ message.replyBody }}
|
|
</span>
|
|
<img
|
|
v-if="
|
|
message.messageType === 'gif' ||
|
|
message.messageType === 'image'
|
|
"
|
|
:src="message.mediaPayload || undefined"
|
|
:alt="message.messageType === 'gif' ? 'GIF' : t('photo')"
|
|
@load="scrollBottom(false)"
|
|
/>
|
|
<video
|
|
v-else-if="message.messageType === 'video'"
|
|
:src="message.mediaPayload || undefined"
|
|
controls
|
|
playsinline
|
|
preload="metadata"
|
|
@loadedmetadata="scrollBottom(false)"
|
|
/>
|
|
<DarkChatVoiceMessage
|
|
v-else-if="message.messageType === 'voice'"
|
|
:message="message"
|
|
/>
|
|
<SharedContentCard
|
|
v-else-if="
|
|
message.messageType === 'share' && message.sharePayload
|
|
"
|
|
:payload="message.sharePayload"
|
|
variant="darkchat"
|
|
/>
|
|
<span v-else class="dc-message-body">{{ message.body }}</span>
|
|
<span
|
|
v-if="Object.keys(message.reactions).length"
|
|
class="dc-reactions"
|
|
>
|
|
{{ Object.values(message.reactions).join(' ') }}
|
|
</span>
|
|
<small>
|
|
{{ formatDate(message.createdAt) }}
|
|
<template v-if="message.direction === 'sent'">
|
|
·
|
|
{{
|
|
message.deliveryStatus === 'sending'
|
|
? t('sending')
|
|
: message.deliveryStatus === 'failed'
|
|
? t('failed')
|
|
: message.readAt
|
|
? t('read')
|
|
: t('delivered')
|
|
}}
|
|
</template>
|
|
</small>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="replyTo" class="dc-replying">
|
|
<Reply :size="15" />
|
|
<span>
|
|
<small>{{ t('replying') }}</small>
|
|
<strong>{{ replyTo.body || t(replyTo.messageType) }}</strong>
|
|
</span>
|
|
<SkyLink icon-only @click="replyTo = null"><X :size="17" /></SkyLink>
|
|
</div>
|
|
<div v-if="attachmentOpen" class="dc-attachments">
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-attachment-action"
|
|
@click="openMediaApp('photos', 'photo')"
|
|
>
|
|
<span><Images :size="19" /></span>
|
|
<small>{{ t('photo') }}</small>
|
|
</SkyGlass>
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-attachment-action"
|
|
@click="openMediaApp('camera', 'photo')"
|
|
>
|
|
<span><Camera :size="19" /></span>
|
|
<small>{{ t('takePhoto') }}</small>
|
|
</SkyGlass>
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-attachment-action"
|
|
@click="openEmojiPanel"
|
|
>
|
|
<span><Smile :size="19" /></span>
|
|
<small>{{ t('emoji') }}</small>
|
|
</SkyGlass>
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-attachment-action"
|
|
@click="openGifPanel"
|
|
>
|
|
<span><ImagePlay :size="19" /></span>
|
|
<small>{{ t('gif') }}</small>
|
|
</SkyGlass>
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-attachment-action"
|
|
@click="openMediaApp('photos', 'video')"
|
|
>
|
|
<span><Video :size="19" /></span>
|
|
<small>{{ t('video') }}</small>
|
|
</SkyGlass>
|
|
</div>
|
|
<SkyCard v-if="gifOpen" class="dc-gif-panel" outline>
|
|
<header>
|
|
<strong>{{ t('gifs') }}</strong>
|
|
<SkyLink @click="gifOpen = false">{{
|
|
phone.t('Common.done')
|
|
}}</SkyLink>
|
|
</header>
|
|
<SkySearchbar
|
|
:value="gifQuery"
|
|
:label="t('searchGifs')"
|
|
:placeholder="t('searchGifs')"
|
|
:clear-label="phone.t('Common.clear')"
|
|
@input="updateGifSearch"
|
|
@clear="clearGifSearch"
|
|
/>
|
|
<div class="dc-gif-grid">
|
|
<button
|
|
v-for="gif in gifResults"
|
|
:key="gif.id"
|
|
type="button"
|
|
@click="sendGif(gif)"
|
|
>
|
|
<img :src="gif.previewUrl" :alt="gif.title" />
|
|
</button>
|
|
</div>
|
|
<SkyButton v-if="gifHasMore && !gifLoading" clear @click="loadGifs()">
|
|
{{ t('loadMore') }}
|
|
</SkyButton>
|
|
<SkySpinner v-if="gifLoading" class="dc-preloader" />
|
|
</SkyCard>
|
|
<FullEmojiPicker
|
|
v-if="emojiOpen"
|
|
@close="emojiOpen = false"
|
|
@pick="appendEmoji"
|
|
/>
|
|
<div
|
|
v-if="shareDraft"
|
|
class="shared-composer-preview shared-composer-preview--dark"
|
|
>
|
|
<SharedContentCard compact :payload="shareDraft" variant="darkchat" />
|
|
<button
|
|
type="button"
|
|
:aria-label="phone.t('Common.close')"
|
|
@click="shareDraft = null"
|
|
>
|
|
<X :size="15" />
|
|
</button>
|
|
</div>
|
|
<SkyGlass v-if="recording" class="dc-composer-pill dc-recorder">
|
|
<SkyLink icon-only @click="cancelRecording"><X :size="19" /></SkyLink>
|
|
<i />
|
|
<time>{{ recordingTime() }}</time>
|
|
<span>
|
|
<b
|
|
v-for="(level, index) in recordingLevels"
|
|
:key="index"
|
|
:style="{ height: `${Math.max(3, level * 23)}px` }"
|
|
/>
|
|
</span>
|
|
<SkyLink icon-only @click="stopRecording">
|
|
<ArrowUpCircle :size="29" />
|
|
</SkyLink>
|
|
</SkyGlass>
|
|
<div v-else class="dc-composer-row">
|
|
<SkyGlass component="div" :highlight="false" class="dc-composer-pill">
|
|
<SkyGlass
|
|
component="button"
|
|
class="dc-composer-action"
|
|
:class="{ active: attachmentOpen || attachmentPanelOpen }"
|
|
:aria-label="phone.t('Common.add')"
|
|
@click="toggleAttachmentPanel"
|
|
>
|
|
<Plus :size="23" />
|
|
</SkyGlass>
|
|
<SkyMessagebar
|
|
class="dc-composer"
|
|
embedded
|
|
:outline="false"
|
|
:value="draft"
|
|
:placeholder="t('message')"
|
|
@input="setDraft"
|
|
@keydown.enter.exact="handleEnterAction($event, sendText)"
|
|
>
|
|
<template #right>
|
|
<SkyLink
|
|
v-if="draft.trim() || shareDraft"
|
|
icon-only
|
|
@click="sendText"
|
|
>
|
|
<ArrowUpCircle :size="27" />
|
|
</SkyLink>
|
|
<SkyLink v-else icon-only @click="startRecording">
|
|
<Mic :size="20" />
|
|
</SkyLink>
|
|
</template>
|
|
</SkyMessagebar>
|
|
</SkyGlass>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-else-if="screen === 'contact' && active" class="dc-screen">
|
|
<SkyNavbar
|
|
:title="t('contactSecurity')"
|
|
show-back
|
|
back-appearance="surface"
|
|
:back-label="phone.t('Common.back')"
|
|
@back="back"
|
|
/>
|
|
<SkyScrollArea padded class="dc-scroll-content">
|
|
<div class="dc-profile-hero">
|
|
<span
|
|
class="dc-avatar dc-avatar--profile"
|
|
:style="{ background: avatarGradient(active.peer.avatarSeed) }"
|
|
>
|
|
{{ avatarGlyph(active.peer.avatarSeed) }}
|
|
</span>
|
|
<h2>{{ active.peer.alias }}</h2>
|
|
<SkyButton clear rounded @click="copyIdentity(active.peer.darkId)">
|
|
{{ active.peer.darkId }}<Copy :size="14" />
|
|
</SkyButton>
|
|
<small>{{
|
|
t('chatSince', { date: dayLabel(active.createdAt) })
|
|
}}</small>
|
|
</div>
|
|
|
|
<SkySettingsGroup :title="t('chatSettings')">
|
|
<SkySettingsRow
|
|
kind="toggle"
|
|
:title="t('notifications')"
|
|
:model-value="active.notificationsEnabled"
|
|
@update:model-value="setConversationNotifications"
|
|
>
|
|
<template #leading><Bell :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="toggle"
|
|
:title="t('readReceipts')"
|
|
:model-value="active.readReceipts"
|
|
@update:model-value="setReadReceipts"
|
|
>
|
|
<template #leading><Check :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="navigation"
|
|
:title="t('disappearing')"
|
|
:value="timerLabel(active.disappearingSeconds)"
|
|
@activate="selectionSheet = 'disappearing'"
|
|
>
|
|
<template #leading><Clock3 :size="20" /></template>
|
|
</SkySettingsRow>
|
|
</SkySettingsGroup>
|
|
|
|
<SkyList inset strong class="dc-form-list">
|
|
<SkyField
|
|
:label="t('contactAlias')"
|
|
:value="contactAliasDraft"
|
|
maxlength="32"
|
|
@input="setContactAliasDraft"
|
|
/>
|
|
</SkyList>
|
|
<SkyButton
|
|
large
|
|
rounded
|
|
block
|
|
class="dc-primary"
|
|
@click="saveContact"
|
|
>
|
|
<UserPlus :size="18" />
|
|
{{ active.peer.isContact ? t('saveContact') : t('addContact') }}
|
|
</SkyButton>
|
|
|
|
<SkySettingsGroup :title="t('contactActions')">
|
|
<SkySettingsRow
|
|
v-if="active.peer.isContact"
|
|
kind="action"
|
|
:title="t('removeContact')"
|
|
@activate="removeContact"
|
|
>
|
|
<template #leading><UserMinus :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="action"
|
|
tone="danger"
|
|
:title="active.peer.blocked ? t('unblock') : t('block')"
|
|
@activate="toggleBlock"
|
|
>
|
|
<template #leading><ShieldOff :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="action"
|
|
tone="danger"
|
|
:title="t('report')"
|
|
@activate="beginReport()"
|
|
>
|
|
<template #leading><BellOff :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="action"
|
|
tone="danger"
|
|
:title="t('clearChat')"
|
|
@activate="clearChat"
|
|
>
|
|
<template #leading><Trash2 :size="20" /></template>
|
|
</SkySettingsRow>
|
|
</SkySettingsGroup>
|
|
</SkyScrollArea>
|
|
</section>
|
|
|
|
<section v-else-if="screen === 'profile'" class="dc-screen">
|
|
<SkyNavbar
|
|
:title="t('myIdentity')"
|
|
show-back
|
|
back-appearance="surface"
|
|
:back-label="phone.t('Common.back')"
|
|
@back="back"
|
|
>
|
|
<template #right>
|
|
<SkyLink @click="saveProfile">{{ phone.t('Common.done') }}</SkyLink>
|
|
</template>
|
|
</SkyNavbar>
|
|
<SkyScrollArea padded class="dc-scroll-content">
|
|
<div class="dc-profile-hero">
|
|
<span
|
|
class="dc-avatar dc-avatar--profile"
|
|
:style="{
|
|
background: avatarGradient(darkchat.profile.avatarSeed),
|
|
}"
|
|
>
|
|
{{ avatarGlyph(darkchat.profile.avatarSeed) }}
|
|
</span>
|
|
<h2>{{ darkchat.profile.alias }}</h2>
|
|
<SkyButton
|
|
clear
|
|
rounded
|
|
@click="copyIdentity(darkchat.profile.darkId)"
|
|
>
|
|
{{ darkchat.profile.darkId }}<Copy :size="14" />
|
|
</SkyButton>
|
|
</div>
|
|
|
|
<SkyList inset strong class="dc-form-list">
|
|
<SkyField
|
|
:label="t('alias')"
|
|
:value="aliasDraft"
|
|
maxlength="32"
|
|
@input="setAliasDraft"
|
|
/>
|
|
</SkyList>
|
|
|
|
<SkySettingsGroup :title="t('privacySettings')">
|
|
<SkySettingsRow
|
|
kind="navigation"
|
|
:title="t('notificationPrivacy')"
|
|
:value="
|
|
notificationOptions.find(
|
|
(option) => option.value === notificationMode,
|
|
)?.label
|
|
"
|
|
@activate="selectionSheet = 'notification'"
|
|
>
|
|
<template #leading><Bell :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="toggle"
|
|
:title="t('shareActivity')"
|
|
:model-value="activityVisible"
|
|
@update:model-value="setActivityVisible"
|
|
>
|
|
<template #leading><ShieldCheck :size="20" /></template>
|
|
</SkySettingsRow>
|
|
</SkySettingsGroup>
|
|
|
|
<SkyCard class="dc-qr-card" outline :content-wrap="false">
|
|
<div class="dc-faux-qr"><QrCode :size="58" /></div>
|
|
<div>
|
|
<strong>{{ darkchat.profile.inviteCode }}</strong>
|
|
<small>{{ t('inviteCode') }}</small>
|
|
</div>
|
|
<div class="dc-invite-actions">
|
|
<SkyButton
|
|
clear
|
|
rounded
|
|
@click="copyIdentity(darkchat.profile.inviteCode)"
|
|
>
|
|
<Copy :size="16" />{{ t('copyInvite') }}
|
|
</SkyButton>
|
|
<SkyButton tonal rounded @click="sharePrivateInvite">
|
|
<Share2 :size="16" />{{ t('shareInvite') }}
|
|
</SkyButton>
|
|
</div>
|
|
</SkyCard>
|
|
<p class="dc-privacy-note">
|
|
<LockKeyhole :size="17" />{{ t('privacyDisclaimer') }}
|
|
</p>
|
|
|
|
<SkySettingsGroup
|
|
:title="t('profileActions')"
|
|
:footer="t('signOutHint')"
|
|
>
|
|
<SkySettingsRow
|
|
kind="action"
|
|
:title="t('signOut')"
|
|
@activate="signOutOpen = true"
|
|
>
|
|
<template #leading><LogOut :size="20" /></template>
|
|
</SkySettingsRow>
|
|
<SkySettingsRow
|
|
kind="action"
|
|
tone="danger"
|
|
:title="t('deleteProfile')"
|
|
@activate="deleteProfileOpen = true"
|
|
>
|
|
<template #leading><Trash2 :size="20" /></template>
|
|
</SkySettingsRow>
|
|
</SkySettingsGroup>
|
|
</SkyScrollArea>
|
|
</section>
|
|
</template>
|
|
|
|
<SkyDialog
|
|
:opened="safetyOpen"
|
|
class="dc-dialog"
|
|
@backdropclick="safetyOpen = false"
|
|
@escape="safetyOpen = false"
|
|
>
|
|
<template #title>{{ t('unknownIdentity') }}</template>
|
|
<span class="dc-dialog-icon"><ShieldCheck :size="25" /></span>
|
|
<p>{{ t('unknownIdentityBody') }}</p>
|
|
<strong class="dc-dialog-id">{{ pendingIdentifier }}</strong>
|
|
<template #buttons>
|
|
<SkyDialogButton @click="safetyOpen = false">
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton strong @click="confirmStart">
|
|
{{ t('openSecureChat') }}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
|
|
<SkySheet
|
|
:opened="Boolean(selectedMessage)"
|
|
:aria-label="t('messageActions')"
|
|
class="dc-action-sheet"
|
|
@backdropclick="selectedMessage = null"
|
|
@escape="selectedMessage = null"
|
|
>
|
|
<template v-if="selectedMessage">
|
|
<div class="dc-sheet-handle" />
|
|
<div class="dc-reaction-row">
|
|
<button
|
|
v-for="reaction in ['❤️', '👍', '👎', '😂', '‼️', '❓']"
|
|
:key="reaction"
|
|
type="button"
|
|
@click="react(selectedMessage, reaction)"
|
|
>
|
|
{{ reaction }}
|
|
</button>
|
|
</div>
|
|
<SkyList inset strong class="dc-action-list">
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
:title="phone.t('Apps.easyShare.name')"
|
|
@click="shareMessage(selectedMessage)"
|
|
>
|
|
<template #media><Share2 :size="20" /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
:title="t('reply')"
|
|
@click="beginReply(selectedMessage)"
|
|
>
|
|
<template #media><Reply :size="20" /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
v-if="
|
|
selectedMessage.messageType === 'text' ||
|
|
selectedMessage.messageType === 'emoji'
|
|
"
|
|
link
|
|
link-component="button"
|
|
:title="t('copy')"
|
|
@click="copyMessage(selectedMessage)"
|
|
>
|
|
<template #media><Copy :size="20" /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
:title="t('deleteForMe')"
|
|
@click="messageAction(selectedMessage, 'delete_me')"
|
|
>
|
|
<template #media><Trash2 :size="20" /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
v-if="selectedMessage.direction === 'sent'"
|
|
link
|
|
link-component="button"
|
|
class="dc-danger-row"
|
|
:title="t('deleteForBoth')"
|
|
@click="messageAction(selectedMessage, 'delete_all')"
|
|
>
|
|
<template #media><Trash2 :size="20" /></template>
|
|
</SkyListItem>
|
|
<SkyListItem
|
|
v-if="selectedMessage.direction === 'received'"
|
|
link
|
|
link-component="button"
|
|
class="dc-danger-row"
|
|
:title="t('report')"
|
|
@click="beginReport(selectedMessage)"
|
|
>
|
|
<template #media><ShieldOff :size="20" /></template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<SkyButton
|
|
large
|
|
rounded
|
|
block
|
|
variant="secondary"
|
|
class="dc-sheet-cancel"
|
|
@click="selectedMessage = null"
|
|
>
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyButton>
|
|
</template>
|
|
</SkySheet>
|
|
|
|
<SkyDialog
|
|
:opened="reportOpen"
|
|
class="dc-dialog dc-report-dialog"
|
|
@backdropclick="closeReport"
|
|
@escape="closeReport"
|
|
>
|
|
<template #title>{{ t('reportUser') }}</template>
|
|
<span class="dc-dialog-icon dc-dialog-icon--danger">
|
|
<ShieldOff :size="25" />
|
|
</span>
|
|
<SkyList inset strong class="dc-action-list dc-report-reason">
|
|
<SkyListItem
|
|
link
|
|
link-component="button"
|
|
:title="t('reportUser')"
|
|
@click="selectionSheet = 'report'"
|
|
>
|
|
<template #after>
|
|
<span class="dc-setting-value">
|
|
{{
|
|
reportOptions.find((option) => option.value === reportReason)
|
|
?.label
|
|
}}
|
|
</span>
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<SkyList inset strong class="dc-form-list">
|
|
<SkyField
|
|
type="textarea"
|
|
:value="reportDetails"
|
|
maxlength="500"
|
|
:placeholder="t('reportDetails')"
|
|
@input="setReportDetails"
|
|
/>
|
|
</SkyList>
|
|
<template #buttons>
|
|
<SkyDialogButton @click="closeReport">
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton strong class="dc-danger-text" @click="submitReport">
|
|
{{ t('submitReport') }}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
|
|
<SkySheet
|
|
:opened="selectionSheet !== null"
|
|
:aria-label="selectionTitle"
|
|
class="dc-action-sheet dc-selection-sheet"
|
|
@backdropclick="selectionSheet = null"
|
|
@escape="selectionSheet = null"
|
|
>
|
|
<div class="dc-sheet-handle" />
|
|
<h3>{{ selectionTitle }}</h3>
|
|
<SkyList inset strong class="dc-action-list dc-selection-list">
|
|
<SkyListItem
|
|
v-for="option in selectionOptions"
|
|
:key="option.value"
|
|
link
|
|
link-component="button"
|
|
:chevron="false"
|
|
:title="option.label"
|
|
@click="chooseSelection(option.value)"
|
|
>
|
|
<template
|
|
v-if="String(option.value) === String(selectionValue)"
|
|
#after
|
|
>
|
|
<Check :size="20" />
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
<SkyButton
|
|
large
|
|
rounded
|
|
block
|
|
variant="secondary"
|
|
class="dc-sheet-cancel"
|
|
@click="selectionSheet = null"
|
|
>
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyButton>
|
|
</SkySheet>
|
|
|
|
<SkyDialog
|
|
:opened="signOutOpen"
|
|
:title="t('signOutTitle')"
|
|
:content="t('signOutBody')"
|
|
@backdropclick="signOutOpen = false"
|
|
@escape="signOutOpen = false"
|
|
>
|
|
<template #buttons>
|
|
<SkyDialogButton
|
|
:disabled="profileActionPending"
|
|
@click="signOutOpen = false"
|
|
>
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton
|
|
strong
|
|
:disabled="profileActionPending"
|
|
@click="signOut"
|
|
>
|
|
{{ profileActionPending ? t('signingOut') : t('signOut') }}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
|
|
<SkyDialog
|
|
:opened="deleteProfileOpen"
|
|
:title="t('deleteProfileTitle')"
|
|
:content="t('deleteProfileBody')"
|
|
@backdropclick="deleteProfileOpen = false"
|
|
@escape="deleteProfileOpen = false"
|
|
>
|
|
<template #buttons>
|
|
<SkyDialogButton
|
|
:disabled="profileActionPending"
|
|
@click="deleteProfileOpen = false"
|
|
>
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton
|
|
strong
|
|
class="dc-danger-text"
|
|
:disabled="profileActionPending"
|
|
@click="deleteProfile"
|
|
>
|
|
{{
|
|
profileActionPending
|
|
? t('deletingProfile')
|
|
: phone.t('Common.delete')
|
|
}}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
|
|
<SkyNotification
|
|
:opened="Boolean(toast)"
|
|
:text="toast"
|
|
class="dc-notification"
|
|
/>
|
|
</SkyAppPage>
|
|
</template>
|