mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - build DarkChat phone app
This commit is contained in:
@@ -26,6 +26,7 @@ import { useCallsStore } from '@/stores/calls'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
@@ -52,6 +53,7 @@ type AppMessage = {
|
||||
| MailEventData
|
||||
| MarketplaceEventData
|
||||
| MessagesEventData
|
||||
| DarkChatEventData
|
||||
| PhoneCall
|
||||
| PhoneNotificationInput
|
||||
| PhoneOpenPayload
|
||||
@@ -79,6 +81,16 @@ type MessagesEventData = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
type DarkChatEventData = {
|
||||
conversationId?: string
|
||||
device?: PhoneNotificationDevicePayload
|
||||
notificationMode?: 'full' | 'private' | 'hidden'
|
||||
preview?: string
|
||||
sender?: string
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type MarketplaceEventData = {
|
||||
counts?: MarketplaceCounts
|
||||
device?: PhoneNotificationDevicePayload
|
||||
@@ -109,6 +121,7 @@ const games = useGamesStore()
|
||||
const calls = useCallsStore()
|
||||
const mail = useMailStore()
|
||||
const messages = useMessagesStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const appStore = useAppStoreStore()
|
||||
@@ -159,6 +172,7 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
else marketplace.setCounts({ active: 0, unread: 0 })
|
||||
void calls.bootstrap()
|
||||
void messages.loadConversations()
|
||||
if (payload.account?.email) void darkchat.bootstrap()
|
||||
}
|
||||
|
||||
async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
@@ -313,6 +327,33 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
} else if (event.data?.type === 'darkchat:changed') {
|
||||
void darkchat.refreshInbox()
|
||||
if (darkchat.activeConversation) {
|
||||
void darkchat.openThread(darkchat.activeConversation.id)
|
||||
}
|
||||
} else if (event.data?.type === 'darkchat:new' && event.data.data) {
|
||||
const data = event.data.data as DarkChatEventData
|
||||
void darkchat.refreshInbox()
|
||||
if (data.conversationId && darkchat.activeConversation?.id === data.conversationId) {
|
||||
void darkchat.openThread(data.conversationId)
|
||||
}
|
||||
if (data.notificationMode !== 'hidden') {
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'darkchat',
|
||||
subtitle: data.sender,
|
||||
text: data.text ?? data.preview ?? phone.t('Apps.darkchat.privateNotification'),
|
||||
title: data.title ?? phone.t('Apps.darkchat.name'),
|
||||
}
|
||||
if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
}
|
||||
} else if (event.data?.type === 'calls:changed') {
|
||||
void calls.loadRecents()
|
||||
} else if (
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="18" y1="12" x2="108" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9f7aea"/>
|
||||
<stop offset="1" stop-color="#5b21b6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="#050507"/>
|
||||
<path d="M24 30c0-8 6-14 14-14h52c8 0 14 6 14 14v42c0 8-6 14-14 14H65L43 105c-3 3-8 1-8-4V86c-6-2-11-7-11-14V30Z" fill="url(#g)"/>
|
||||
<path d="M49 51a15 15 0 0 1 30 0v5h3c3 0 5 2 5 5v14c0 3-2 5-5 5H46c-3 0-5-2-5-5V61c0-3 2-5 5-5h3v-5Zm8 5h14v-5a7 7 0 0 0-14 0v5Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
@@ -582,6 +582,396 @@ button {
|
||||
transition-duration: 0.18s;
|
||||
}
|
||||
}
|
||||
|
||||
/* DarkChat is intentionally independent from the phone appearance setting. */
|
||||
.phone-app:has(.darkchat-page),
|
||||
.phone-app:has(.darkchat-page) .phone-app-window,
|
||||
.phone-app:has(.darkchat-page) .phone-app-window__content {
|
||||
background: #000 !important;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.phone-app:has(.darkchat-page) .phone-status-bar {
|
||||
color: #fff !important;
|
||||
--status-bar-color: #fff;
|
||||
}
|
||||
.app-icon--darkchat { background: #050507; }
|
||||
.darkchat-page {
|
||||
--dc-purple: #8b5cf6;
|
||||
--dc-purple-dark: #5b21b6;
|
||||
--dc-blue: #0a84ff;
|
||||
--dc-red: #ff453a;
|
||||
--dc-surface: #1c1c1e;
|
||||
--dc-surface-2: #29292c;
|
||||
--dc-border: #38383a;
|
||||
--dc-muted: #8e8e93;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #f5f5f7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
|
||||
}
|
||||
.darkchat-page button,
|
||||
.darkchat-page input,
|
||||
.darkchat-page textarea,
|
||||
.darkchat-page select { font: inherit; }
|
||||
.darkchat-page button { color: inherit; }
|
||||
.darkchat-page input,
|
||||
.darkchat-page textarea,
|
||||
.darkchat-page select { color: #f5f5f7; }
|
||||
.darkchat-page button:disabled { opacity: .34; }
|
||||
.darkchat-page button:active:not(:disabled) { transform: scale(.96); }
|
||||
.darkchat-round {
|
||||
width: 39px;
|
||||
height: 39px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: 50%;
|
||||
background: var(--dc-surface);
|
||||
transition: transform .14s ease, background .2s ease;
|
||||
}
|
||||
.darkchat-round--large { width: 43px; height: 43px; }
|
||||
.darkchat-round.active { background: var(--dc-purple); border-color: var(--dc-purple); }
|
||||
.darkchat-pill {
|
||||
min-width: 67px;
|
||||
height: 37px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: 19px;
|
||||
background: var(--dc-surface);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.darkchat-gate {
|
||||
height: 100%;
|
||||
padding: 58px 27px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.darkchat-gate > span:not(.darkchat-loader) {
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(145deg, var(--dc-purple), var(--dc-purple-dark));
|
||||
box-shadow: 0 18px 45px rgb(91 33 182 / 40%);
|
||||
}
|
||||
.darkchat-gate h1 { margin: 21px 0 8px; font-size: 27px; }
|
||||
.darkchat-gate p { margin: 0; color: #d1d1d6; font-size: 14px; line-height: 1.45; }
|
||||
.darkchat-gate small { margin-top: 13px; color: var(--dc-muted); font-size: 11px; }
|
||||
.darkchat-loader {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
display: block;
|
||||
border: 3px solid rgb(139 92 246 / 25%);
|
||||
border-top-color: var(--dc-purple);
|
||||
border-radius: 50%;
|
||||
animation: darkchat-spin .7s linear infinite;
|
||||
}
|
||||
.darkchat-loader--small { width: 17px; height: 17px; border-width: 2px; }
|
||||
.darkchat-inbox,
|
||||
.darkchat-sheet-page,
|
||||
.darkchat-thread { height: 100%; padding: 44px 0 25px; box-sizing: border-box; }
|
||||
.darkchat-inbox { display: flex; flex-direction: column; }
|
||||
.darkchat-inbox__header,
|
||||
.darkchat-sheet-page > header,
|
||||
.darkchat-thread__header {
|
||||
height: 62px;
|
||||
padding: 5px 15px 7px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
box-sizing: border-box;
|
||||
background: #000;
|
||||
}
|
||||
.darkchat-inbox__header > strong,
|
||||
.darkchat-sheet-page > header > strong { font-size: 17px; font-weight: 720; letter-spacing: -.2px; }
|
||||
.darkchat-inbox__header > :last-child,
|
||||
.darkchat-sheet-page > header > :last-child { justify-self: end; }
|
||||
.darkchat-security-strip {
|
||||
height: 28px;
|
||||
margin: 0 15px 4px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgb(139 92 246 / 20%);
|
||||
border-radius: 9px;
|
||||
background: rgb(139 92 246 / 8%);
|
||||
color: #b9a3ff;
|
||||
font-size: 9px;
|
||||
}
|
||||
.darkchat-security-strip span { flex: 1; }
|
||||
.darkchat-security-strip button { padding: 0; border: 0; background: transparent; color: #b9a3ff; font-size: 8px; }
|
||||
.darkchat-conversations { min-height: 0; flex: 1; padding: 3px 12px 76px; overflow-y: auto; }
|
||||
.darkchat-conversation {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 67px;
|
||||
padding: 8px 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.darkchat-conversation::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 59px;
|
||||
height: 1px;
|
||||
background: #262628;
|
||||
}
|
||||
.darkchat-unread {
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--dc-purple);
|
||||
box-shadow: 0 0 10px rgb(139 92 246 / 80%);
|
||||
}
|
||||
.darkchat-avatar {
|
||||
width: 49px;
|
||||
height: 49px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 17%);
|
||||
border-radius: 50%;
|
||||
color: rgb(255 255 255 / 90%);
|
||||
font-size: 23px;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 1px 7px rgb(0 0 0 / 35%);
|
||||
}
|
||||
.darkchat-avatar--small { width: 37px; height: 37px; font-size: 18px; }
|
||||
.darkchat-avatar--header { width: 39px; height: 39px; font-size: 18px; }
|
||||
.darkchat-conversation__body { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||
.darkchat-conversation__body > span { display: flex; align-items: center; gap: 4px; }
|
||||
.darkchat-conversation__body strong { min-width: 0; flex: 1; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.darkchat-conversation__body time { color: var(--dc-muted); font-size: 10px; }
|
||||
.darkchat-conversation__body svg { color: #636366; }
|
||||
.darkchat-conversation__body small { overflow: hidden; color: var(--dc-muted); font-size: 11px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.darkchat-timer-icon { position: absolute; right: 4px; bottom: 7px; color: #a78bfa; }
|
||||
.darkchat-empty { flex: 1; padding: 15px 40px 80px; display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; }
|
||||
.darkchat-empty > span { width: 57px; height: 57px; display: grid; place-items: center; border-radius: 20px; background: var(--dc-surface); color: var(--dc-purple); }
|
||||
.darkchat-empty h2 { margin: 14px 0 5px; font-size: 18px; }
|
||||
.darkchat-empty p { margin: 0; color: var(--dc-muted); font-size: 12px; line-height: 1.4; }
|
||||
.darkchat-inbox__toolbar {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
right: 12px;
|
||||
bottom: 28px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.darkchat-inbox__toolbar label {
|
||||
height: 43px;
|
||||
padding: 0 13px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: 23px;
|
||||
background: rgb(28 28 30 / 94%);
|
||||
backdrop-filter: blur(22px);
|
||||
}
|
||||
.darkchat-inbox__toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 12px; }
|
||||
.darkchat-sheet-page { overflow-y: auto; padding-right: 15px; padding-left: 15px; }
|
||||
.darkchat-sheet-page > header { position: sticky; z-index: 4; top: -1px; margin: 0 -15px; }
|
||||
.darkchat-new-hero { padding: 14px 23px 19px; text-align: center; }
|
||||
.darkchat-new-hero > span { width: 59px; height: 59px; margin: auto; display: grid; place-items: center; border-radius: 20px; background: linear-gradient(145deg, var(--dc-purple), var(--dc-purple-dark)); }
|
||||
.darkchat-new-hero h2 { margin: 12px 0 4px; font-size: 19px; }
|
||||
.darkchat-new-hero p { margin: 0; color: var(--dc-muted); font-size: 11px; line-height: 1.4; }
|
||||
.darkchat-input { margin: 9px 0; padding: 9px 12px; display: flex; flex-direction: column; gap: 5px; border: 1px solid var(--dc-border); border-radius: 12px; background: var(--dc-surface); }
|
||||
.darkchat-input span { color: var(--dc-muted); font-size: 9px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.darkchat-input input { border: 0; outline: 0; background: transparent; font-size: 13px; }
|
||||
.darkchat-primary,
|
||||
.darkchat-danger {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
margin: 3px 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--dc-purple), var(--dc-purple-dark));
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.darkchat-danger { background: rgb(255 69 58 / 14%); color: var(--dc-red) !important; }
|
||||
.darkchat-sheet-page h3 { margin: 17px 5px 5px; color: var(--dc-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.darkchat-contact-row {
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
padding: 7px 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #272729;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.darkchat-contact-row > span:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.darkchat-contact-row strong { font-size: 13px; }
|
||||
.darkchat-contact-row small { color: var(--dc-muted); font-size: 9px; }
|
||||
.darkchat-contact-row svg { color: var(--dc-muted); }
|
||||
.darkchat-qr-card { margin: 18px 0 6px; padding: 15px; display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; border: 1px solid var(--dc-border); border-radius: 18px; background: var(--dc-surface); }
|
||||
.darkchat-faux-qr { width: 72px; height: 72px; display: grid; place-items: center; border-radius: 10px; background: #fff; color: #111; }
|
||||
.darkchat-qr-card > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.darkchat-qr-card > span strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; }
|
||||
.darkchat-qr-card > span small { color: var(--dc-muted); font-size: 9px; }
|
||||
.darkchat-qr-card > button { grid-column: 1 / -1; height: 35px; display: flex; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 10px; background: rgb(139 92 246 / 16%); color: #c4b5fd; font-size: 11px; }
|
||||
.darkchat-thread { display: flex; flex-direction: column; padding-bottom: 79px; }
|
||||
.darkchat-thread--panel { padding-bottom: 342px; }
|
||||
.darkchat-thread__header { position: relative; flex: none; grid-template-columns: 42px 1fr 42px; }
|
||||
.darkchat-thread__identity { min-width: 0; padding: 0; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; background: transparent; }
|
||||
.darkchat-thread__identity > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
|
||||
.darkchat-thread__identity strong { max-width: 142px; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.darkchat-thread__identity small { color: var(--dc-muted); font-size: 8px; }
|
||||
.darkchat-thread__identity svg { color: var(--dc-muted); }
|
||||
.darkchat-thread__meta { height: 21px; flex: none; display: flex; align-items: center; justify-content: center; gap: 3px; border-bottom: 1px solid #1b1b1d; color: var(--dc-muted); font-size: 8px; }
|
||||
.darkchat-thread__messages { min-height: 0; padding: 12px 13px 18px; flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 5px; overflow-y: auto; }
|
||||
.darkchat-day { align-self: center; margin: 4px 0 8px; color: var(--dc-muted); font-size: 9px; font-weight: 600; }
|
||||
.darkchat-system { max-width: 86%; margin: 7px auto; display: flex; align-items: center; gap: 4px; color: var(--dc-muted); font-size: 8px; text-align: center; }
|
||||
.darkchat-message {
|
||||
position: relative;
|
||||
max-width: 82%;
|
||||
padding: 7px 10px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
border: 0;
|
||||
border-radius: 15px;
|
||||
background: var(--dc-surface-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
animation: darkchat-message-in .28s cubic-bezier(.32,.72,0,1) both;
|
||||
}
|
||||
.darkchat-message--sent { align-self: flex-end; background: linear-gradient(145deg, #8b5cf6, #6d28d9); }
|
||||
.darkchat-message--received::before,
|
||||
.darkchat-message--sent::after { content: ""; position: absolute; bottom: 0; width: 9px; height: 11px; }
|
||||
.darkchat-message--received::before { left: -5px; border-bottom-right-radius: 10px; background: var(--dc-surface-2); }
|
||||
.darkchat-message--sent::after { right: -5px; border-bottom-left-radius: 10px; background: #6d28d9; }
|
||||
.darkchat-message > small { align-self: flex-end; color: rgb(255 255 255 / 57%); font-size: 7px; white-space: nowrap; }
|
||||
.darkchat-message--received > small { color: var(--dc-muted); }
|
||||
.darkchat-message--failed { outline: 1px solid var(--dc-red); }
|
||||
.darkchat-message > img { width: min(181px, 62vw); max-height: 175px; margin: -5px -8px 1px; border-radius: 12px; object-fit: cover; }
|
||||
.darkchat-reply-preview { padding: 4px 6px; display: flex; align-items: center; gap: 4px; overflow: hidden; border-left: 2px solid #c4b5fd; border-radius: 4px; background: rgb(0 0 0 / 18%); color: rgb(255 255 255 / 72%); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.darkchat-reactions { position: absolute; right: 4px; bottom: -15px; z-index: 2; padding: 1px 5px; border: 2px solid #000; border-radius: 9px; background: #343438; font-size: 9px; white-space: nowrap; }
|
||||
.darkchat-message:has(.darkchat-reactions) { margin-bottom: 12px; }
|
||||
.darkchat-replying { position: absolute; z-index: 6; right: 55px; bottom: 72px; left: 55px; min-height: 39px; padding: 5px 8px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 11px; background: rgb(28 28 30 / 96%); color: #c4b5fd; }
|
||||
.darkchat-replying > span { min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.darkchat-replying small { font-size: 7px; text-transform: uppercase; }
|
||||
.darkchat-replying strong { overflow: hidden; color: #fff; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.darkchat-replying button { width: 25px; height: 25px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #343438; }
|
||||
.darkchat-composer { position: absolute; z-index: 7; right: 12px; bottom: 28px; left: 12px; display: flex; align-items: flex-end; gap: 8px; }
|
||||
.darkchat-composer > label { min-height: 41px; padding: 6px 6px 6px 13px; flex: 1; display: flex; align-items: center; gap: 5px; border: 1px solid var(--dc-border); border-radius: 21px; background: var(--dc-surface); }
|
||||
.darkchat-composer textarea { min-height: 19px; max-height: 70px; min-width: 0; padding: 2px 0; flex: 1; resize: none; border: 0; outline: 0; background: transparent; font-size: 12px; line-height: 17px; }
|
||||
.darkchat-composer label button { width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: #a78bfa; }
|
||||
.darkchat-actions-bubbles { position: absolute; z-index: 8; bottom: 76px; left: 14px; display: flex; flex-direction: column-reverse; align-items: flex-start; gap: 6px; }
|
||||
.darkchat-actions-bubbles button { min-height: 32px; padding: 3px 10px 3px 4px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 17px; background: var(--dc-surface); font-size: 9px; box-shadow: 0 7px 19px rgb(0 0 0 / 45%); animation: darkchat-bubble-in .23s cubic-bezier(.32,.72,0,1) both; }
|
||||
.darkchat-actions-bubbles button > span { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; background: rgb(139 92 246 / 18%); color: #c4b5fd; font-size: 15px; }
|
||||
.darkchat-gif-panel,
|
||||
.darkchat-page .messages-full-emoji-picker { position: absolute; z-index: 8; right: 0; bottom: 0; left: 0; height: 285px; border-top: 1px solid var(--dc-border); background: rgb(20 20 22 / 97%); color: #fff; backdrop-filter: blur(25px); }
|
||||
.darkchat-gif-panel > header { height: 37px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.darkchat-gif-panel > header strong { font-size: 13px; }
|
||||
.darkchat-gif-panel > header button { border: 0; background: transparent; color: #a78bfa; font-size: 11px; }
|
||||
.darkchat-gif-panel > label { height: 31px; margin: 0 9px 6px; padding: 0 9px; display: flex; align-items: center; gap: 6px; border-radius: 16px; background: #2c2c2e; color: var(--dc-muted); }
|
||||
.darkchat-gif-panel > label input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 10px; }
|
||||
.darkchat-gif-panel > div { height: 175px; padding: 0 9px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px; overflow-y: auto; }
|
||||
.darkchat-gif-panel > div button { min-height: 80px; padding: 0; overflow: hidden; border: 0; border-radius: 9px; background: #2c2c2e; }
|
||||
.darkchat-gif-panel > div img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.darkchat-load-more { width: calc(100% - 18px); height: 28px; margin: 4px 9px; border: 0; border-radius: 9px; background: #2c2c2e; color: #a78bfa !important; font-size: 10px; }
|
||||
.darkchat-page .messages-full-emoji-picker { animation: darkchat-panel-in .28s cubic-bezier(.32,.72,0,1) both; }
|
||||
.darkchat-page .messages-full-emoji-picker > header button,
|
||||
.darkchat-page .messages-full-emoji-picker__search,
|
||||
.darkchat-page .messages-full-emoji-picker nav button.active { background: #2c2c2e; color: #fff; }
|
||||
.darkchat-page .messages-full-emoji-picker__search input { color: #fff; }
|
||||
.darkchat-page .messages-full-emoji-picker nav { border-color: var(--dc-border); }
|
||||
.darkchat-recorder { position: absolute; z-index: 9; right: 12px; bottom: 28px; left: 12px; height: 43px; padding: 0 7px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--dc-border); border-radius: 22px; background: var(--dc-surface); }
|
||||
.darkchat-recorder > button { width: 29px; height: 29px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #2c2c2e; }
|
||||
.darkchat-recorder > button:last-child { color: #a78bfa; }
|
||||
.darkchat-recorder > i { width: 7px; height: 7px; border-radius: 50%; background: var(--dc-red); animation: messages-record-pulse 1s ease infinite; }
|
||||
.darkchat-recorder time { font-size: 10px; }
|
||||
.darkchat-recorder > span { height: 26px; min-width: 0; flex: 1; display: flex; align-items: center; justify-content: center; gap: 1px; overflow: hidden; }
|
||||
.darkchat-recorder > span b { width: 2px; flex: none; border-radius: 2px; background: #a78bfa; }
|
||||
.darkchat-voice { min-width: 177px; display: flex; align-items: center; gap: 7px; }
|
||||
.darkchat-voice > button { width: 26px; height: 26px; display: grid; place-items: center; padding: 0; flex: none; border: 0; border-radius: 50%; background: rgb(255 255 255 / 17%); }
|
||||
.darkchat-voice > div { min-width: 0; flex: 1; }
|
||||
.darkchat-voice__wave { height: 23px; display: flex; align-items: center; gap: 1px; }
|
||||
.darkchat-voice__wave i { width: 2px; border-radius: 2px; background: rgb(255 255 255 / 38%); }
|
||||
.darkchat-voice__wave i.active { background: #fff; }
|
||||
.darkchat-voice > div small { display: block; font-size: 7px; }
|
||||
.darkchat-voice .darkchat-voice__speed { width: 27px; color: #fff; font-size: 7px; font-weight: 800; }
|
||||
.darkchat-profile-hero { padding: 15px 0 17px; display: flex; align-items: center; flex-direction: column; }
|
||||
.darkchat-profile-hero .darkchat-avatar { width: 67px; height: 67px; font-size: 30px; box-shadow: 0 13px 32px rgb(91 33 182 / 32%); }
|
||||
.darkchat-profile-hero h2 { margin: 10px 0 3px; font-size: 20px; }
|
||||
.darkchat-profile-hero button { padding: 4px 8px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: 11px; background: var(--dc-surface); color: #c4b5fd; font-size: 9px; }
|
||||
.darkchat-profile-hero small { margin-top: 6px; color: var(--dc-muted); font-size: 8px; }
|
||||
.darkchat-settings-group,
|
||||
.darkchat-danger-group { margin: 10px 0; overflow: hidden; border: 1px solid var(--dc-border); border-radius: 13px; background: var(--dc-surface); }
|
||||
.darkchat-settings-group label,
|
||||
.darkchat-danger-group button { width: 100%; min-height: 43px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 11px; }
|
||||
.darkchat-settings-group label:last-child,
|
||||
.darkchat-danger-group button:last-child { border-bottom: 0; }
|
||||
.darkchat-settings-group label > span,
|
||||
.darkchat-danger-group button { justify-content: flex-start; }
|
||||
.darkchat-settings-group label > span { display: flex; align-items: center; gap: 8px; }
|
||||
.darkchat-settings-group label > span svg { color: #a78bfa; }
|
||||
.darkchat-settings-group input[type="checkbox"] { width: 36px; height: 21px; accent-color: var(--dc-purple); }
|
||||
.darkchat-select select { min-width: 90px; border: 0; outline: 0; background: transparent; color: #a78bfa; font-size: 9px; text-align: right; }
|
||||
.darkchat-danger-group button { color: var(--dc-red); text-align: left; }
|
||||
.darkchat-save { padding: 7px; border: 0; background: transparent; color: #a78bfa !important; font-size: 11px; }
|
||||
.darkchat-privacy-note { margin: 14px 4px 4px; display: flex; align-items: flex-start; gap: 7px; color: var(--dc-muted); font-size: 9px; line-height: 1.4; }
|
||||
.darkchat-privacy-note svg { flex: none; color: #a78bfa; }
|
||||
.darkchat-modal-backdrop { position: absolute; z-index: 40; inset: 0; padding: 44px 18px 27px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 67%); backdrop-filter: blur(10px); }
|
||||
.darkchat-modal,
|
||||
.darkchat-message-menu { width: 100%; padding: 17px; display: flex; align-items: stretch; flex-direction: column; border: 1px solid var(--dc-border); border-radius: 21px; background: rgb(28 28 30 / 98%); box-shadow: 0 24px 70px rgb(0 0 0 / 60%); animation: darkchat-modal-in .3s cubic-bezier(.32,.72,0,1) both; }
|
||||
.darkchat-modal { text-align: center; }
|
||||
.darkchat-modal__icon { width: 52px; height: 52px; margin: 0 auto; display: grid; place-items: center; border-radius: 17px; background: rgb(139 92 246 / 16%); color: #a78bfa; }
|
||||
.darkchat-modal__icon--danger { background: rgb(255 69 58 / 14%); color: var(--dc-red); }
|
||||
.darkchat-modal h2 { margin: 11px 0 6px; font-size: 18px; }
|
||||
.darkchat-modal p { margin: 0 0 10px; color: var(--dc-muted); font-size: 10px; line-height: 1.4; }
|
||||
.darkchat-modal > strong { margin-bottom: 12px; color: #c4b5fd; font-size: 12px; }
|
||||
.darkchat-modal > button:not(.darkchat-primary):not(.darkchat-danger) { min-height: 35px; border: 0; background: transparent; color: var(--dc-muted); font-size: 11px; }
|
||||
.darkchat-modal select,
|
||||
.darkchat-modal textarea { margin-bottom: 8px; padding: 9px; border: 1px solid var(--dc-border); border-radius: 10px; outline: 0; background: #2c2c2e; font-size: 11px; }
|
||||
.darkchat-modal textarea { min-height: 65px; resize: none; }
|
||||
.darkchat-message-menu { padding: 7px; }
|
||||
.darkchat-message-menu > button { min-height: 39px; padding: 0 11px; display: flex; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 11px; text-align: left; }
|
||||
.darkchat-message-menu > button:last-child { justify-content: center; border-bottom: 0; color: var(--dc-muted); }
|
||||
.darkchat-message-menu > button.danger { color: var(--dc-red); }
|
||||
.darkchat-reaction-row { margin: -30px 4px 8px; padding: 8px 5px; display: flex; justify-content: space-around; border: 1px solid var(--dc-border); border-radius: 20px; background: #2c2c2e; box-shadow: 0 7px 20px rgb(0 0 0 / 35%); }
|
||||
.darkchat-reaction-row button { width: 34px; height: 31px; padding: 0; border: 0; border-radius: 13px; background: transparent; font-size: 18px; }
|
||||
.darkchat-toast { position: absolute; z-index: 60; bottom: 95px; left: 50%; max-width: 80%; padding: 8px 13px; transform: translateX(-50%); border: 1px solid var(--dc-border); border-radius: 14px; background: rgb(44 44 46 / 96%); box-shadow: 0 8px 30px rgb(0 0 0 / 45%); font-size: 10px; text-align: center; }
|
||||
.darkchat-toast-enter-active,
|
||||
.darkchat-toast-leave-active { transition: opacity .2s ease, transform .2s ease; }
|
||||
.darkchat-toast-enter-from,
|
||||
.darkchat-toast-leave-to { opacity: 0; transform: translate(-50%, 8px); }
|
||||
@keyframes darkchat-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes darkchat-message-in { from { opacity: 0; transform: translateY(8px) scale(.94); } }
|
||||
@keyframes darkchat-bubble-in { from { opacity: 0; transform: translate(-8px, 6px) scale(.8); } }
|
||||
@keyframes darkchat-panel-in { from { transform: translateY(100%); } }
|
||||
@keyframes darkchat-modal-in { from { opacity: 0; transform: translateY(18px) scale(.92); } }
|
||||
.springboard-track {
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
@@ -23,11 +24,13 @@ const props = withDefaults(
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const unreadCount = computed(() => {
|
||||
if (props.app.id === 'mail') return mail.counts.unread
|
||||
if (props.app.id === 'citymarkt') return marketplace.counts.unread
|
||||
if (props.app.id === 'darkchat') return darkchat.unreadCount
|
||||
return 0
|
||||
})
|
||||
const notificationBadgeColors = {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { Pause, Play } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import type { DarkChatMessage } from '@/types/darkchat'
|
||||
|
||||
const props = defineProps<{ message: DarkChatMessage }>()
|
||||
const darkchat = useDarkChatStore()
|
||||
const audio = ref<HTMLAudioElement>()
|
||||
const currentTime = ref(0)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const speed = ref<1 | 1.5 | 2>(1)
|
||||
const duration = computed(() => (props.message.mediaDurationMs ?? 0) / 1000)
|
||||
const progress = computed(() =>
|
||||
duration.value > 0 ? Math.min(1, currentTime.value / duration.value) : 0,
|
||||
)
|
||||
const source = computed(() => darkchat.mediaSources[props.message.id] ?? '')
|
||||
const displayTime = computed(() => {
|
||||
const seconds = Math.max(0, Math.floor(playing.value || currentTime.value ? currentTime.value : duration.value))
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (loading.value) return
|
||||
failed.value = false
|
||||
if (playing.value) {
|
||||
audio.value?.pause()
|
||||
return
|
||||
}
|
||||
if (!source.value) {
|
||||
loading.value = true
|
||||
const loaded = await darkchat.loadMedia(props.message.id)
|
||||
loading.value = false
|
||||
if (!loaded) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
if (audio.value) audio.value.playbackRate = speed.value
|
||||
try {
|
||||
await audio.value?.play()
|
||||
}
|
||||
catch (error) {
|
||||
failed.value = true
|
||||
console.error(`[DarkChat] Could not play voice message ${props.message.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function cycleSpeed(): void {
|
||||
speed.value = speed.value === 1 ? 1.5 : speed.value === 1.5 ? 2 : 1
|
||||
if (audio.value) audio.value.playbackRate = speed.value
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
playing.value = false
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => audio.value?.pause())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="darkchat-voice" :class="{ 'darkchat-voice--failed': failed }">
|
||||
<button type="button" :disabled="loading" @click="togglePlayback">
|
||||
<span v-if="loading" class="darkchat-loader darkchat-loader--small" />
|
||||
<Pause v-else-if="playing" :size="15" fill="currentColor" />
|
||||
<Play v-else :size="15" fill="currentColor" />
|
||||
</button>
|
||||
<div>
|
||||
<span class="darkchat-voice__wave" aria-hidden="true">
|
||||
<i
|
||||
v-for="(sample, index) in message.mediaWaveform ?? []"
|
||||
:key="index"
|
||||
:class="{ active: index / Math.max(1, (message.mediaWaveform?.length ?? 1) - 1) <= progress }"
|
||||
:style="{ height: `${Math.max(3, sample * 21)}px` }"
|
||||
/>
|
||||
</span>
|
||||
<small>{{ displayTime }}</small>
|
||||
</div>
|
||||
<button type="button" class="darkchat-voice__speed" @click="cycleSpeed">{{ speed }}×</button>
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="source"
|
||||
preload="metadata"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@timeupdate="currentTime = audio?.currentTime ?? 0"
|
||||
@ended="finish"
|
||||
@error="failed = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Mail,
|
||||
MapPinned,
|
||||
MessageCircle,
|
||||
ShieldCheck,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Settings,
|
||||
@@ -32,6 +33,7 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg'
|
||||
import mailIcon from '@/assets/img/app-icons/mail.webp'
|
||||
import mapIcon from '@/assets/img/app-icons/map.webp'
|
||||
import messagesIcon from '@/assets/img/app-icons/sms.webp'
|
||||
import darkChatIcon from '@/assets/img/app-icons/darkchat.svg'
|
||||
import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
import photosIcon from '@/assets/img/app-icons/gallery.webp'
|
||||
import phoneIcon from '@/assets/img/app-icons/phone.webp'
|
||||
@@ -105,6 +107,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
labelKey: 'Apps.messages.name',
|
||||
route: '/apps/messages',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/DarkChatApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 22,
|
||||
icon: markRaw(ShieldCheck),
|
||||
iconClass: 'app-icon--darkchat',
|
||||
iconImage: darkChatIcon,
|
||||
id: 'darkchat',
|
||||
labelKey: 'Apps.darkchat.name',
|
||||
route: '/apps/darkchat',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MapApp.vue')),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import type { DarkChatMessage, DarkChatThread } from '@/types/darkchat'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
const conversation: DarkChatThread['conversation'] = {
|
||||
blockedByPeer: false,
|
||||
createdAt: '2026-08-06 20:00:00',
|
||||
disappearingSeconds: 3600,
|
||||
id: 'conversation-id',
|
||||
notificationsEnabled: true,
|
||||
peer: { alias: 'Nova', avatarSeed: 42, darkId: 'dark:N0VA-41KQ', id: 2 },
|
||||
readReceipts: true,
|
||||
}
|
||||
|
||||
function message(id: string): DarkChatMessage {
|
||||
return {
|
||||
body: 'Quiet channel',
|
||||
conversationId: conversation.id,
|
||||
createdAt: '2026-08-06 20:01:00',
|
||||
direction: 'sent',
|
||||
id,
|
||||
messageType: 'text',
|
||||
reactions: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('darkchat store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('optimistically sends and confirms a private message', async () => {
|
||||
let resolveSend: ((value: { data: DarkChatMessage; success: true }) => void) | undefined
|
||||
const pending = new Promise<{ data: DarkChatMessage; success: true }>((resolve) => (resolveSend = resolve))
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true })
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
.mockImplementationOnce(() => pending)
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
|
||||
const store = useDarkChatStore()
|
||||
await store.openThread(conversation.id)
|
||||
const sending = store.send({ body: 'Quiet channel', messageType: 'text' })
|
||||
expect(store.messages[0]).toMatchObject({ deliveryStatus: 'sending', body: 'Quiet channel' })
|
||||
|
||||
resolveSend?.({ data: message('server-message'), success: true })
|
||||
await sending
|
||||
expect(store.messages[0]).toMatchObject({ deliveryStatus: 'delivered', id: 'server-message' })
|
||||
})
|
||||
|
||||
it('keeps failed messages visible for delivery feedback', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true })
|
||||
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
|
||||
.mockResolvedValueOnce({ error: 'blocked', success: false })
|
||||
|
||||
const store = useDarkChatStore()
|
||||
await store.openThread(conversation.id)
|
||||
await store.send({ body: 'Quiet channel', messageType: 'text' })
|
||||
expect(store.messages[0].deliveryStatus).toBe('failed')
|
||||
})
|
||||
|
||||
it('loads protected voice data once', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' },
|
||||
success: true,
|
||||
})
|
||||
const store = useDarkChatStore()
|
||||
expect(await store.loadMedia('voice-id')).toBe(true)
|
||||
expect(await store.loadMedia('voice-id')).toBe(true)
|
||||
expect(store.mediaSources['voice-id']).toBe('data:audio/webm;codecs=opus;base64,ZmFrZQ==')
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
DarkChatBootstrap,
|
||||
DarkChatContact,
|
||||
DarkChatConversation,
|
||||
DarkChatConversationSummary,
|
||||
DarkChatMessage,
|
||||
DarkChatOutgoing,
|
||||
DarkChatProfile,
|
||||
DarkChatThread,
|
||||
} from '@/types/darkchat'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useDarkChatStore = defineStore('darkchat', () => {
|
||||
const profile = ref<DarkChatProfile | null>(null)
|
||||
const contacts = ref<DarkChatContact[]>([])
|
||||
const conversations = ref<DarkChatConversationSummary[]>([])
|
||||
const activeConversation = ref<DarkChatConversation | null>(null)
|
||||
const messages = ref<DarkChatMessage[]>([])
|
||||
const mediaSources = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const unreadCount = computed(() =>
|
||||
conversations.value.reduce((total, conversation) => total + conversation.unread, 0),
|
||||
)
|
||||
|
||||
async function bootstrap(): Promise<boolean> {
|
||||
loading.value = true
|
||||
const response = await nuiCall<DarkChatBootstrap>('darkchat:bootstrap')
|
||||
loading.value = false
|
||||
lastError.value = response.error ?? null
|
||||
if (!response.success || !response.data) {
|
||||
profile.value = null
|
||||
contacts.value = []
|
||||
conversations.value = []
|
||||
return false
|
||||
}
|
||||
profile.value = response.data.profile
|
||||
contacts.value = response.data.contacts
|
||||
conversations.value = response.data.conversations
|
||||
return true
|
||||
}
|
||||
|
||||
async function openThread(conversationId: string): Promise<boolean> {
|
||||
loading.value = true
|
||||
const response = await nuiCall<DarkChatThread>('darkchat:thread', { conversationId })
|
||||
loading.value = false
|
||||
lastError.value = response.error ?? null
|
||||
if (!response.success || !response.data) return false
|
||||
activeConversation.value = response.data.conversation
|
||||
messages.value = response.data.messages.map((message) => ({
|
||||
...message,
|
||||
deliveryStatus: message.direction === 'sent' ? 'delivered' : undefined,
|
||||
}))
|
||||
await refreshInbox()
|
||||
return true
|
||||
}
|
||||
|
||||
async function refreshInbox(): Promise<boolean> {
|
||||
const response = await nuiCall<DarkChatBootstrap>('darkchat:bootstrap')
|
||||
if (!response.success || !response.data) return false
|
||||
profile.value = response.data.profile
|
||||
contacts.value = response.data.contacts
|
||||
conversations.value = response.data.conversations
|
||||
return true
|
||||
}
|
||||
|
||||
async function start(identifier: string): Promise<NuiResponse<{ conversationId: string }>> {
|
||||
const response = await nuiCall<{ conversationId: string }>('darkchat:start', { identifier })
|
||||
if (response.success) await refreshInbox()
|
||||
return response
|
||||
}
|
||||
|
||||
async function send(outgoing: DarkChatOutgoing): Promise<NuiResponse<DarkChatMessage>> {
|
||||
if (!activeConversation.value) return { success: false, error: 'invalid_conversation' }
|
||||
const clientId = `dark-pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const optimistic: DarkChatMessage = {
|
||||
body: outgoing.body ?? '',
|
||||
clientId,
|
||||
conversationId: activeConversation.value.id,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
deliveryStatus: 'sending',
|
||||
direction: 'sent',
|
||||
id: clientId,
|
||||
mediaDurationMs: outgoing.mediaDurationMs,
|
||||
mediaMime: outgoing.mediaMime,
|
||||
mediaWaveform: outgoing.mediaWaveform,
|
||||
messageType: outgoing.messageType,
|
||||
reactions: {},
|
||||
replyToId: outgoing.replyToId,
|
||||
}
|
||||
messages.value.push(optimistic)
|
||||
if (outgoing.messageType === 'voice' && outgoing.mediaPayload) {
|
||||
mediaSources.value[clientId] = `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}`
|
||||
}
|
||||
const response = await nuiCall<DarkChatMessage>('darkchat:send', {
|
||||
...outgoing,
|
||||
conversationId: activeConversation.value.id,
|
||||
})
|
||||
const index = messages.value.findIndex((message) => message.clientId === clientId)
|
||||
if (!response.success || !response.data) {
|
||||
if (index >= 0) messages.value[index].deliveryStatus = 'failed'
|
||||
return response
|
||||
}
|
||||
const source = mediaSources.value[clientId]
|
||||
delete mediaSources.value[clientId]
|
||||
if (source) mediaSources.value[response.data.id] = source
|
||||
if (index >= 0) {
|
||||
messages.value[index] = { ...response.data, clientId, deliveryStatus: 'delivered' }
|
||||
}
|
||||
await refreshInbox()
|
||||
return response
|
||||
}
|
||||
|
||||
async function loadMedia(messageId: string): Promise<boolean> {
|
||||
if (mediaSources.value[messageId]) return true
|
||||
const response = await nuiCall<{ mime: string; payload: string }>('darkchat:media', { messageId })
|
||||
if (!response.success || !response.data) return false
|
||||
mediaSources.value[messageId] = `data:${response.data.mime};base64,${response.data.payload}`
|
||||
return true
|
||||
}
|
||||
|
||||
async function mutate(endpoint: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
const response = await nuiCall(`darkchat:${endpoint}`, data)
|
||||
return response.success
|
||||
}
|
||||
|
||||
function closeThread(): void {
|
||||
activeConversation.value = null
|
||||
messages.value = []
|
||||
mediaSources.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
activeConversation,
|
||||
bootstrap,
|
||||
closeThread,
|
||||
contacts,
|
||||
conversations,
|
||||
lastError,
|
||||
loadMedia,
|
||||
loading,
|
||||
mediaSources,
|
||||
messages,
|
||||
mutate,
|
||||
openThread,
|
||||
profile,
|
||||
refreshInbox,
|
||||
send,
|
||||
start,
|
||||
unreadCount,
|
||||
}
|
||||
})
|
||||
@@ -29,6 +29,27 @@ const namespaceQueues = new Map<string, Promise<void>>()
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
darkchat: {
|
||||
name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', privateNotification: 'New DarkChat message',
|
||||
signInBody: 'DarkChat identities are linked to your private iFruit account.', signInHint: 'Sign in through Settings to continue.',
|
||||
security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', noResultsBody: 'Try another alias or Dark-ID.',
|
||||
noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat',
|
||||
connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity',
|
||||
message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation',
|
||||
emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', searchGifs: 'Search GIFs', loadMore: 'Load More', photosLater: 'Photos · coming later', videosLater: 'Videos · coming later',
|
||||
voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to',
|
||||
messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}',
|
||||
timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days',
|
||||
copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report',
|
||||
contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared',
|
||||
myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.',
|
||||
unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat',
|
||||
reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted',
|
||||
microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.',
|
||||
errors: {
|
||||
not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.',
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
name: 'Messages',
|
||||
newMessage: 'New message from {sender}',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
|
||||
export type PhoneAppId =
|
||||
| 'phone'
|
||||
| 'messages'
|
||||
| 'darkchat'
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { DatabaseDateValue } from '@/utils/date'
|
||||
|
||||
export type DarkChatMessageType = 'text' | 'emoji' | 'gif' | 'voice' | 'system'
|
||||
export type DarkChatNotificationMode = 'full' | 'private' | 'hidden'
|
||||
|
||||
export type DarkChatIdentity = {
|
||||
id: number
|
||||
darkId: string
|
||||
alias: string
|
||||
originalAlias?: string
|
||||
avatarSeed: number
|
||||
activityVisible?: boolean
|
||||
isContact?: boolean
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
export type DarkChatProfile = DarkChatIdentity & {
|
||||
inviteCode: string
|
||||
notificationMode: DarkChatNotificationMode
|
||||
activityVisible: boolean
|
||||
createdAt: DatabaseDateValue
|
||||
}
|
||||
|
||||
export type DarkChatContact = DarkChatIdentity & {
|
||||
createdAt: DatabaseDateValue
|
||||
}
|
||||
|
||||
export type DarkChatConversationSummary = {
|
||||
id: string
|
||||
peer: DarkChatIdentity
|
||||
disappearingSeconds: number
|
||||
blocked: boolean
|
||||
lastMessage: string
|
||||
lastMessageType: DarkChatMessageType
|
||||
lastMessageAt: DatabaseDateValue
|
||||
unread: number
|
||||
}
|
||||
|
||||
export type DarkChatConversation = {
|
||||
id: string
|
||||
peer: DarkChatIdentity
|
||||
disappearingSeconds: number
|
||||
notificationsEnabled: boolean
|
||||
readReceipts: boolean
|
||||
blockedByPeer: boolean
|
||||
createdAt: DatabaseDateValue
|
||||
}
|
||||
|
||||
export type DarkChatMessage = {
|
||||
id: string
|
||||
clientId?: string
|
||||
conversationId: string
|
||||
direction: 'sent' | 'received'
|
||||
senderProfileId?: number
|
||||
messageType: DarkChatMessageType
|
||||
body: string
|
||||
mediaMime?: string | null
|
||||
mediaPayload?: string | null
|
||||
mediaDurationMs?: number | null
|
||||
mediaWaveform?: number[] | null
|
||||
replyToId?: string | null
|
||||
replyBody?: string | null
|
||||
reactions: Record<string, string>
|
||||
expiresAt?: DatabaseDateValue | null
|
||||
createdAt: DatabaseDateValue
|
||||
readAt?: DatabaseDateValue | null
|
||||
deletedForEveryone?: boolean
|
||||
deliveryStatus?: 'sending' | 'delivered' | 'failed'
|
||||
}
|
||||
|
||||
export type DarkChatBootstrap = {
|
||||
profile: DarkChatProfile
|
||||
contacts: DarkChatContact[]
|
||||
conversations: DarkChatConversationSummary[]
|
||||
}
|
||||
|
||||
export type DarkChatThread = {
|
||||
conversation: DarkChatConversation
|
||||
messages: DarkChatMessage[]
|
||||
}
|
||||
|
||||
export type DarkChatOutgoing = {
|
||||
body?: string
|
||||
messageType: 'text' | 'emoji' | 'gif' | 'voice'
|
||||
mediaPayload?: string
|
||||
mediaMime?: string
|
||||
mediaDurationMs?: number
|
||||
mediaWaveform?: number[]
|
||||
replyToId?: string
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
> = {
|
||||
phone: { enabled: true, sounds: true },
|
||||
messages: { enabled: true, sounds: true },
|
||||
darkchat: { enabled: true, sounds: true },
|
||||
'app-store': { enabled: true, sounds: true },
|
||||
calculator: { enabled: true, sounds: true },
|
||||
snake: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
ImagePlay,
|
||||
LockKeyhole,
|
||||
MessageCirclePlus,
|
||||
Mic,
|
||||
Plus,
|
||||
QrCode,
|
||||
Reply,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { DarkChatConversationSummary, DarkChatMessage, DarkChatNotificationMode } from '@/types/darkchat'
|
||||
import type { GifSearchResult } from '@/types/messages'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
|
||||
const VOICE_MAX_DURATION_MS = 60_000
|
||||
const VOICE_MAX_BYTES = 270_000
|
||||
const WAVEFORM_SAMPLES = 48
|
||||
|
||||
const account = useAccountStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const sms = useMessagesStore()
|
||||
const phone = usePhoneStore()
|
||||
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 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 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 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 `linear-gradient(145deg,hsl(${hue} 72% 62%),hsl(${(hue + 54) % 360} 70% 35%))`
|
||||
}
|
||||
|
||||
function avatarGlyph(seed: number): string {
|
||||
const glyphs = ['◉', '◇', '△', '⬡', '✦', '◎', '◈', '⬢']
|
||||
return glyphs[Math.abs(seed || 0) % glyphs.length]
|
||||
}
|
||||
|
||||
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 === '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', 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')
|
||||
}
|
||||
|
||||
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 = document.querySelector<HTMLElement>('.darkchat-thread__messages')
|
||||
area?.scrollTo({ behavior: animate ? 'smooth' : 'auto', 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()
|
||||
await scrollBottom(false)
|
||||
}
|
||||
|
||||
function back(): void {
|
||||
if (screen.value === 'contact') {
|
||||
screen.value = 'thread'
|
||||
return
|
||||
}
|
||||
if (screen.value === 'thread') darkchat.closeThread()
|
||||
screen.value = 'inbox'
|
||||
resetPanels()
|
||||
}
|
||||
|
||||
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 requestStart(value = identifier.value): void {
|
||||
const clean = value.trim()
|
||||
if (!clean) 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 || sending.value) return
|
||||
draft.value = ''
|
||||
const outgoingReply = replyTo.value?.id
|
||||
replyTo.value = null
|
||||
resetPanels()
|
||||
sending.value = true
|
||||
const response = await darkchat.send({ body, messageType: 'text', replyToId: outgoingReply })
|
||||
sending.value = false
|
||||
if (!response.success) 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()
|
||||
}
|
||||
|
||||
async function copyMessage(message: DarkChatMessage): Promise<void> {
|
||||
await navigator.clipboard.writeText(message.body)
|
||||
selectedMessage.value = null
|
||||
showToast(t('copied'))
|
||||
}
|
||||
|
||||
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 copyIdentity(value: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(value)
|
||||
showToast(t('copied'))
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (signedIn.value) void darkchat.bootstrap()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
discardRecording = true
|
||||
cleanupRecording()
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
if (gifTimer) clearTimeout(gifTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="darkchat-page">
|
||||
<section v-if="!signedIn" class="darkchat-gate">
|
||||
<span><LockKeyhole :size="31" /></span>
|
||||
<h1>{{ t('name') }}</h1>
|
||||
<p>{{ t('signInBody') }}</p>
|
||||
<small>{{ t('signInHint') }}</small>
|
||||
</section>
|
||||
|
||||
<section v-else-if="darkchat.loading && !darkchat.profile" class="darkchat-gate">
|
||||
<span class="darkchat-loader" />
|
||||
<p>{{ phone.t('Common.loading') }}</p>
|
||||
</section>
|
||||
|
||||
<template v-else-if="darkchat.profile">
|
||||
<section v-if="screen === 'inbox'" class="darkchat-inbox">
|
||||
<header class="darkchat-inbox__header">
|
||||
<button type="button" class="darkchat-pill" @click="openProfile">{{ phone.t('Common.edit') }}</button>
|
||||
<strong>{{ t('name') }}</strong>
|
||||
<button type="button" class="darkchat-round" :aria-label="t('security')" @click="openProfile">
|
||||
<ShieldCheck :size="19" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="darkchat-security-strip">
|
||||
<LockKeyhole :size="12" />
|
||||
<span>{{ t('privateNetwork') }}</span>
|
||||
<button type="button" @click="copyIdentity(darkchat.profile.darkId)">{{ darkchat.profile.darkId }}</button>
|
||||
</div>
|
||||
<div v-if="filteredConversations.length" class="darkchat-conversations">
|
||||
<button
|
||||
v-for="conversation in filteredConversations"
|
||||
:key="conversation.id"
|
||||
type="button"
|
||||
class="darkchat-conversation"
|
||||
@click="openConversation(conversation.id)"
|
||||
>
|
||||
<i v-if="conversation.unread" class="darkchat-unread" />
|
||||
<span class="darkchat-avatar" :style="{ background: avatarGradient(conversation.peer.avatarSeed) }">
|
||||
{{ avatarGlyph(conversation.peer.avatarSeed) }}
|
||||
</span>
|
||||
<span class="darkchat-conversation__body">
|
||||
<span><strong>{{ conversation.peer.alias }}</strong><time>{{ formatDate(conversation.lastMessageAt) }}</time><ChevronRight :size="14" /></span>
|
||||
<small>{{ preview(conversation) }}</small>
|
||||
</span>
|
||||
<Clock3 v-if="conversation.disappearingSeconds !== 0" class="darkchat-timer-icon" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="darkchat-empty">
|
||||
<span><ShieldCheck :size="28" /></span>
|
||||
<h2>{{ search ? t('noResults') : t('noChats') }}</h2>
|
||||
<p>{{ search ? t('noResultsBody') : t('noChatsBody') }}</p>
|
||||
</div>
|
||||
<footer class="darkchat-inbox__toolbar">
|
||||
<label><Search :size="18" /><input v-model="search" type="search" :placeholder="phone.t('Common.search')" /></label>
|
||||
<button type="button" class="darkchat-round darkchat-round--large" :aria-label="t('newChat')" @click="screen = 'new'">
|
||||
<MessageCirclePlus :size="20" />
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'new'" class="darkchat-sheet-page">
|
||||
<header><button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button><strong>{{ t('newChat') }}</strong><i /></header>
|
||||
<div class="darkchat-new-hero">
|
||||
<span><QrCode :size="28" /></span>
|
||||
<h2>{{ t('connectPrivately') }}</h2>
|
||||
<p>{{ t('newChatBody') }}</p>
|
||||
</div>
|
||||
<label class="darkchat-input"><span>{{ t('darkIdOrInvite') }}</span><input v-model="identifier" autocomplete="off" placeholder="dark:7X4K-P92D" @keydown.enter.prevent="requestStart()" /></label>
|
||||
<button type="button" class="darkchat-primary" :disabled="!identifier.trim()" @click="requestStart()">{{ t('continue') }}</button>
|
||||
<h3>{{ t('contacts') }}</h3>
|
||||
<button v-for="contact in darkchat.contacts" :key="contact.id" type="button" class="darkchat-contact-row" @click="requestStart(contact.darkId)">
|
||||
<span class="darkchat-avatar darkchat-avatar--small" :style="{ background: avatarGradient(contact.avatarSeed) }">{{ avatarGlyph(contact.avatarSeed) }}</span>
|
||||
<span><strong>{{ contact.alias }}</strong><small>{{ contact.darkId }}</small></span><ChevronRight :size="15" />
|
||||
</button>
|
||||
<div class="darkchat-qr-card">
|
||||
<div class="darkchat-faux-qr"><QrCode :size="62" /></div>
|
||||
<span><strong>{{ darkchat.profile.darkId }}</strong><small>{{ t('shareIdentity') }}</small></span>
|
||||
<button type="button" @click="copyIdentity(darkchat.profile.inviteCode)"><Copy :size="16" /> {{ darkchat.profile.inviteCode }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'thread' && active" class="darkchat-thread" :class="{ 'darkchat-thread--panel': attachmentPanelOpen }">
|
||||
<header class="darkchat-thread__header">
|
||||
<button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button>
|
||||
<button type="button" class="darkchat-thread__identity" @click="openContact">
|
||||
<span class="darkchat-avatar darkchat-avatar--header" :style="{ background: avatarGradient(active.peer.avatarSeed) }">{{ avatarGlyph(active.peer.avatarSeed) }}</span>
|
||||
<span><strong>{{ active.peer.alias }}</strong><small>{{ active.peer.activityVisible ? t('activeNow') : t('encryptedSession') }}</small></span>
|
||||
<ChevronRight :size="13" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="darkchat-thread__meta"><LockKeyhole :size="11" /> {{ t('serverPrivate') }}<span v-if="active.disappearingSeconds !== 0"> · {{ timerLabel(active.disappearingSeconds) }}</span></div>
|
||||
<div class="darkchat-thread__messages">
|
||||
<div class="darkchat-day">{{ dayLabel(active.createdAt) }}</div>
|
||||
<template v-for="message in darkchat.messages" :key="message.clientId ?? message.id">
|
||||
<div v-if="message.messageType === 'system'" class="darkchat-system"><ShieldCheck :size="12" />{{ systemText(message.body) }}</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="darkchat-message"
|
||||
:class="[`darkchat-message--${message.direction}`, { 'darkchat-message--failed': message.deliveryStatus === 'failed' }]"
|
||||
@click="selectedMessage = message"
|
||||
>
|
||||
<span v-if="message.replyBody" class="darkchat-reply-preview"><Reply :size="10" />{{ message.replyBody }}</span>
|
||||
<img v-if="message.messageType === 'gif'" :src="message.mediaPayload || undefined" alt="GIF" />
|
||||
<DarkChatVoiceMessage v-else-if="message.messageType === 'voice'" :message="message" />
|
||||
<span v-else>{{ message.body }}</span>
|
||||
<span v-if="Object.keys(message.reactions).length" class="darkchat-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="darkchat-replying"><Reply :size="14" /><span><small>{{ t('replying') }}</small><strong>{{ replyTo.body || t(replyTo.messageType) }}</strong></span><button type="button" @click="replyTo = null"><X :size="15" /></button></div>
|
||||
<div v-if="attachmentOpen" class="darkchat-actions-bubbles">
|
||||
<button type="button" @click="attachmentOpen = false; emojiOpen = true"><span>😀</span>{{ t('emoji') }}</button>
|
||||
<button type="button" @click="attachmentOpen = false; gifOpen = true; loadGifs(true)"><span><ImagePlay :size="17" /></span>{{ t('gif') }}</button>
|
||||
<button type="button" disabled><span>📷</span>{{ t('photosLater') }}</button>
|
||||
<button type="button" disabled><span>🎥</span>{{ t('videosLater') }}</button>
|
||||
</div>
|
||||
<div v-if="gifOpen" class="darkchat-gif-panel">
|
||||
<header><strong>{{ t('gifs') }}</strong><button type="button" @click="gifOpen = false">{{ phone.t('Common.done') }}</button></header>
|
||||
<label><Search :size="14" /><input v-model="gifQuery" type="search" :placeholder="t('searchGifs')" @input="queueGifSearch" /></label>
|
||||
<div><button v-for="gif in gifResults" :key="gif.id" type="button" @click="sendGif(gif)"><img :src="gif.previewUrl" :alt="gif.title" /></button></div>
|
||||
<button v-if="gifHasMore && !gifLoading" type="button" class="darkchat-load-more" @click="loadGifs()">{{ t('loadMore') }}</button>
|
||||
<span v-if="gifLoading" class="darkchat-loader darkchat-loader--small" />
|
||||
</div>
|
||||
<FullEmojiPicker v-if="emojiOpen" @close="emojiOpen = false" @pick="appendEmoji" />
|
||||
<div v-if="recording" class="darkchat-recorder">
|
||||
<button type="button" @click="cancelRecording"><X :size="18" /></button><i /><time>{{ recordingTime() }}</time>
|
||||
<span><b v-for="(level, index) in recordingLevels" :key="index" :style="{ height: `${Math.max(3, level * 23)}px` }" /></span>
|
||||
<button type="button" @click="stopRecording"><ArrowUpCircle :size="27" /></button>
|
||||
</div>
|
||||
<footer v-else class="darkchat-composer">
|
||||
<button type="button" class="darkchat-round" :class="{ active: attachmentOpen || attachmentPanelOpen }" @click="attachmentOpen = !attachmentOpen; emojiOpen = false; gifOpen = false"><Plus :size="22" /></button>
|
||||
<label><textarea v-model="draft" rows="1" :placeholder="t('message')" @keydown.enter.exact.prevent="sendText" /><button v-if="draft.trim()" type="button" @click="sendText"><ArrowUpCircle :size="25" /></button><button v-else type="button" @click="startRecording"><Mic :size="19" /></button></label>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'contact' && active" class="darkchat-sheet-page darkchat-contact-settings">
|
||||
<header><button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button><strong>{{ t('contactSecurity') }}</strong><i /></header>
|
||||
<div class="darkchat-profile-hero">
|
||||
<span class="darkchat-avatar" :style="{ background: avatarGradient(active.peer.avatarSeed) }">{{ avatarGlyph(active.peer.avatarSeed) }}</span>
|
||||
<h2>{{ active.peer.alias }}</h2><button type="button" @click="copyIdentity(active.peer.darkId)">{{ active.peer.darkId }} <Copy :size="12" /></button>
|
||||
<small>{{ t('chatSince', { date: dayLabel(active.createdAt) }) }}</small>
|
||||
</div>
|
||||
<div class="darkchat-settings-group">
|
||||
<label><span><Bell :size="17" />{{ t('notifications') }}</span><input v-model="active.notificationsEnabled" type="checkbox" @change="updateConversation" /></label>
|
||||
<label><span><Check :size="17" />{{ t('readReceipts') }}</span><input v-model="active.readReceipts" type="checkbox" @change="updateConversation" /></label>
|
||||
<label class="darkchat-select"><span><Clock3 :size="17" />{{ t('disappearing') }}</span><select v-model.number="active.disappearingSeconds" @change="updateConversation"><option v-for="timer in timerOptions" :key="timer" :value="timer">{{ timerLabel(timer) }}</option></select></label>
|
||||
</div>
|
||||
<label class="darkchat-input"><span>{{ t('contactAlias') }}</span><input v-model="contactAliasDraft" maxlength="32" /></label>
|
||||
<button type="button" class="darkchat-primary" @click="saveContact"><UserPlus :size="16" />{{ active.peer.isContact ? t('saveContact') : t('addContact') }}</button>
|
||||
<div class="darkchat-danger-group">
|
||||
<button v-if="active.peer.isContact" type="button" @click="removeContact"><UserMinus :size="17" />{{ t('removeContact') }}</button>
|
||||
<button type="button" @click="toggleBlock"><ShieldOff :size="17" />{{ active.peer.blocked ? t('unblock') : t('block') }}</button>
|
||||
<button type="button" @click="beginReport()"><BellOff :size="17" />{{ t('report') }}</button>
|
||||
<button type="button" @click="clearChat"><Trash2 :size="17" />{{ t('clearChat') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'profile'" class="darkchat-sheet-page darkchat-profile-settings">
|
||||
<header><button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button><strong>{{ t('myIdentity') }}</strong><button type="button" class="darkchat-save" @click="saveProfile">{{ phone.t('Common.done') }}</button></header>
|
||||
<div class="darkchat-profile-hero">
|
||||
<span class="darkchat-avatar" :style="{ background: avatarGradient(darkchat.profile.avatarSeed) }">{{ avatarGlyph(darkchat.profile.avatarSeed) }}</span>
|
||||
<h2>{{ darkchat.profile.alias }}</h2><button type="button" @click="copyIdentity(darkchat.profile.darkId)">{{ darkchat.profile.darkId }} <Copy :size="12" /></button>
|
||||
</div>
|
||||
<label class="darkchat-input"><span>{{ t('alias') }}</span><input v-model="aliasDraft" maxlength="32" /></label>
|
||||
<div class="darkchat-settings-group">
|
||||
<label class="darkchat-select"><span><Bell :size="17" />{{ t('notificationPrivacy') }}</span><select v-model="notificationMode"><option value="full">{{ t('notificationFull') }}</option><option value="private">{{ t('notificationPrivate') }}</option><option value="hidden">{{ t('notificationHidden') }}</option></select></label>
|
||||
<label><span><ShieldCheck :size="17" />{{ t('shareActivity') }}</span><input v-model="activityVisible" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="darkchat-qr-card">
|
||||
<div class="darkchat-faux-qr"><QrCode :size="62" /></div>
|
||||
<span><strong>{{ darkchat.profile.inviteCode }}</strong><small>{{ t('inviteCode') }}</small></span>
|
||||
<button type="button" @click="copyIdentity(darkchat.profile.inviteCode)"><Copy :size="16" />{{ t('copyInvite') }}</button>
|
||||
</div>
|
||||
<p class="darkchat-privacy-note"><LockKeyhole :size="15" />{{ t('privacyDisclaimer') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-if="safetyOpen" class="darkchat-modal-backdrop">
|
||||
<section class="darkchat-modal"><span class="darkchat-modal__icon"><ShieldCheck :size="25" /></span><h2>{{ t('unknownIdentity') }}</h2><p>{{ t('unknownIdentityBody') }}</p><strong>{{ pendingIdentifier }}</strong><button type="button" class="darkchat-primary" @click="confirmStart">{{ t('openSecureChat') }}</button><button type="button" @click="safetyOpen = false">{{ phone.t('Common.cancel') }}</button></section>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMessage" class="darkchat-modal-backdrop" @click.self="selectedMessage = null">
|
||||
<section class="darkchat-message-menu">
|
||||
<div class="darkchat-reaction-row"><button v-for="reaction in ['❤️','👍','👎','😂','‼️','❓']" :key="reaction" type="button" @click="react(selectedMessage!, reaction)">{{ reaction }}</button></div>
|
||||
<button type="button" @click="beginReply(selectedMessage)"><Reply :size="17" />{{ t('reply') }}</button>
|
||||
<button v-if="selectedMessage.messageType === 'text' || selectedMessage.messageType === 'emoji'" type="button" @click="copyMessage(selectedMessage)"><Copy :size="17" />{{ t('copy') }}</button>
|
||||
<button type="button" @click="messageAction(selectedMessage, 'delete_me')"><Trash2 :size="17" />{{ t('deleteForMe') }}</button>
|
||||
<button v-if="selectedMessage.direction === 'sent'" type="button" class="danger" @click="messageAction(selectedMessage, 'delete_all')"><Trash2 :size="17" />{{ t('deleteForBoth') }}</button>
|
||||
<button v-if="selectedMessage.direction === 'received'" type="button" class="danger" @click="beginReport(selectedMessage)"><ShieldOff :size="17" />{{ t('report') }}</button>
|
||||
<button type="button" @click="selectedMessage = null">{{ phone.t('Common.cancel') }}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="reportOpen" class="darkchat-modal-backdrop">
|
||||
<section class="darkchat-modal"><span class="darkchat-modal__icon darkchat-modal__icon--danger"><ShieldOff :size="25" /></span><h2>{{ t('reportUser') }}</h2><select v-model="reportReason"><option value="spam">{{ t('reportSpam') }}</option><option value="harassment">{{ t('reportHarassment') }}</option><option value="threats">{{ t('reportThreats') }}</option><option value="illegal">{{ t('reportIllegal') }}</option><option value="other">{{ t('reportOther') }}</option></select><textarea v-model="reportDetails" maxlength="500" :placeholder="t('reportDetails')" /><button type="button" class="darkchat-danger" @click="submitReport">{{ t('submitReport') }}</button><button type="button" @click="reportOpen = false; selectedMessage = null">{{ phone.t('Common.cancel') }}</button></section>
|
||||
</div>
|
||||
|
||||
<Transition name="darkchat-toast"><div v-if="toast" class="darkchat-toast">{{ toast }}</div></Transition>
|
||||
</main>
|
||||
</template>
|
||||
@@ -154,6 +154,69 @@ const smsMessages = [
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
]
|
||||
const darkChatProfile = {
|
||||
id: 1,
|
||||
darkId: 'dark:7X4K-P92D',
|
||||
inviteCode: 'DC-7X4K-NOVA',
|
||||
alias: 'Nightshade',
|
||||
avatarSeed: 267,
|
||||
notificationMode: 'private',
|
||||
activityVisible: false,
|
||||
createdAt: '2026-08-01 21:20:00',
|
||||
}
|
||||
const darkChatPeers = [
|
||||
{ id: 2, darkId: 'dark:N0VA-41KQ', alias: 'Nova', originalAlias: 'Nova', avatarSeed: 142, activityVisible: true, isContact: true, blocked: false },
|
||||
{ id: 3, darkId: 'dark:ECH0-77LM', alias: 'Echo', originalAlias: 'Echo', avatarSeed: 311, activityVisible: false, isContact: true, blocked: false },
|
||||
]
|
||||
const darkChatConversations = [
|
||||
{
|
||||
id: 'dc-conversation-nova-0000-000000000001',
|
||||
peer: darkChatPeers[0],
|
||||
disappearingSeconds: 3600,
|
||||
notificationsEnabled: true,
|
||||
readReceipts: true,
|
||||
blockedByPeer: false,
|
||||
createdAt: '2026-08-03 22:12:00',
|
||||
},
|
||||
]
|
||||
const darkChatMessages = [
|
||||
{
|
||||
id: 'dc-message-00000000-0000-000000000001', conversationId: darkChatConversations[0].id,
|
||||
direction: 'received', senderProfileId: 2, messageType: 'text', body: 'The east gate is clear. Are you close?',
|
||||
reactions: {}, createdAt: '2026-08-06 22:42:00', readAt: '2026-08-06 22:43:00',
|
||||
},
|
||||
{
|
||||
id: 'dc-message-00000000-0000-000000000002', conversationId: darkChatConversations[0].id,
|
||||
direction: 'sent', senderProfileId: 1, messageType: 'text', body: 'Two minutes. Keep this channel quiet. 🟣',
|
||||
reactions: { 2: '👍' }, createdAt: '2026-08-06 22:43:00', readAt: '2026-08-06 22:43:30',
|
||||
},
|
||||
{
|
||||
id: 'dc-message-00000000-0000-000000000003', conversationId: darkChatConversations[0].id,
|
||||
direction: 'received', senderProfileId: 2, messageType: 'gif', body: '',
|
||||
mediaPayload: 'https://media.giphy.com/media/ICOgUNjpvO0PC/giphy.gif', reactions: {}, createdAt: '2026-08-06 22:44:00', readAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
function darkChatBootstrap() {
|
||||
return {
|
||||
profile: darkChatProfile,
|
||||
contacts: darkChatPeers.filter((peer) => peer.isContact).map((peer) => ({ ...peer, createdAt: '2026-08-03 22:12:00' })),
|
||||
conversations: darkChatConversations.map((conversation) => {
|
||||
const thread = darkChatMessages.filter((message) => message.conversationId === conversation.id)
|
||||
const last = thread.at(-1)
|
||||
return {
|
||||
id: conversation.id,
|
||||
peer: conversation.peer,
|
||||
disappearingSeconds: conversation.disappearingSeconds,
|
||||
blocked: conversation.peer.blocked,
|
||||
lastMessage: last?.body ?? '',
|
||||
lastMessageType: last?.messageType ?? 'system',
|
||||
lastMessageAt: last?.createdAt ?? conversation.createdAt,
|
||||
unread: thread.filter((message) => message.direction === 'received' && !message.readAt).length,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
const marketplaceListings = [
|
||||
{
|
||||
id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf01',
|
||||
@@ -847,6 +910,159 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:bootstrap') {
|
||||
response.json({ success: true, data: darkChatBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:update-profile') {
|
||||
darkChatProfile.alias = String(request.body.alias ?? darkChatProfile.alias).trim()
|
||||
darkChatProfile.notificationMode = request.body.notificationMode ?? 'private'
|
||||
darkChatProfile.activityVisible = Boolean(request.body.activityVisible)
|
||||
response.json({ success: true, data: darkChatProfile })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:start') {
|
||||
const identifier = String(request.body.identifier ?? '').toUpperCase()
|
||||
const peer = darkChatPeers.find((item) => item.darkId.toUpperCase() === identifier)
|
||||
?? (identifier === 'DC-ECH0-77LM' ? darkChatPeers[1] : null)
|
||||
if (!peer) {
|
||||
response.json({ success: false, error: 'profile_not_found' })
|
||||
return
|
||||
}
|
||||
let conversation = darkChatConversations.find((item) => item.peer.id === peer.id)
|
||||
if (!conversation) {
|
||||
conversation = {
|
||||
id: `dc-conversation-${Date.now()}`,
|
||||
peer,
|
||||
disappearingSeconds: 0,
|
||||
notificationsEnabled: true,
|
||||
readReceipts: true,
|
||||
blockedByPeer: false,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
}
|
||||
darkChatConversations.push(conversation)
|
||||
}
|
||||
response.json({ success: true, data: { conversationId: conversation.id } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:thread') {
|
||||
const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
|
||||
if (!conversation) {
|
||||
response.json({ success: false, error: 'conversation_not_found' })
|
||||
return
|
||||
}
|
||||
const thread = darkChatMessages.filter((message) => message.conversationId === conversation.id)
|
||||
for (const message of thread) {
|
||||
if (message.direction === 'received') message.readAt = message.readAt ?? new Date().toISOString()
|
||||
}
|
||||
response.json({ success: true, data: { conversation, messages: thread.map(({ mediaSecret, ...message }) => message) } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:send') {
|
||||
const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
|
||||
if (!conversation || conversation.peer.blocked) {
|
||||
response.json({ success: false, error: conversation ? 'blocked' : 'conversation_not_found' })
|
||||
return
|
||||
}
|
||||
const messageType = request.body.messageType ?? 'text'
|
||||
const body = String(request.body.body ?? '')
|
||||
if ((messageType === 'text' || messageType === 'emoji') && !body.trim()) {
|
||||
response.json({ success: false, error: 'invalid_message' })
|
||||
return
|
||||
}
|
||||
const reply = darkChatMessages.find((message) => message.id === request.body.replyToId)
|
||||
const message = {
|
||||
id: `dc-message-${Date.now()}`,
|
||||
conversationId: conversation.id,
|
||||
direction: 'sent',
|
||||
senderProfileId: darkChatProfile.id,
|
||||
messageType,
|
||||
body,
|
||||
mediaPayload: messageType === 'gif' ? request.body.mediaPayload : undefined,
|
||||
mediaSecret: messageType === 'voice' ? request.body.mediaPayload : undefined,
|
||||
mediaMime: request.body.mediaMime,
|
||||
mediaDurationMs: request.body.mediaDurationMs,
|
||||
mediaWaveform: request.body.mediaWaveform,
|
||||
replyToId: request.body.replyToId,
|
||||
replyBody: reply?.body,
|
||||
reactions: {},
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
readAt: null,
|
||||
}
|
||||
darkChatMessages.push(message)
|
||||
const { mediaSecret, ...publicMessage } = message
|
||||
response.json({ success: true, data: publicMessage })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:media') {
|
||||
const message = darkChatMessages.find((item) => item.id === request.body.messageId && item.messageType === 'voice')
|
||||
response.json(message?.mediaSecret
|
||||
? { success: true, data: { mime: message.mediaMime, payload: message.mediaSecret } }
|
||||
: { success: false, error: 'message_not_found' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:react') {
|
||||
const message = darkChatMessages.find((item) => item.id === request.body.messageId)
|
||||
if (message) {
|
||||
const current = message.reactions[String(darkChatProfile.id)]
|
||||
if (current === request.body.reaction) delete message.reactions[String(darkChatProfile.id)]
|
||||
else message.reactions[String(darkChatProfile.id)] = request.body.reaction
|
||||
}
|
||||
response.json({ success: Boolean(message), error: message ? undefined : 'message_not_found' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:message-action') {
|
||||
const index = darkChatMessages.findIndex((item) => item.id === request.body.messageId)
|
||||
if (index >= 0 && request.body.action === 'delete_me') darkChatMessages.splice(index, 1)
|
||||
else if (index >= 0 && request.body.action === 'delete_all') {
|
||||
darkChatMessages[index].messageType = 'system'
|
||||
darkChatMessages[index].body = 'message_deleted'
|
||||
darkChatMessages[index].mediaPayload = undefined
|
||||
}
|
||||
response.json({ success: index >= 0 })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:update-conversation') {
|
||||
const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
|
||||
if (conversation) {
|
||||
conversation.disappearingSeconds = Number(request.body.disappearingSeconds)
|
||||
conversation.notificationsEnabled = Boolean(request.body.notificationsEnabled)
|
||||
conversation.readReceipts = Boolean(request.body.readReceipts)
|
||||
darkChatMessages.push({
|
||||
id: `dc-system-${Date.now()}`, conversationId: conversation.id, direction: 'received',
|
||||
messageType: 'system', body: `timer_changed:${conversation.disappearingSeconds}`,
|
||||
reactions: {}, createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
})
|
||||
}
|
||||
response.json({ success: Boolean(conversation) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:add-contact' || endpoint === 'darkchat:remove-contact') {
|
||||
const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
|
||||
if (conversation) {
|
||||
conversation.peer.isContact = endpoint === 'darkchat:add-contact'
|
||||
if (request.body.alias) conversation.peer.alias = String(request.body.alias)
|
||||
}
|
||||
response.json({ success: Boolean(conversation) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:block') {
|
||||
const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
|
||||
if (conversation) conversation.peer.blocked = Boolean(request.body.blocked)
|
||||
response.json({ success: Boolean(conversation) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:clear') {
|
||||
for (let index = darkChatMessages.length - 1; index >= 0; index -= 1) {
|
||||
if (darkChatMessages[index].conversationId === request.body.conversationId) darkChatMessages.splice(index, 1)
|
||||
}
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:report') {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'messages:conversations') {
|
||||
const grouped = new Map()
|
||||
for (const message of [...smsMessages].reverse()) {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-D8a3rSQd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-BInJdX1o.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-nwsHiC9b.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-BqvyRdgo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user