Merge branch 'dev' into feature/funk

# Conflicts:
#	frontend/src/config/apps.test.ts
#	frontend/src/config/apps.ts
#	frontend/src/stores/phone.ts
#	frontend/testserver/index.cjs
#	sky_phone/config/locales/en.lua
#	sky_phone/source/html/index.html
#	sky_phone/source/server/db_migrate.lua
This commit is contained in:
DerEchteAlec
2026-08-07 16:06:51 +02:00
50 changed files with 6108 additions and 164 deletions
+1
View File
@@ -1 +1,2 @@
/sky_phone/source/html
.pnpm-store/
+2
View File
@@ -1,4 +1,6 @@
node_modules/
dist/
.vite/
*.local
dev-server*.log
+1 -1
View File
@@ -5,7 +5,7 @@ import pluginVue from 'eslint-plugin-vue'
export default defineConfigWithVueTs(
{ files: ['**/*.{ts,mts,tsx,vue}'], name: 'app/files-to-lint' },
globalIgnores(['**/dist/**', 'testserver/**', 'build.cjs']),
globalIgnores(['**/.vite/**', '**/dist/**', 'testserver/**', 'build.cjs']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
+45
View File
@@ -24,9 +24,11 @@ import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
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'
@@ -54,6 +56,7 @@ type AppMessage = {
| MailEventData
| MarketplaceEventData
| MessagesEventData
| DarkChatEventData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -81,6 +84,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,8 +122,10 @@ const account = useAccountStore()
const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
const banking = useBankingStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
@@ -161,6 +176,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> {
@@ -315,8 +331,37 @@ 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 (event.data?.type === 'banking:changed') {
void banking.load()
} else if (
(event.data?.type === 'call:incoming' ||
event.data?.type === 'call:state') &&
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop offset="0.52" stop-color="#1857d9"/>
<stop offset="1" stop-color="#081b55"/>
</linearGradient>
<linearGradient id="shine" x1="26" y1="18" x2="99" y2="106" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".95"/>
<stop offset="1" stop-color="#cfe1ff" stop-opacity=".82"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
<circle cx="103" cy="22" r="35" fill="#6aa4ff" opacity=".2"/>
<path d="M26 52 64 29l38 23v8H26v-8Zm7 15h10v25H33V67Zm21 0h10v25H54V67Zm21 0h10v25H75V67Zm21 0h-1v25h-9V67h10ZM25 98h78v10H25V98Z" fill="url(#shine)"/>
</svg>

After

Width:  |  Height:  |  Size: 846 B

@@ -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

+923 -9
View File
@@ -582,6 +582,418 @@ 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-back {
width: 46px;
height: 46px;
border-color: #3a3a3c;
background: linear-gradient(145deg, rgb(44 44 46 / 96%), rgb(28 28 30 / 92%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 9%), 0 5px 18px rgb(0 0 0 / 42%);
color: #fff;
line-height: 0;
}
.darkchat-back svg { display: block; margin: 0; }
.darkchat-pill {
width: 54px;
min-width: 54px;
height: 37px;
padding: 0 9px;
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: 32px;
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: 11px;
}
.darkchat-security-strip span { flex: 1; }
.darkchat-security-strip button { padding: 0; border: 0; background: transparent; color: #b9a3ff; font-size: 10px; }
.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: 11px; }
.darkchat-conversation__body svg { color: #636366; }
.darkchat-conversation__body small { overflow: hidden; color: var(--dc-muted); font-size: 13px; line-height: 1.3; 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: 14px; }
.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: 50px 1fr 50px; }
.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: 15px; text-overflow: ellipsis; white-space: nowrap; }
.darkchat-thread__identity small { color: var(--dc-muted); font-size: 10px; }
.darkchat-thread__identity svg { color: var(--dc-muted); }
.darkchat-thread__meta { height: 25px; flex: none; display: flex; align-items: center; justify-content: center; gap: 4px; border-bottom: 1px solid #1b1b1d; color: var(--dc-muted); font-size: 10px; }
.darkchat-thread__messages { min-height: 0; padding: 14px 15px 20px; flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 7px; overflow-y: auto; }
.darkchat-day { align-self: center; margin: 5px 0 9px; color: var(--dc-muted); font-size: 11px; font-weight: 600; }
.darkchat-system { max-width: 86%; margin: 8px auto; display: flex; align-items: center; gap: 5px; color: var(--dc-muted); font-size: 10px; text-align: center; }
.darkchat-message {
position: relative;
max-width: 86%;
padding: 9px 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
border: 0;
border-radius: 18px;
background: var(--dc-surface-2);
font-size: 15px;
line-height: 1.32;
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: 9px; white-space: nowrap; }
.darkchat-message--received > small { color: var(--dc-muted); }
.darkchat-message--failed { outline: 1px solid var(--dc-red); }
.darkchat-message > img,
.darkchat-message > video { width: min(228px, 74vw); max-height: 240px; margin: -7px -10px 2px; border-radius: 14px; object-fit: cover; }
.darkchat-message > video { background: #000; }
.darkchat-reply-preview { padding: 5px 7px; display: flex; align-items: center; gap: 5px; overflow: hidden; border-left: 2px solid #c4b5fd; border-radius: 5px; background: rgb(0 0 0 / 18%); color: rgb(255 255 255 / 72%); font-size: 10px; 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: 9px; text-transform: uppercase; }
.darkchat-replying strong { overflow: hidden; color: #fff; font-size: 11px; 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: 47px; padding: 7px 7px 7px 14px; flex: 1; display: flex; align-items: center; gap: 6px; border: 1px solid var(--dc-border); border-radius: 24px; background: var(--dc-surface); }
.darkchat-composer textarea { min-height: 21px; max-height: 76px; min-width: 0; padding: 2px 0; flex: 1; resize: none; border: 0; outline: 0; background: transparent; font-size: 14px; line-height: 19px; }
.darkchat-composer label button { width: 32px; height: 32px; 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: 40px; padding: 4px 13px 4px 5px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--dc-border); border-radius: 20px; background: var(--dc-surface); font-size: 12px; 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: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: rgb(139 92 246 / 18%); color: #c4b5fd; font-size: 18px; }
.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: 197px; display: flex; align-items: center; gap: 8px; }
.darkchat-voice > button { width: 30px; height: 30px; 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: 27px; 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: 9px; }
.darkchat-voice .darkchat-voice__speed { width: 30px; color: #fff; font-size: 9px; 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: 5px 9px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: 11px; background: var(--dc-surface); color: #c4b5fd; font-size: 11px; }
.darkchat-profile-hero small { margin-top: 6px; color: var(--dc-muted); font-size: 10px; }
.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 { overflow: visible; }
.darkchat-settings-group label,
.darkchat-settings-group .darkchat-select,
.darkchat-danger-group button { width: 100%; min-height: 50px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 13px; }
.darkchat-settings-group label:last-child,
.darkchat-settings-group .darkchat-select:last-child,
.darkchat-danger-group button:last-child { border-bottom: 0; }
.darkchat-settings-group label > span,
.darkchat-settings-group .darkchat-select > span,
.darkchat-danger-group button { justify-content: flex-start; }
.darkchat-settings-group label > span,
.darkchat-settings-group .darkchat-select > span { display: flex; align-items: center; gap: 8px; }
.darkchat-settings-group label > span svg,
.darkchat-settings-group .darkchat-select > span svg { color: #a78bfa; }
.darkchat-settings-group input[type="checkbox"] { width: 36px; height: 21px; accent-color: var(--dc-purple); }
.darkchat-select { position: relative; z-index: 2; }
.darkchat-danger-group button { color: var(--dc-red); text-align: left; }
.darkchat-save { padding: 7px; border: 0; background: transparent; color: #a78bfa !important; font-size: 13px; }
.darkchat-privacy-note { margin: 14px 4px 4px; display: flex; align-items: flex-start; gap: 7px; color: var(--dc-muted); font-size: 11px; line-height: 1.45; }
.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: 12px; line-height: 1.45; }
.darkchat-modal > strong { margin-bottom: 12px; color: #c4b5fd; font-size: 12px; }
.darkchat-modal > button:not(.darkchat-primary):not(.darkchat-danger) { min-height: 40px; border: 0; background: transparent; color: var(--dc-muted); font-size: 13px; }
.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-report-select { width: 100%; margin-bottom: 8px; }
.darkchat-report-select .darkchat-choice__trigger,
.darkchat-report-select .darkchat-choice__menu { width: 100%; }
.darkchat-modal textarea { min-height: 65px; resize: none; }
.darkchat-message-menu { padding: 7px; }
.darkchat-message-menu > button { min-height: 46px; padding: 0 12px; display: flex; align-items: center; gap: 10px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 13px; 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 {
height: 100%;
display: flex;
@@ -605,6 +1017,15 @@ button {
grid-template-columns: repeat(4, 1fr);
gap: 28px 15px;
}
.app-grid-slot {
min-height: 73px;
}
.app-icon-item {
position: relative;
min-width: 0;
display: flex;
justify-content: center;
}
.app-icon-button {
min-width: 0;
border: 0;
@@ -618,6 +1039,52 @@ button {
cursor: pointer;
text-shadow: 0 1px 4px #000b;
}
.app-icon-item--dragging {
z-index: 20;
opacity: 0.35;
}
.app-icon-remove {
position: absolute;
z-index: 4;
top: -8px;
left: 1px;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: black;
display: grid;
place-items: center;
cursor: pointer;
text-shadow: none;
}
.app-icon-remove__badge {
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
padding: 0;
border: 1px solid #ffffff73;
box-shadow: 0 1px 4px #0009;
}
.app-icon-item--editing:not(.app-icon-item--dragging) {
animation: app-icon-wobble 0.15s ease-in-out infinite alternate;
transform-origin: 50% 45%;
}
.app-icon-item--editing:nth-child(even):not(.app-icon-item--dragging) {
animation-direction: alternate-reverse;
animation-delay: -0.075s;
}
@keyframes app-icon-wobble {
from {
transform: rotate(-1.5deg) translateY(-0.4px);
}
to {
transform: rotate(1.5deg) translateY(0.4px);
}
}
.app-icon-anchor {
position: relative;
display: inline-grid;
@@ -697,8 +1164,8 @@ button {
right: 15px;
height: 82px;
padding: 13px 18px;
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1px solid #ffffff28;
border-radius: 27px;
background: #ffffff22;
@@ -708,6 +1175,39 @@ button {
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
}
.app-dock-slot {
min-width: 0;
height: 56px;
}
.springboard-edit-done {
position: absolute;
z-index: 12;
top: 45px;
right: 18px;
min-width: 51px;
min-height: 30px;
padding: 0 13px;
border: 1px solid #ffffff38;
border-radius: 999px;
color: white;
font-size: 12px;
font-weight: 650;
line-height: 28px;
text-align: center;
text-shadow: 0 1px 3px #000a;
cursor: pointer;
}
.edit-done-enter-active,
.edit-done-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.edit-done-enter-from,
.edit-done-leave-to {
opacity: 0;
transform: scale(0.88);
}
.page-indicator {
position: absolute;
bottom: 132px;
@@ -1014,6 +1514,11 @@ button {
height: 44px;
border-radius: 11px;
}
@media (prefers-reduced-motion: reduce) {
.app-icon-item--editing:not(.app-icon-item--dragging) {
animation: none;
}
}
.app-library-group .app-icon-button--compact .app-icon {
width: 56px;
height: 56px;
@@ -1158,15 +1663,416 @@ button {
linear-gradient(165deg, #7897b5 0%, #536d8b 48%, #283c58 100%);
}
.weather-navbar {
--k-safe-area-top: 46px;
--tw-bg-opacity: 0;
position: absolute;
z-index: 3;
top: 46px;
top: 0;
right: 0;
left: 0;
background: transparent !important;
color: #fff;
}
/* Banking */
.banking-app {
--bank-blue: #2d76ff;
--bank-cyan: #57d5ff;
--bank-green: #4ee6a4;
position: relative;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 92% 4%, rgb(45 118 255 / 28%), transparent 32%),
linear-gradient(160deg, #0c1834 0%, #080d1d 44%, #050710 100%) !important;
color: #fff;
isolation: isolate;
}
.banking-app::before {
position: absolute;
z-index: -1;
inset: 0;
background-image:
linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px);
background-size: 42px 42px;
content: '';
mask-image: linear-gradient(to bottom, #000, transparent 66%);
}
.banking-app__aurora {
position: absolute;
z-index: -1;
width: 240px;
height: 180px;
top: 160px;
left: -145px;
border-radius: 50%;
background: #215dea;
filter: blur(78px);
opacity: 0.2;
}
.banking-navbar {
--k-safe-area-top: 46px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
color: #fff;
}
.banking-scroll {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 108px 17px 112px;
scrollbar-width: none;
}
.banking-scroll::-webkit-scrollbar { display: none; }
.banking-pull-refresh {
position: absolute;
z-index: 4;
top: 78px;
right: 0;
left: 0;
display: flex;
justify-content: center;
opacity: 0;
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.banking-pull-refresh.is-visible { opacity: 1; }
.banking-loading,
.banking-empty {
position: absolute;
inset: 100px 24px 82px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: rgb(226 235 255 / 72%);
text-align: center;
}
.banking-empty p {
margin: 0 0 8px;
color: rgb(208 220 248 / 58%);
font-size: 12px;
}
.banking-balance {
padding: 22px 20px 20px;
border-radius: 27px;
}
.banking-balance__label {
display: flex;
align-items: center;
justify-content: space-between;
color: rgb(226 235 255 / 67%);
font-size: 12px;
}
.banking-balance__label small {
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 99px;
background: rgb(0 0 0 / 12%);
font-size: 9px;
}
.banking-balance > strong {
display: block;
margin-top: 6px;
font-size: 36px;
font-weight: 600;
letter-spacing: -0.055em;
}
.banking-balance__trend {
margin-top: 8px;
color: rgb(208 220 248 / 57%);
font-size: 10px;
}
.banking-balance__trend span {
margin-right: 4px;
color: var(--bank-green);
font-weight: 650;
}
.banking-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 9px;
margin: 13px 0 18px;
}
.banking-action {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 64px;
padding: 11px 12px;
border-radius: 19px;
color: rgb(237 243 255 / 82%);
font-size: 10px;
}
.banking-action__icon {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
background: linear-gradient(145deg, rgb(60 129 255 / 34%), rgb(25 54 115 / 25%));
color: #8fb8ff;
}
.banking-action b {
font-size: 11px;
font-weight: 610;
}
.banking-action--primary {
grid-column: 1 / -1;
justify-content: flex-start;
min-height: 58px;
padding: 10px 13px;
color: #fff;
}
.banking-action--primary .banking-action__icon {
background: rgb(255 255 255 / 15%);
color: #fff;
}
.banking-action--primary svg:last-child {
margin-left: auto;
opacity: 0.78;
}
.banking-action--secondary {
justify-content: flex-start;
}
.banking-card {
margin: 0 0 18px !important;
}
.banking-transaction-card {
overflow: hidden;
}
.banking-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 11px;
}
.banking-section-title h2 {
margin: 0;
font-size: 15px;
font-weight: 630;
letter-spacing: -0.025em;
}
.banking-section-title > svg { color: rgb(220 232 255 / 43%); }
.banking-section-title button {
color: #7ca8ff;
font-size: 10px;
}
.banking-account-list,
.banking-transaction-list {
margin: 0 !important;
}
.banking-transactions { padding: 0 3px; }
.banking-transactions--all { margin-top: 18px; }
.banking-transaction-list b {
flex: 0 0 auto;
color: #f2a0a8;
font-size: 11px;
font-weight: 620;
}
.banking-transaction-list b.is-incoming { color: var(--bank-green); }
.banking-no-transactions {
padding: 24px 12px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
background: rgb(255 255 255 / 3%);
color: rgb(213 225 248 / 47%);
font-size: 11px;
text-align: center;
}
.banking-activity-hero {
display: flex;
align-items: center;
flex-direction: column;
padding: 16px 0 20px;
text-align: center;
}
.banking-activity-hero span {
color: rgb(214 225 250 / 55%);
font-size: 11px;
}
.banking-activity-hero strong {
margin: 5px 0;
color: var(--bank-green);
font-size: 31px;
font-weight: 590;
letter-spacing: -0.045em;
}
.banking-activity-hero small {
color: rgb(214 225 250 / 38%);
font-size: 9px;
}
.banking-chart-card { padding: 18px 14px 15px !important; }
.banking-chart-legend {
display: flex;
justify-content: flex-end;
gap: 12px;
color: rgb(218 229 251 / 50%);
font-size: 8px;
}
.banking-chart-legend span { display: flex; align-items: center; gap: 4px; }
.banking-chart-legend i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #58647f;
}
.banking-chart-legend i.is-incoming { background: var(--bank-blue); }
.banking-chart {
display: grid;
height: 145px;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
margin-top: 14px;
}
.banking-chart__day {
display: flex;
align-items: center;
justify-content: flex-end;
flex-direction: column;
gap: 7px;
}
.banking-chart__day > div {
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
height: 116px;
gap: 3px;
}
.banking-chart__day i {
width: 7px;
min-height: 5px;
border-radius: 6px;
background: linear-gradient(to top, #343e57, #65718d);
transition: height 0.35s ease;
}
.banking-chart__day i.is-incoming {
background: linear-gradient(to top, #1d55d8, #3d87ff);
box-shadow: 0 0 14px rgb(45 118 255 / 33%);
}
.banking-chart__day span {
color: rgb(217 227 249 / 45%);
font-size: 8px;
}
.banking-sheet__content {
position: relative;
width: 100%;
padding: 22px 18px 18px;
}
.banking-modal__close {
position: absolute;
top: 15px;
right: 15px;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 50%;
background: rgb(255 255 255 / 8%);
color: rgb(226 235 255 / 65%);
}
.banking-modal__icon {
display: grid;
width: 45px;
height: 45px;
place-items: center;
border-radius: 15px;
background: linear-gradient(145deg, #3c83ff, #1849b6);
box-shadow: 0 12px 28px rgb(22 76 196 / 36%), inset 0 1px rgb(255 255 255 / 28%);
}
.banking-sheet__content h2 {
margin: 14px 0 4px;
font-size: 20px;
letter-spacing: -0.035em;
}
.banking-sheet__content > p {
margin: 0 0 15px;
color: rgb(213 225 248 / 52%);
font-size: 10px;
line-height: 1.45;
}
.banking-form-list { margin: 12px 0 0 !important; }
.banking-sheet__content .banking-form-error {
margin: 10px 3px 0;
color: #ff8b98;
font-size: 9px;
}
.banking-sheet__content > button:last-child { margin-top: 15px; }
.banking-sheet__content > button:last-child > span {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.weather-navbar::after {
opacity: 0;
}
@@ -1914,13 +2820,21 @@ button {
color: #000;
}
.reference-store {
padding: 0 0 79px;
background: #0e0e0e;
.app-store-page {
display: flex;
flex-direction: column;
padding: 44px 0 24px;
background: #020202;
color: #fff;
}
.phone-app--light .app-store-page {
background: #f2f2f7;
color: #000;
}
.store-scroll {
height: 100%;
padding: 52px 19px 26px;
min-height: 0;
flex: 1;
padding: 0 19px 26px;
overflow-y: auto;
}
.store-title {
@@ -2034,7 +2948,7 @@ button {
.reference-store .store-feature {
margin: 0 0 20px;
}
.reference-store .store-list {
.app-store-page .store-list {
padding: 0;
}
+175 -38
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { kBadge } from 'konsta/vue'
import { computed, ref } from 'vue'
import { Minus } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
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'
@@ -12,22 +15,45 @@ const props = withDefaults(
defineProps<{
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
showLabel: true,
},
)
const emit = defineEmits<{
dragcancel: []
dragend: [event: PointerEvent]
dragstart: [event: PointerEvent]
edit: []
remove: []
}>()
const phone = usePhoneStore()
const mail = useMailStore()
const marketplace = useMarketplaceStore()
const darkchat = useDarkChatStore()
const router = useRouter()
const iconFailed = ref(false)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const dragStyle = computed(() =>
isDragging.value
? {
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
}
: undefined,
)
const suppressClick = ref(false)
let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
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 = {
@@ -36,6 +62,10 @@ const notificationBadgeColors = {
}
function launch(event: MouseEvent): void {
if (props.editMode || suppressClick.value) {
suppressClick.value = false
return
}
if (!props.app.route) return
const button = event.currentTarget as HTMLElement
@@ -62,47 +92,154 @@ function launch(event: MouseEvent): void {
void router.push(props.app.route)
}
const removeBadgeColors = {
bg: 'bg-[#8e8e93]',
text: 'text-black',
}
function clearHold(): void {
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
holdTimer = undefined
}
function onPointerDown(event: PointerEvent): void {
if (props.compact || event.button !== 0) return
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
beginPointerDrag(event)
return
}
holdTimer = window.setTimeout(() => {
suppressClick.value = true
emit('edit')
beginPointerDrag(event)
holdTimer = undefined
}, 520)
}
function onPointerMove(event: PointerEvent): void {
if (isDragging.value) {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
) {
clearHold()
}
}
function beginPointerDrag(event: PointerEvent): void {
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelPointerDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
}
function cancelPointerDrag(): void {
clearHold()
if (!isDragging.value) return
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelPointerDrag)
}
onBeforeUnmount(() => {
clearHold()
removeDragListeners()
})
</script>
<template>
<button
class="app-icon-button"
:class="{ 'app-icon-button--compact': compact }"
type="button"
:aria-label="phone.t(app.labelKey)"
:aria-disabled="!app.route"
@click="launch"
<div
class="app-icon-item"
:class="{
'app-icon-item--compact': compact,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
}"
:style="dragStyle"
>
<span class="app-icon-anchor" aria-hidden="true">
<span
class="app-icon"
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
>
<img
v-if="!iconFailed"
:src="app.iconImage"
alt=""
draggable="false"
@error="iconFailed = true"
/>
<component
:is="app.icon"
v-else
:size="compact ? 18 : 28"
:stroke-width="2"
/>
<button
class="app-icon-button"
:class="{ 'app-icon-button--compact': compact }"
type="button"
:aria-label="phone.t(app.labelKey)"
:aria-disabled="!app.route"
@click="launch"
@contextmenu.prevent
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<span class="app-icon-anchor" aria-hidden="true">
<span
class="app-icon"
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
>
<img
v-if="!iconFailed"
:src="app.iconImage"
alt=""
draggable="false"
@error="iconFailed = true"
/>
<component
:is="app.icon"
v-else
:size="compact ? 18 : 28"
:stroke-width="2"
/>
</span>
<k-badge
v-if="unreadCount"
class="app-icon-badge"
:small="compact"
:colors="notificationBadgeColors"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</k-badge>
</span>
<k-badge
v-if="unreadCount"
class="app-icon-badge"
:small="compact"
:colors="notificationBadgeColors"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
<span v-if="showLabel" class="app-icon-label">{{
phone.t(app.labelKey)
}}</span>
</button>
<button
v-if="editMode && !NON_REMOVABLE_PHONE_APP_IDS.has(app.id)"
class="app-icon-remove"
type="button"
:aria-label="phone.t('Home.removeApp', { app: phone.t(app.labelKey) })"
@click.stop="emit('remove')"
@pointerdown.stop
>
<k-badge class="app-icon-remove__badge" :colors="removeBadgeColors">
<Minus :size="15" :stroke-width="3" aria-hidden="true" />
</k-badge>
</span>
<span v-if="showLabel" class="app-icon-label">{{
phone.t(app.labelKey)
}}</span>
</button>
</button>
</div>
</template>
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
export type DarkChatSelectOption = {
label: string
value: number | string
}
const props = defineProps<{
label: string
modelValue: number | string
options: DarkChatSelectOption[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: number | string]
}>()
const root = ref<HTMLElement | null>(null)
const opened = ref(false)
const selectedOption = computed(
() =>
props.options.find((option) => option.value === props.modelValue) ??
props.options[0],
)
function selectOption(option: DarkChatSelectOption): void {
emit('update:modelValue', option.value)
opened.value = false
}
function closeFromOutside(event: PointerEvent): void {
if (!root.value?.contains(event.target as Node)) opened.value = false
}
function closeFromEscape(event: KeyboardEvent): void {
if (event.key === 'Escape') opened.value = false
}
onMounted(() => {
document.addEventListener('pointerdown', closeFromOutside)
document.addEventListener('keydown', closeFromEscape)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeFromOutside)
document.removeEventListener('keydown', closeFromEscape)
})
</script>
<template>
<div ref="root" class="darkchat-choice">
<button
type="button"
class="darkchat-choice__trigger"
role="combobox"
:aria-label="label"
:aria-expanded="opened"
aria-haspopup="listbox"
@click="opened = !opened"
>
<span>{{ selectedOption?.label }}</span>
<ChevronDown :size="14" :class="{ open: opened }" />
</button>
<Transition name="darkchat-choice">
<div v-if="opened" class="darkchat-choice__menu" role="listbox">
<button
v-for="option in options"
:key="option.value"
type="button"
role="option"
:aria-selected="option.value === modelValue"
:class="{ selected: option.value === modelValue }"
@click="selectOption(option)"
>
<span>{{ option.label }}</span>
<Check v-if="option.value === modelValue" :size="14" />
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.darkchat-choice { position: relative; min-width: 0; }
.darkchat-choice__trigger {
width: 145px;
height: 35px;
padding: 0 10px 0 11px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
border: 1px solid rgb(139 92 246 / 28%);
border-radius: 10px;
background: linear-gradient(145deg, rgb(44 44 46 / 96%), rgb(28 28 30 / 96%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 7%);
color: #c4b5fd;
font-size: 11px;
text-align: left;
}
.darkchat-choice__trigger span,
.darkchat-choice__menu span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.darkchat-choice__trigger svg { flex: none; transition: transform .18s ease; }
.darkchat-choice__trigger svg.open { transform: rotate(180deg); }
.darkchat-choice__menu {
position: absolute;
z-index: 80;
top: calc(100% + 5px);
right: 0;
width: 195px;
padding: 5px;
overflow: hidden;
border: 1px solid rgb(139 92 246 / 28%);
border-radius: 13px;
background: rgb(28 28 30 / 98%);
box-shadow: 0 14px 38px rgb(0 0 0 / 65%);
backdrop-filter: blur(24px);
}
.darkchat-choice__menu button {
width: 100%;
min-height: 40px;
padding: 7px 9px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 7px;
border: 0;
border-radius: 9px;
background: transparent;
color: #f5f5f7;
font-size: 11px;
text-align: left;
}
.darkchat-choice__menu button.selected { background: rgb(139 92 246 / 18%); color: #c4b5fd; }
.darkchat-choice__menu button svg { flex: none; color: #a78bfa; }
.darkchat-choice-enter-active,
.darkchat-choice-leave-active { transition: opacity .16s ease, transform .16s ease; transform-origin: top right; }
.darkchat-choice-enter-from,
.darkchat-choice-leave-to { opacity: 0; transform: translateY(-4px) scale(.97); }
</style>
@@ -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>
+6 -1
View File
@@ -30,6 +30,11 @@ describe('app registry', () => {
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({
gridOrder: 5,
labelKey: 'Apps.banking.name',
route: '/apps/banking',
})
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
gridOrder: 21,
labelKey: 'Apps.calendar.name',
@@ -113,7 +118,7 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'radio', 'phone', 'mail'])
).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+43
View File
@@ -13,12 +13,14 @@ import {
Mail,
MapPinned,
MessageCircle,
ShieldCheck,
NotebookPen,
Phone,
RadioTower,
Settings,
ShoppingBag,
CloudSun,
Landmark,
Wind,
Tag,
MapPinHouse,
@@ -33,6 +35,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 radioIcon from '@/assets/img/app-icons/radio.svg'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
@@ -46,6 +49,7 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import bankingIcon from '@/assets/img/app-icons/banking.svg'
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import type {
@@ -125,6 +129,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.messages.name',
route: '/apps/messages',
},
{
category: 'social',
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',
},
{
category: 'utilities',
component: markRaw(
@@ -139,6 +157,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.map.name',
route: '/apps/map',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/BankingApp.vue')),
),
dockOrder: null,
gridOrder: 5,
icon: markRaw(Landmark),
iconClass: 'app-icon--banking',
iconImage: bankingIcon,
id: 'banking',
labelKey: 'Apps.banking.name',
route: '/apps/banking',
},
{
category: 'social',
component: markRaw(
@@ -379,6 +411,17 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
},
]
export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
new Set([
'app-store',
'settings',
'camera',
'photos',
'phone',
'messages',
'mail',
])
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
export function getPhoneApp(
-3
View File
@@ -1,3 +0,0 @@
export const IFRUIT_AUTH_INPUT_COLORS = {
outlineLabelBgIos: 'bg-black',
}
+84
View File
@@ -1,7 +1,9 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { removeHomeApp } from '@/utils/homeLayout'
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
vi.mock('@/stores/phone', () => ({
@@ -32,6 +34,7 @@ describe('app store', () => {
expect(apps.launchCounts).toEqual({ mail: 4 })
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
homeLayout: apps.homeLayout,
launchCounts: { mail: 4 },
})
})
@@ -53,6 +56,7 @@ describe('app store', () => {
expect(apps.claimedApps).toEqual(['snake'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
homeLayout: apps.homeLayout,
launchCounts: {},
})
})
@@ -69,4 +73,84 @@ describe('app store', () => {
expect(apps.claimedApps).toEqual(['memory', 'snake'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('reinstalls core and claimed apps removed from the Home Screen', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['memory'] })
apps.removeHomeApp('notes')
apps.removeHomeApp('memory')
mocks.saveDeviceNamespace.mockClear()
apps.installApp('notes')
apps.installApp('memory')
expect(apps.installingApps).toEqual({ notes: true, memory: true })
expect(apps.homeLayout.hidden).toEqual(['notes', 'memory'])
vi.advanceTimersByTime(3000)
expect(apps.installingApps).toEqual({})
expect(apps.homeLayout.hidden).toEqual([])
expect(apps.homeLayout.grid).toContain('notes')
expect(apps.homeLayout.grid).toContain('memory')
expect(apps.claimedApps).toEqual(['memory'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(2)
})
it('prevents protected apps from being removed from the Home Screen', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.saveDeviceNamespace.mockClear()
expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([
'app-store',
'settings',
'camera',
'photos',
'phone',
'messages',
'mail',
])
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
apps.removeHomeApp(appId)
expect(apps.homeLayout.hidden).not.toContain(appId)
}
expect(mocks.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('restores protected apps hidden by older persisted layouts', () => {
const apps = useAppStoreStore()
const legacyLayout = removeHomeApp(apps.homeLayout, 'mail')
apps.hydrate({ homeLayout: legacyLayout })
expect(apps.homeLayout.hidden).not.toContain('mail')
expect(apps.homeLayout.grid).toContain('mail')
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
})
})
it('persists home reordering and removal independently from installation', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
const notesIndex = apps.homeLayout.grid.indexOf('notes')
apps.moveHomeApp('grid', notesIndex, 'grid', 0)
expect(apps.homeLayout.grid[0]).toBe('notes')
apps.removeHomeApp('notes')
expect(apps.homeLayout.grid).not.toContain('notes')
expect(apps.homeLayout.hidden).toContain('notes')
expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
})
})
})
+86 -3
View File
@@ -1,14 +1,40 @@
import { defineStore } from 'pinia'
import { isPhoneAppId } from '@/config/apps'
import {
isPhoneAppId,
NON_REMOVABLE_PHONE_APP_IDS,
PHONE_APPS,
} from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
import {
createDefaultHomeLayout,
moveHomeApp,
parseHomeLayout,
removeHomeApp,
restoreHomeApp,
type HomeArea,
} from '@/utils/homeLayout'
const INSTALL_DURATION_MS = 3000
const DEFAULT_GRID_IDS = [...PHONE_APPS]
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
const DEFAULT_DOCK_IDS = PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id)
const CORE_APP_IDS = PHONE_APPS.filter((app) => app.category !== 'games').map(
(app) => app.id,
)
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as LaunchablePhoneAppId[],
homeLayout: createDefaultHomeLayout(
CORE_APP_IDS,
DEFAULT_GRID_IDS,
DEFAULT_DOCK_IDS,
),
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
@@ -16,21 +42,34 @@ export const useAppStoreStore = defineStore('app-store', {
claimApp(id: LaunchablePhoneAppId): void {
if (!this.claimedApps.includes(id)) {
this.claimedApps.push(id)
this.homeLayout = restoreHomeApp(this.homeLayout, id)
this.persist()
}
},
installApp(id: LaunchablePhoneAppId): void {
if (this.claimedApps.includes(id) || this.installingApps[id]) return
const installed =
CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)
if (
this.installingApps[id] ||
(installed && !this.homeLayout.hidden.includes(id))
) {
return
}
this.installingApps[id] = true
globalThis.setTimeout(() => {
this.claimApp(id)
if (CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)) {
this.restoreHomeApp(id)
} else {
this.claimApp(id)
}
delete this.installingApps[id]
}, INSTALL_DURATION_MS)
},
hydrate(payload: unknown): void {
const data = payload as {
claimedApps?: unknown
homeLayout?: unknown
launchCounts?: unknown
} | null
this.claimedApps = Array.isArray(data?.claimedApps)
@@ -39,6 +78,23 @@ export const useAppStoreStore = defineStore('app-store', {
typeof id === 'string' && isPhoneAppId(id),
)
: []
const installedIds = [...CORE_APP_IDS, ...this.claimedApps]
const defaults = createDefaultHomeLayout(
installedIds,
DEFAULT_GRID_IDS,
DEFAULT_DOCK_IDS,
)
this.homeLayout = parseHomeLayout(
data?.homeLayout,
defaults,
installedIds,
)
const protectedHiddenAppIds = this.homeLayout.hidden.filter((id) =>
NON_REMOVABLE_PHONE_APP_IDS.has(id),
)
for (const appId of protectedHiddenAppIds) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
this.installingApps = {}
this.launchCounts = {}
if (data?.launchCounts && typeof data.launchCounts === 'object') {
@@ -53,14 +109,41 @@ export const useAppStoreStore = defineStore('app-store', {
}
}
}
if (protectedHiddenAppIds.length) this.persist()
},
recordLaunch(appId: LaunchablePhoneAppId): void {
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
this.persist()
},
moveHomeApp(
from: HomeArea,
sourceIndex: number,
to: HomeArea,
targetIndex: number,
): void {
this.homeLayout = moveHomeApp(
this.homeLayout,
from,
sourceIndex,
to,
targetIndex,
)
this.persist()
},
removeHomeApp(appId: LaunchablePhoneAppId): void {
if (NON_REMOVABLE_PHONE_APP_IDS.has(appId)) return
this.homeLayout = removeHomeApp(this.homeLayout, appId)
this.persist()
},
restoreHomeApp(appId: LaunchablePhoneAppId): void {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
this.persist()
},
persist(): void {
usePhoneStore().saveDeviceNamespace('apps', {
claimedApps: this.claimedApps,
homeLayout: this.homeLayout,
launchCounts: this.launchCounts,
})
},
+63
View File
@@ -0,0 +1,63 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBankingStore } from '@/stores/banking'
import type { BankingOverview } from '@/types/banking'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const overview: BankingOverview = {
bank: 24787,
cash: 2350,
currency: '$',
playerId: 42,
playerName: 'Alex Morgan',
transactions: [],
}
describe('banking store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads the server-authoritative banking overview', async () => {
mockNuiCall.mockResolvedValueOnce({ data: overview, success: true })
const banking = useBankingStore()
expect(await banking.load()).toBe(true)
expect(banking.overview).toEqual(overview)
expect(mockNuiCall).toHaveBeenCalledWith('banking:overview')
})
it('updates balances after a successful transfer', async () => {
const updated = { ...overview, bank: 23787 }
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
const banking = useBankingStore()
const response = await banking.perform('transfer', 1000, 17)
expect(response.success).toBe(true)
expect(banking.overview?.bank).toBe(23787)
expect(mockNuiCall).toHaveBeenCalledWith('banking:transfer', {
amount: 1000,
target: 17,
})
})
it('keeps the previous overview and exposes server errors', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'insufficient_funds',
success: false,
})
const banking = useBankingStore()
banking.overview = overview
await banking.perform('transfer', 50000, 17)
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
})
})
+45
View File
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import type { BankingAction, BankingOverview } from '@/types/banking'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useBankingStore = defineStore('banking', {
state: () => ({
error: '',
isLoading: false,
overview: null as BankingOverview | null,
}),
actions: {
async load(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<BankingOverview>('banking:overview')
this.isLoading = false
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async perform(
action: BankingAction,
amount: number,
target?: number,
): Promise<NuiResponse<BankingOverview>> {
this.isLoading = true
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
amount,
...(target === undefined ? {} : { target }),
})
this.isLoading = false
if (response.success && response.data) {
this.overview = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
+108
View File
@@ -0,0 +1,108 @@
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('uses a local media preview without sending that URL to the server', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true })
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
.mockResolvedValueOnce({ data: { ...message('media-id'), messageType: 'image' }, success: true })
.mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true })
const store = useDarkChatStore()
await store.openThread(conversation.id)
const sending = store.send({
mediaAssetId: '17',
mediaPreviewUrl: 'https://media.example/photo.jpg',
messageType: 'image',
})
expect(store.messages[0]).toMatchObject({
mediaPayload: 'https://media.example/photo.jpg',
messageType: 'image',
})
expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'darkchat:send', {
conversationId: conversation.id,
mediaAssetId: '17',
messageType: 'image',
})
await sending
})
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)
})
})
+160
View File
@@ -0,0 +1,160 @@
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 { mediaPreviewUrl, ...request } = outgoing
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,
mediaPayload:
outgoing.messageType === 'image' || outgoing.messageType === 'video'
? mediaPreviewUrl
: outgoing.mediaPayload,
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', {
...request,
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,
}
})
+34
View File
@@ -0,0 +1,34 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useMessageMediaStore } from '@/stores/messageMedia'
import type { PhoneMedia } from '@/types/media'
const photo: PhoneMedia = {
createdAt: 1_786_035_600,
id: 17,
mediaType: 'photo',
url: 'https://media.example/photo.jpg',
}
describe('message media handoff', () => {
beforeEach(() => setActivePinia(createPinia()))
it('returns captured media to the requesting DarkChat conversation', () => {
const store = useMessageMediaStore()
store.begin('darkchat:conversation-id', 'photo', '/apps/darkchat')
expect(store.complete(photo)).toBe('/apps/darkchat')
expect(store.consume('darkchat:another-conversation')).toBeNull()
expect(store.consume('darkchat:conversation-id')).toEqual(photo)
})
it('keeps the request active when the selected media type does not match', () => {
const store = useMessageMediaStore()
store.begin('4205550196', 'video')
expect(store.complete(photo)).toBeNull()
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
expect(store.cancel()).toBe('/apps/messages')
})
})
+17 -8
View File
@@ -4,7 +4,8 @@ import type { MediaType, PhoneMedia } from '@/types/media'
type MessageMediaRequest = {
mediaType: MediaType
phoneNumber: string
returnPath: string
target: string
}
type MessageMediaResult = MessageMediaRequest & {
@@ -17,20 +18,28 @@ export const useMessageMediaStore = defineStore('message-media', {
result: null as MessageMediaResult | null,
}),
actions: {
begin(phoneNumber: string, mediaType: MediaType): void {
this.request = { mediaType, phoneNumber }
begin(
target: string,
mediaType: MediaType,
returnPath = '/apps/messages',
): void {
this.request = { mediaType, returnPath, target }
this.result = null
},
cancel(): void {
cancel(): string {
const returnPath = this.request?.returnPath ?? '/apps/messages'
this.request = null
return returnPath
},
complete(media: PhoneMedia): void {
if (!this.request || this.request.mediaType !== media.mediaType) return
complete(media: PhoneMedia): string | null {
if (!this.request || this.request.mediaType !== media.mediaType) return null
const returnPath = this.request.returnPath
this.result = { ...this.request, media }
this.request = null
return returnPath
},
consume(phoneNumber: string): PhoneMedia | null {
if (!this.result || this.result.phoneNumber !== phoneNumber) return null
consume(target: string): PhoneMedia | null {
if (!this.result || this.result.target !== target) return null
const media = this.result.media
this.result = null
return media
+74
View File
@@ -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', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More',
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_attachment: 'This photo or video is unavailable.', 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}',
@@ -244,6 +265,58 @@ const defaultLocales: LocaleTree = {
default: 'The radio request failed.',
},
},
banking: {
name: 'Banking',
welcome: 'Welcome back',
totalBalance: 'Total Balance',
recentPeriod: 'in recent activity',
actions: 'Banking actions',
send: 'Send',
accounts: 'Accounts',
bankAccount: 'Bank account',
cash: 'Cash',
latestTransactions: 'Latest Transactions',
allTransactions: 'All Transactions',
viewAll: 'View all',
noTransactions: 'Your banking activity will appear here.',
home: 'Home',
activity: 'Activity',
chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing',
navigation: 'Banking navigation',
incoming: 'Incoming',
outgoing: 'Outgoing',
refresh: 'Refresh banking data',
unavailable: 'Banking unavailable',
tryAgain: 'Try Again',
playerId: 'Player ID',
playerIdPlaceholder: 'Enter the recipient ID',
amount: 'Amount',
amountPlaceholder: 'Enter an amount',
transactions: {
deposit: 'Cash deposit',
withdrawal: 'Cash withdrawal',
transfer_in: 'Incoming transfer',
transfer_out: 'Outgoing transfer',
},
forms: {
transfer: {
title: 'Send money',
body: 'Transfer money from your bank account to an online player.',
submit: 'Send transfer',
},
},
errors: {
invalid_request: 'Enter a valid whole amount and player ID.',
insufficient_funds: 'There is not enough money in this account.',
target_not_found: 'The recipient is not online.',
self_transfer: 'You cannot send money to yourself.',
rate_limited: 'Please wait before making another transaction.',
transfer_failed: 'The transfer could not be completed.',
banking_unavailable: 'Banking is currently unavailable.',
request_failed: 'The banking request failed.',
default: 'The banking request failed.',
},
},
calculator: { name: 'Calculator' },
snake: {
name: 'Snake',
@@ -1048,6 +1121,7 @@ const defaultLocales: LocaleTree = {
apps: 'Apps',
dock: 'Dock',
noApps: 'No apps found',
removeApp: 'Remove {app} from Home Screen',
page: 'Page',
pages: 'Home screen pages',
groups: {
+2
View File
@@ -3,11 +3,13 @@ import type { Component } from 'vue'
export type PhoneAppId =
| 'phone'
| 'messages'
| 'darkchat'
| 'calculator'
| 'camera'
| 'clock'
| 'calendar'
| 'weather'
| 'banking'
| 'mail'
| 'map'
| 'notes'
+25
View File
@@ -0,0 +1,25 @@
export type BankingTransactionKind =
| 'deposit'
| 'withdrawal'
| 'transfer_in'
| 'transfer_out'
export type BankingTransaction = {
amount: number
createdAt: number
id: number
kind: BankingTransactionKind
label: string
reference: string
}
export type BankingOverview = {
bank: number
cash: number
currency: string
playerId: number
playerName: string
transactions: BankingTransaction[]
}
export type BankingAction = 'transfer'
+99
View File
@@ -0,0 +1,99 @@
import type { DatabaseDateValue } from '@/utils/date'
export type DarkChatMessageType =
| 'text'
| 'emoji'
| 'gif'
| 'voice'
| 'image'
| 'video'
| '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' | 'image' | 'video'
mediaAssetId?: string
mediaPayload?: string
mediaPreviewUrl?: string
mediaMime?: string
mediaDurationMs?: number
mediaWaveform?: number[]
replyToId?: string
}
+23
View File
@@ -0,0 +1,23 @@
export function copyText(value: string): boolean {
const activeElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: undefined
const textarea = document.createElement('textarea')
textarea.value = value
textarea.readOnly = true
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, value.length)
try {
return document.execCommand('copy')
} finally {
textarea.remove()
activeElement?.focus({ preventScroll: true })
}
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultHomeLayout,
HOME_GRID_PAGE_SIZE,
moveHomeApp,
parseHomeLayout,
removeHomeApp,
restoreHomeApp,
type HomeLayout,
} from '@/utils/homeLayout'
const installed = ['phone', 'messages', 'mail', 'clock', 'notes'] as const
const defaults = createDefaultHomeLayout(
[...installed],
['phone', 'messages', 'mail', 'clock', 'notes'],
['phone', 'messages', 'clock'],
)
describe('home layout', () => {
it('uses fixed grid and dock slots for the registry arrangement', () => {
const layout = parseHomeLayout(undefined, defaults, [...installed])
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
expect(layout.grid.slice(0, 6)).toEqual([
'phone',
'messages',
'mail',
'clock',
'notes',
null,
])
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
expect(layout.version).toBe(2)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
const layout = parseHomeLayout(
{
dock: ['messages', 'invalid', 'messages'],
grid: ['mail'],
hidden: ['phone'],
},
defaults,
[...installed],
)
expect(layout.dock).toEqual(['messages', null, null, null])
expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null])
expect(layout.hidden).toEqual(['phone'])
expect(layout.version).toBe(2)
})
it('preserves explicit gaps in versioned layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: HOME_GRID_PAGE_SIZE },
() => null,
)
grid[0] = 'phone'
grid[7] = 'mail'
const layout = parseHomeLayout(
{
dock: ['messages', null, 'clock', null],
grid,
hidden: ['notes'],
version: 2,
},
defaults,
[...installed],
)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[1]).toBeNull()
expect(layout.grid[7]).toBe('mail')
expect(layout.dock).toEqual(['messages', null, 'clock', null])
})
it('preserves independently positioned shortcuts for the same app', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: HOME_GRID_PAGE_SIZE },
() => null,
)
grid[0] = 'phone'
grid[5] = 'phone'
const layout = parseHomeLayout(
{
dock: ['phone', null, null, null],
grid,
hidden: [],
version: 2,
},
defaults,
[...installed],
)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[5]).toBe('phone')
expect(layout.dock[0]).toBe('phone')
})
it('moves to an exact empty slot without compacting other apps', () => {
const moved = moveHomeApp(defaults, 'grid', 2, 'grid', 12)
expect(moved.grid[2]).toBeNull()
expect(moved.grid[12]).toBe('mail')
expect(moved.grid[0]).toBe('phone')
expect(moved.grid[4]).toBe('notes')
})
it('shifts occupied grid slots instead of replacing their apps', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
'mail',
'phone',
'messages',
'clock',
'notes',
])
})
it('shifts an occupied dock slot toward its gap', () => {
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
expect(docked.grid[2]).toBeNull()
})
it('moves an app displaced from a full dock into the source slot', () => {
const layout: HomeLayout = {
...defaults,
dock: ['phone', 'messages', 'clock', 'notes'],
}
const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1)
expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock'])
expect(docked.grid[2]).toBe('notes')
})
it('moves shortcuts between the dock and grid independently', () => {
const movedToGrid = moveHomeApp(defaults, 'dock', 0, 'grid', 5)
expect(movedToGrid.dock[0]).toBeNull()
expect(movedToGrid.grid[0]).toBe('phone')
expect(movedToGrid.grid[5]).toBe('phone')
const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3)
expect(movedToDock.grid[1]).toBeNull()
expect(movedToDock.dock[1]).toBe('messages')
expect(movedToDock.dock[3]).toBe('messages')
})
it('removes shortcuts without closing gaps and restores the first gap', () => {
const layout: HomeLayout = moveHomeApp(defaults, 'grid', 0, 'grid', 10)
const removed = removeHomeApp(layout, 'phone')
expect(removed.grid[0]).toBeNull()
expect(removed.grid[10]).toBeNull()
expect(removed.dock[0]).toBeNull()
expect(removed.hidden).toContain('phone')
const restored = restoreHomeApp(removed, 'phone')
expect(restored.grid[0]).toBe('phone')
expect(restored.hidden).not.toContain('phone')
})
})
+259
View File
@@ -0,0 +1,259 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_PAGE_SIZE = 20
const MAX_HOME_GRID_PAGES = 5
export type HomeArea = 'dock' | 'grid'
export type HomeSlot = LaunchablePhoneAppId | null
export type HomeLayout = {
dock: HomeSlot[]
grid: HomeSlot[]
hidden: LaunchablePhoneAppId[]
version: 2
}
function readAppIds(
value: unknown,
availableIds: Set<LaunchablePhoneAppId>,
): LaunchablePhoneAppId[] {
if (!Array.isArray(value)) return []
const ids: LaunchablePhoneAppId[] = []
for (const valueId of value) {
if (
typeof valueId === 'string' &&
availableIds.has(valueId as LaunchablePhoneAppId) &&
!ids.includes(valueId as LaunchablePhoneAppId)
) {
ids.push(valueId as LaunchablePhoneAppId)
}
}
return ids
}
function createSlots(length: number): HomeSlot[] {
return Array.from({ length }, () => null)
}
function getGridCapacity(itemCount: number): number {
return Math.max(
HOME_GRID_PAGE_SIZE,
Math.ceil(itemCount / HOME_GRID_PAGE_SIZE) * HOME_GRID_PAGE_SIZE,
)
}
function readSlots(
value: unknown,
availableIds: Set<LaunchablePhoneAppId>,
length: number,
): HomeSlot[] {
const slots = createSlots(length)
if (!Array.isArray(value)) return slots
for (let index = 0; index < Math.min(value.length, length); index += 1) {
const valueId = value[index]
if (
typeof valueId === 'string' &&
availableIds.has(valueId as LaunchablePhoneAppId)
) {
slots[index] = valueId as LaunchablePhoneAppId
}
}
return slots
}
function placeInFirstEmptySlot(
slots: HomeSlot[],
appId: LaunchablePhoneAppId,
): void {
const emptyIndex = slots.indexOf(null)
if (emptyIndex !== -1) {
slots[emptyIndex] = appId
return
}
slots.push(...createSlots(HOME_GRID_PAGE_SIZE))
slots[slots.length - HOME_GRID_PAGE_SIZE] = appId
}
function insertIntoSlot(
slots: HomeSlot[],
targetIndex: number,
appId: LaunchablePhoneAppId,
): HomeSlot {
if (slots[targetIndex] === null) {
slots[targetIndex] = appId
return null
}
const emptyAfter = slots.indexOf(null, targetIndex + 1)
if (emptyAfter !== -1) {
for (let index = emptyAfter; index > targetIndex; index -= 1) {
slots[index] = slots[index - 1]
}
slots[targetIndex] = appId
return null
}
const emptyBefore = slots.lastIndexOf(null, targetIndex - 1)
if (emptyBefore !== -1) {
for (let index = emptyBefore; index < targetIndex; index += 1) {
slots[index] = slots[index + 1]
}
slots[targetIndex] = appId
return null
}
const displacedApp = slots.at(-1) ?? null
for (let index = slots.length - 1; index > targetIndex; index -= 1) {
slots[index] = slots[index - 1]
}
slots[targetIndex] = appId
return displacedApp
}
export function createDefaultHomeLayout(
installedIds: LaunchablePhoneAppId[],
defaultGridIds: LaunchablePhoneAppId[],
defaultDockIds: LaunchablePhoneAppId[],
): HomeLayout {
const installed = new Set(installedIds)
const gridIds = defaultGridIds.filter((id) => installed.has(id))
const grid = createSlots(getGridCapacity(gridIds.length))
for (let index = 0; index < gridIds.length; index += 1) {
grid[index] = gridIds[index]
}
const dock = createSlots(HOME_DOCK_CAPACITY)
for (const [index, id] of defaultDockIds
.filter((id) => installed.has(id))
.slice(0, HOME_DOCK_CAPACITY)
.entries()) {
dock[index] = id
}
return { dock, grid, hidden: [], version: 2 }
}
export function parseHomeLayout(
value: unknown,
defaults: HomeLayout,
installedIds: LaunchablePhoneAppId[],
): HomeLayout {
if (!value || typeof value !== 'object') return defaults
const source = value as Partial<Record<keyof HomeLayout, unknown>>
const availableIds = new Set(installedIds)
const hidden = readAppIds(source.hidden, availableIds)
const hiddenIds = new Set(hidden)
const persistedGridLength = Array.isArray(source.grid)
? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
: 0
const gridLength = Math.max(
defaults.grid.length,
getGridCapacity(persistedGridLength),
)
let grid: HomeSlot[]
let dock: HomeSlot[]
if (source.version === 2) {
grid = readSlots(source.grid, availableIds, gridLength)
dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY)
} else {
grid = createSlots(gridLength)
for (const id of readAppIds(source.grid, availableIds)) {
placeInFirstEmptySlot(grid, id)
}
dock = createSlots(HOME_DOCK_CAPACITY)
for (const [index, id] of readAppIds(source.dock, availableIds)
.slice(0, HOME_DOCK_CAPACITY)
.entries()) {
dock[index] = id
}
}
grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null))
dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null))
const placedIds = new Set(
[...grid, ...dock, ...hidden].filter(
(id): id is LaunchablePhoneAppId => id !== null,
),
)
for (const id of defaults.grid) {
if (id && !placedIds.has(id)) {
placeInFirstEmptySlot(grid, id)
placedIds.add(id)
}
}
return { dock, grid, hidden, version: 2 }
}
export function removeHomeApp(
layout: HomeLayout,
appId: LaunchablePhoneAppId,
): HomeLayout {
return {
dock: layout.dock.map((id) => (id === appId ? null : id)),
grid: layout.grid.map((id) => (id === appId ? null : id)),
hidden: layout.hidden.includes(appId)
? [...layout.hidden]
: [...layout.hidden, appId],
version: 2,
}
}
export function restoreHomeApp(
layout: HomeLayout,
appId: LaunchablePhoneAppId,
): HomeLayout {
if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout
const grid = [...layout.grid]
placeInFirstEmptySlot(grid, appId)
return {
dock: [...layout.dock],
grid,
hidden: layout.hidden.filter((id) => id !== appId),
version: 2,
}
}
export function moveHomeApp(
layout: HomeLayout,
from: HomeArea,
sourceIndex: number,
to: HomeArea,
targetIndex: number,
): HomeLayout {
const next: HomeLayout = {
dock: [...layout.dock],
grid: [...layout.grid],
hidden: [...layout.hidden],
version: 2,
}
const source = next[from]
const target = next[to]
const appId = source[sourceIndex]
if (
!appId ||
sourceIndex < 0 ||
sourceIndex >= source.length ||
targetIndex < 0 ||
targetIndex >= target.length
) {
return layout
}
if (from === to) {
if (sourceIndex === targetIndex) return layout
source[sourceIndex] = null
insertIntoSlot(source, targetIndex, appId)
return next
}
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
return next
}
+2
View File
@@ -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 },
@@ -65,6 +66,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
banking: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
+168 -23
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Search, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { kGlass } from 'konsta/vue'
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
@@ -8,9 +9,10 @@ import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import type { LaunchablePhoneAppId } from '@/types/apps'
import { HOME_GRID_PAGE_SIZE, type HomeArea } from '@/utils/homeLayout'
import { paginateItems } from '@/utils/pages'
const APPS_PER_HOME_PAGE = 20
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'games',
'productivity',
@@ -23,52 +25,65 @@ const appStore = useAppStoreStore()
const searchQuery = ref('')
const searchFocused = ref(false)
const showAllApps = ref(false)
const editMode = ref(false)
const dragOffset = ref(0)
const dragging = ref(false)
const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
let pointerStart = 0
let pointerStartedAt = 0
const gridApps = computed(() =>
const installedApps = computed(() =>
PHONE_APPS.filter(
(app) => app.category !== 'games' || appStore.claimedApps.includes(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
),
)
const installedAppsById = computed(
() => new Map(installedApps.value.map((app) => [app.id, app])),
)
const gridSlots = computed(() =>
appStore.homeLayout.grid.map((id) =>
id ? (installedAppsById.value.get(id) ?? null) : null,
),
)
const appPages = computed(() =>
paginateItems(gridApps.value, APPS_PER_HOME_PAGE),
paginateItems(gridSlots.value, HOME_GRID_PAGE_SIZE),
)
const pageCount = computed(() => appPages.value.length + 2)
const libraryPage = computed(() => pageCount.value - 1)
const isAppPage = computed(
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
)
const dockApps = computed(() =>
PHONE_APPS.filter((app) => app.dockOrder !== null).sort(
(a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0),
const dockSlots = computed(() =>
appStore.homeLayout.dock.map((id) =>
id ? (installedAppsById.value.get(id) ?? null) : null,
),
)
const filteredApps = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
if (!query) return gridApps.value
return gridApps.value.filter((app) =>
if (!query) return installedApps.value
return installedApps.value.filter((app) =>
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(query),
)
})
const appGroups = computed(() => {
const suggestions = [...gridApps.value]
const suggestions = [...installedApps.value]
.sort(
(a, b) =>
(appStore.launchCounts[b.id] ?? 0) -
(appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder,
)
.slice(0, 7)
const recentlyAdded = [...gridApps.value]
const recentlyAdded = [...installedApps.value]
.sort((a, b) => b.gridOrder - a.gridOrder)
.slice(0, 7)
const groups = [
{ apps: suggestions, key: 'suggestions' },
{ apps: recentlyAdded, key: 'recentlyAdded' },
...APP_LIBRARY_CATEGORIES.map((category) => ({
apps: gridApps.value.filter((app) => app.category === category),
apps: installedApps.value.filter((app) => app.category === category),
key: category,
})),
]
@@ -100,6 +115,7 @@ const trackStyle = computed(() => ({
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
function onPointerDown(event: PointerEvent): void {
if (editMode.value) return
const target = event.target as HTMLElement
if (target.closest('button, input')) return
pointerStart = event.clientX
@@ -128,6 +144,60 @@ function finishPointer(event: PointerEvent): void {
dragOffset.value = 0
}
function enterEditMode(): void {
editMode.value = true
dragging.value = false
dragOffset.value = 0
}
function startHomeDrag(area: HomeArea, index: number): void {
draggingHomeApp.value = { area, index }
}
function finishHomeDrag(event: PointerEvent): void {
const dragged = draggingHomeApp.value
if (!dragged) return
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.find((element) => !element.closest('.app-icon-item--dragging'))
const targetArea = target?.closest<HTMLElement>('[data-home-area]')
let targetItem = target?.closest<HTMLElement>('[data-home-index]')
if (!targetItem && targetArea) {
const slotItems = Array.from(
targetArea.querySelectorAll<HTMLElement>('[data-home-index]'),
)
targetItem = slotItems.reduce<HTMLElement | undefined>((closest, slot) => {
if (!closest) return slot
const slotBounds = slot.getBoundingClientRect()
const closestBounds = closest.getBoundingClientRect()
const slotDistance = Math.hypot(
event.clientX - (slotBounds.left + slotBounds.width / 2),
event.clientY - (slotBounds.top + slotBounds.height / 2),
)
const closestDistance = Math.hypot(
event.clientX - (closestBounds.left + closestBounds.width / 2),
event.clientY - (closestBounds.top + closestBounds.height / 2),
)
return slotDistance < closestDistance ? slot : closest
}, undefined)
}
const area = (targetItem?.dataset.homeArea ??
targetArea?.dataset.homeArea) as HomeArea | undefined
if ((area === 'grid' || area === 'dock') && targetItem) {
const targetIndex = Number.parseInt(targetItem.dataset.homeIndex ?? '', 10)
appStore.moveHomeApp(dragged.area, dragged.index, area, targetIndex)
}
draggingHomeApp.value = null
}
function stopHomeDrag(): void {
draggingHomeApp.value = null
}
function removeHomeApp(appId: LaunchablePhoneAppId): void {
appStore.removeHomeApp(appId)
}
function clearSearch(): void {
searchQuery.value = ''
searchFocused.value = false
@@ -138,6 +208,10 @@ function openAllApps(): void {
searchFocused.value = true
showAllApps.value = true
}
watch(isAppPage, (visible) => {
if (!visible) editMode.value = false
})
</script>
<template>
@@ -145,7 +219,10 @@ function openAllApps(): void {
class="springboard"
:class="[
`wallpaper--${phone.preferences.settings.wallpaper}`,
{ 'springboard--dragging': dragging },
{
'springboard--dragging': dragging,
'springboard--editing': editMode,
},
]"
>
<div
@@ -171,8 +248,39 @@ function openAllApps(): void {
:style="pageStyle"
:aria-label="phone.t('Home.apps')"
>
<div class="app-grid">
<AppIcon v-for="app in apps" :key="app.id" :app="app" />
<div class="app-grid" data-home-area="grid">
<template
v-for="(app, appIndex) in apps"
:key="
app?.id ??
`grid-empty-${pageIndex * HOME_GRID_PAGE_SIZE + appIndex}`
"
>
<AppIcon
v-if="app"
:app="app"
data-home-area="grid"
:data-home-index="pageIndex * HOME_GRID_PAGE_SIZE + appIndex"
:edit-mode="editMode"
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragstart="
startHomeDrag(
'grid',
pageIndex * HOME_GRID_PAGE_SIZE + appIndex,
)
"
@edit="enterEditMode"
@remove="removeHomeApp(app.id)"
/>
<div
v-else
class="app-grid-slot"
data-home-area="grid"
:data-home-index="pageIndex * HOME_GRID_PAGE_SIZE + appIndex"
aria-hidden="true"
></div>
</template>
</div>
</section>
@@ -268,14 +376,51 @@ function openAllApps(): void {
</section>
</div>
<Transition name="edit-done">
<k-glass
v-if="editMode && isAppPage"
component="button"
class="springboard-edit-done"
type="button"
@click="editMode = false"
>
{{ phone.t('Common.done') }}
</k-glass>
</Transition>
<Transition name="dock">
<nav v-if="isAppPage" class="app-dock" :aria-label="phone.t('Home.dock')">
<AppIcon
v-for="app in dockApps"
:key="app.id"
:app="app"
:show-label="false"
/>
<nav
v-if="isAppPage"
class="app-dock"
:class="{ 'app-dock--editing': editMode }"
:aria-label="phone.t('Home.dock')"
data-home-area="dock"
>
<template
v-for="(app, appIndex) in dockSlots"
:key="app?.id ?? `dock-empty-${appIndex}`"
>
<AppIcon
v-if="app"
:app="app"
data-home-area="dock"
:data-home-index="appIndex"
:edit-mode="editMode"
:show-label="false"
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragstart="startHomeDrag('dock', appIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(app.id)"
/>
<div
v-else
class="app-dock-slot"
data-home-area="dock"
:data-home-index="appIndex"
aria-hidden="true"
></div>
</template>
</nav>
</Transition>
+110 -60
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { kPreloader } from 'konsta/vue'
import {
kNavbar,
kPage,
kPreloader,
kSearchbar,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { Gamepad2, Grid2X2, Search } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -7,78 +14,113 @@ import { useRouter } from 'vue-router'
import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
} from '@/types/apps'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const router = useRouter()
const tab = ref<'apps' | 'games' | 'search'>('apps')
const query = ref('')
const installedDuringVisit = ref<LaunchablePhoneAppId[]>([])
const tabs = [
{ id: 'apps', icon: Grid2X2 },
{ id: 'games', icon: Gamepad2 },
{ id: 'search', icon: Search },
] as const
const catalog = PHONE_APPS.filter((app) => app.id !== 'app-store').sort(
(a, b) => a.gridOrder - b.gridOrder,
const tabBarColors = {
strongHighlightBgIos: 'bg-[#e5e5ea] dark:bg-[#2c2c2e]',
}
const catalog = computed(() =>
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
if (
app.component === null ||
app.route === null ||
app.id === 'app-store'
) {
return false
}
const installed =
app.category !== 'games' || appStore.claimedApps.includes(app.id)
return (
!installed ||
appStore.homeLayout.hidden.includes(app.id) ||
installedDuringVisit.value.includes(app.id)
)
}).sort((a, b) => a.gridOrder - b.gridOrder),
)
const shownApps = computed(() => {
if (tab.value === 'games') {
return catalog.filter((app) => app.category === 'games')
return catalog.value.filter((app) => app.category === 'games')
}
if (tab.value === 'apps') {
return catalog.filter((app) => app.category !== 'games')
return catalog.value.filter((app) => app.category !== 'games')
}
const search = query.value.trim().toLocaleLowerCase(phone.lang)
if (!search) return catalog
return catalog.filter((app) =>
if (!search) return catalog.value
return catalog.value.filter((app) =>
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search),
)
})
function isInstalled(app: PhoneAppDefinition): boolean {
return app.category !== 'games' || appStore.claimedApps.includes(app.id)
function updateSearch(event: Event): void {
query.value = (event.target as HTMLInputElement).value
}
function handleApp(app: PhoneAppDefinition): void {
if (isInstalled(app)) {
if (app.route) void router.push(app.route)
function appAction(
app: LaunchablePhoneAppDefinition,
): 'get' | 'installing' | 'open' {
if (appStore.installingApps[app.id]) return 'installing'
const installed =
app.category !== 'games' || appStore.claimedApps.includes(app.id)
if (
installed &&
!appStore.homeLayout.hidden.includes(app.id) &&
installedDuringVisit.value.includes(app.id)
) {
return 'open'
}
return 'get'
}
function handleApp(app: LaunchablePhoneAppDefinition): void {
if (appAction(app) === 'open') {
void router.push(app.route)
return
}
if (!installedDuringVisit.value.includes(app.id)) {
installedDuringVisit.value.push(app.id)
}
appStore.installApp(app.id)
}
</script>
<template>
<main class="native-app reference-store">
<section class="store-scroll">
<header class="store-title">
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
</header>
<div v-if="tab === 'search'" class="app-search">
<Search :size="17" />
<input
v-model="query"
<k-page component="main" class="native-app app-store-page">
<k-navbar
large
transparent
:title="phone.t('Apps.appStore.name')"
class="top-0 sticky"
>
<template v-if="tab === 'search'" #subnavbar>
<k-searchbar
:value="query"
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
@input="updateSearch"
@clear="query = ''"
/>
</div>
<div class="store-section-title">
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.gamesTitle'
: 'Apps.appStore.appsTitle',
)
}}
</h2>
<p>{{ phone.t('Apps.appStore.selected') }}</p>
</div>
</template>
</k-navbar>
<section class="store-scroll">
<section class="store-list">
<article v-for="app in shownApps" :key="app.id">
<img
@@ -95,11 +137,7 @@ function handleApp(app: PhoneAppDefinition): void {
type="button"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${phone.t(app.labelKey)} ${phone.t(
appStore.installingApps[app.id]
? 'Apps.appStore.installing'
: isInstalled(app)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
`Apps.appStore.${appAction(app)}`,
)}`"
@click="handleApp(app)"
>
@@ -108,11 +146,7 @@ function handleApp(app: PhoneAppDefinition): void {
class="store-installing"
/>
<template v-else>
{{
phone.t(
isInstalled(app) ? 'Apps.appStore.open' : 'Apps.appStore.get',
)
}}
{{ phone.t(`Apps.appStore.${appAction(app)}`) }}
</template>
</button>
</article>
@@ -122,17 +156,33 @@ function handleApp(app: PhoneAppDefinition): void {
</section>
</section>
<nav class="reference-tabbar">
<button
v-for="item in tabs"
:key="item.id"
:class="{ active: tab === item.id }"
type="button"
@click="tab = item.id"
>
<component :is="item.icon" :size="21" />
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
</button>
</nav>
</main>
<k-navbar component="nav" :aria-label="phone.t('Apps.appStore.name')">
<template #subnavbar>
<k-segmented
strong
rounded
:colors="tabBarColors"
:data-active-tab="tab"
>
<k-segmented-button
v-for="item in tabs"
:key="item.id"
large
:active="tab === item.id"
:class="tab === item.id ? 'text-primary' : 'text-[#8e8e93]'"
:aria-label="phone.t(`Apps.appStore.tabs.${item.id}`)"
:aria-pressed="tab === item.id"
@click="tab = item.id"
>
<span
class="flex flex-col items-center gap-0.5 text-[10px] leading-none"
>
<component :is="item.icon" class="h-5 w-5" aria-hidden="true" />
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
</span>
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
</k-page>
</template>
+608
View File
@@ -0,0 +1,608 @@
<script setup lang="ts">
import {
kButton,
kCard,
kGlass,
kIcon,
kLink,
kList,
kListInput,
kListItem,
kNavbar,
kPage,
kPreloader,
kSheet,
kTabbar,
kTabbarLink,
kToolbarPane,
} from 'konsta/vue'
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
BarChart3,
ChevronRight,
CircleDollarSign,
House,
Landmark,
Send,
WalletCards,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useBankingStore } from '@/stores/banking'
import { usePhoneStore } from '@/stores/phone'
import type {
BankingAction,
BankingTransaction,
BankingTransactionKind,
} from '@/types/banking'
type BankingTab = 'home' | 'activity'
const phone = usePhoneStore()
const banking = useBankingStore()
const activeTab = ref<BankingTab>('home')
const action = ref<BankingAction | null>(null)
const amount = ref('')
const target = ref('')
const formError = ref('')
const bankingScroll = ref<HTMLElement | null>(null)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const pullThreshold = 56
let pullStartY = 0
let isPulling = false
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
let previousFocus: HTMLElement | null = null
const transactionIcons: Record<BankingTransactionKind, typeof Send> = {
deposit: ArrowDownLeft,
withdrawal: ArrowUpRight,
transfer_in: ArrowDownLeft,
transfer_out: ArrowUpRight,
}
const isIncoming = (kind: BankingTransactionKind): boolean =>
kind === 'deposit' || kind === 'transfer_in'
const chart = computed(() => {
const days = Array.from({ length: 7 }, (_, offset) => {
const date = new Date()
date.setHours(0, 0, 0, 0)
date.setDate(date.getDate() - (6 - offset))
return { date, incoming: 0, outgoing: 0 }
})
for (const transaction of banking.overview?.transactions ?? []) {
const transactionDate = new Date(transaction.createdAt)
transactionDate.setHours(0, 0, 0, 0)
const day = days.find(
(candidate) => candidate.date.getTime() === transactionDate.getTime(),
)
if (!day) continue
if (isIncoming(transaction.kind)) day.incoming += transaction.amount
else day.outgoing += transaction.amount
}
const maximum = Math.max(
1,
...days.flatMap((day) => [day.incoming, day.outgoing]),
)
return days.map((day) => ({
...day,
incomingHeight: Math.max(5, (day.incoming / maximum) * 100),
label: new Intl.DateTimeFormat(phone.lang, { weekday: 'narrow' }).format(
day.date,
),
outgoingHeight: Math.max(5, (day.outgoing / maximum) * 100),
}))
})
const totals = computed(() =>
(banking.overview?.transactions ?? []).reduce(
(result, transaction) => {
if (isIncoming(transaction.kind)) result.incoming += transaction.amount
else result.outgoing += transaction.amount
return result
},
{ incoming: 0, outgoing: 0 },
),
)
function formatMoney(value: number, signed = false): string {
const formatted = new Intl.NumberFormat(phone.lang, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
}).format(Math.abs(value))
const prefix = signed ? (value >= 0 ? '+' : '') : ''
return `${prefix}${banking.overview?.currency ?? '$'}${formatted}`
}
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
month: 'short',
}).format(timestamp)
}
function transactionTitle(transaction: BankingTransaction): string {
if (transaction.label) return transaction.label
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
}
function openAction(nextAction: BankingAction): void {
action.value = nextAction
amount.value = ''
target.value = ''
formError.value = ''
}
function closeAction(): void {
if (banking.isLoading) return
action.value = null
}
function updateTarget(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) {
console.error('[banking] Player ID input emitted without an input target.')
return
}
target.value = event.target.value
formError.value = ''
}
function updateAmount(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) {
console.error('[banking] Amount input emitted without an input target.')
return
}
amount.value = event.target.value
formError.value = ''
}
async function refresh(): Promise<void> {
if (isRefreshing.value) return
isRefreshing.value = true
pullDistance.value = pullThreshold
await banking.load()
isRefreshing.value = false
pullDistance.value = 0
}
function atTop(): boolean {
return (bankingScroll.value?.scrollTop ?? 0) <= 0
}
function startPull(event: TouchEvent): void {
if (!atTop() || isRefreshing.value) return
pullStartY = event.touches[0]?.clientY ?? 0
isPulling = true
}
function movePull(event: TouchEvent): void {
if (!isPulling || isRefreshing.value) return
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
if (distance <= 0) {
pullDistance.value = 0
return
}
pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45)
}
function finishPull(): void {
if (!isPulling && pullDistance.value === 0) return
isPulling = false
if (pullDistance.value >= pullThreshold) {
void refresh()
return
}
pullDistance.value = 0
}
function pullWithWheel(event: WheelEvent): void {
if (!atTop() || isRefreshing.value || event.deltaY >= 0) return
pullDistance.value = Math.min(
pullThreshold + 20,
pullDistance.value + Math.abs(event.deltaY) * 0.18,
)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
wheelRefreshTimeout = setTimeout(finishPull, 130)
}
function focusableSheetElements(): HTMLElement[] {
const sheet = document.querySelector<HTMLElement>('.banking-sheet__content')
if (!sheet) return []
return Array.from(
sheet.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
),
)
}
function handleSheetKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
closeAction()
return
}
if (event.key !== 'Tab') return
const focusable = focusableSheetElements()
if (!focusable.length) {
event.preventDefault()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
function errorMessage(code: string): string {
return phone.t(`Apps.banking.errors.${code}`) ===
`Apps.banking.errors.${code}`
? phone.t('Apps.banking.errors.default')
: phone.t(`Apps.banking.errors.${code}`)
}
async function submitAction(): Promise<void> {
if (!action.value) return
const parsedAmount = Number(amount.value)
const parsedTarget = action.value === 'transfer' ? Number(target.value) : undefined
if (
!Number.isSafeInteger(parsedAmount) ||
parsedAmount <= 0 ||
(action.value === 'transfer' &&
(!Number.isSafeInteger(parsedTarget) || (parsedTarget ?? 0) <= 0))
) {
formError.value = phone.t('Apps.banking.errors.invalid_request')
return
}
const response = await banking.perform(action.value, parsedAmount, parsedTarget)
if (!response.success) {
formError.value = errorMessage(response.error ?? 'default')
return
}
action.value = null
}
onMounted(() => void banking.load())
watch(action, async (currentAction) => {
if (currentAction) {
previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null
await nextTick()
document.getElementById('banking-transfer-target')?.focus()
return
}
previousFocus?.focus()
previousFocus = null
})
onBeforeUnmount(() => {
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
previousFocus?.focus()
})
</script>
<template>
<k-page
component="main"
class="banking-app pb-safe-24"
:colors="{ bgIos: 'bg-transparent' }"
>
<div class="banking-app__aurora" aria-hidden="true"></div>
<k-navbar
class="banking-navbar"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:subtitle="phone.t('Apps.banking.welcome')"
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
/>
<div
v-if="!banking.overview && banking.isLoading"
class="banking-loading"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
>
<k-preloader />
<span>{{ phone.t('Common.loading') }}</span>
</div>
<div
v-else-if="!banking.overview"
class="banking-empty"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
>
<Landmark :size="34" />
<strong>{{ phone.t('Apps.banking.unavailable') }}</strong>
<p>{{ errorMessage(banking.error) }}</p>
<k-button rounded @click="banking.load()">
{{ phone.t('Apps.banking.tryAgain') }}
</k-button>
</div>
<div
v-else
ref="bankingScroll"
class="banking-scroll"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
@touchend="finishPull"
@touchmove.passive="movePull"
@touchstart.passive="startPull"
@wheel="pullWithWheel"
>
<div
class="banking-pull-refresh"
:class="{ 'is-visible': pullDistance > 0 }"
:style="{ transform: `translateY(${pullDistance - pullThreshold}px)` }"
aria-live="polite"
>
<k-preloader />
</div>
<template v-if="activeTab === 'home'">
<k-glass class="banking-balance">
<div class="banking-balance__label">
<span>{{ phone.t('Apps.banking.totalBalance') }}</span>
<small>#{{ banking.overview.playerId }}</small>
</div>
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
<div class="banking-balance__trend">
<span>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</span>
{{ phone.t('Apps.banking.recentPeriod') }}
</div>
</k-glass>
<section class="banking-actions" :aria-label="phone.t('Apps.banking.actions')">
<k-glass
component="button"
type="button"
class="banking-action banking-action--primary"
@click="openAction('transfer')"
>
<span class="banking-action__icon"><Send :size="20" /></span>
<b>{{ phone.t('Apps.banking.send') }}</b>
<ChevronRight :size="16" aria-hidden="true" />
</k-glass>
</section>
<k-card class="banking-card banking-accounts">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.accounts') }}</h2>
</div>
<k-list inset strong class="banking-account-list">
<k-list-item
:title="phone.t('Apps.banking.bankAccount')"
:after="formatMoney(banking.overview.bank)"
>
<template #media><WalletCards :size="18" /></template>
</k-list-item>
<k-list-item
:title="phone.t('Apps.banking.cash')"
:after="formatMoney(banking.overview.cash)"
>
<template #media><CircleDollarSign :size="18" /></template>
</k-list-item>
</k-list>
</k-card>
<section class="banking-transactions">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.latestTransactions') }}</h2>
<k-link
component="button"
:link-props="{ type: 'button' }"
@click="activeTab = 'activity'"
>
{{ phone.t('Apps.banking.viewAll') }}
</k-link>
</div>
<k-card
v-if="banking.overview.transactions.length"
:content-wrap="false"
class="banking-card banking-transaction-card"
>
<k-list inset strong class="banking-transaction-list">
<k-list-item
v-for="transaction in banking.overview.transactions.slice(0, 5)"
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
>
<template #media>
<component :is="transactionIcons[transaction.kind]" :size="17" />
</template>
<template #after>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
</b>
</template>
</k-list-item>
</k-list>
</k-card>
<p v-else class="banking-no-transactions">
{{ phone.t('Apps.banking.noTransactions') }}
</p>
</section>
</template>
<template v-else>
<section class="banking-activity-hero">
<span>{{ phone.t('Apps.banking.activity') }}</span>
<strong>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</strong>
<small>{{ phone.t('Apps.banking.recentPeriod') }}</small>
</section>
<k-card :content-wrap="false" class="banking-card banking-chart-card">
<div class="banking-chart-legend">
<span><i class="is-incoming"></i>{{ phone.t('Apps.banking.incoming') }}</span>
<span><i></i>{{ phone.t('Apps.banking.outgoing') }}</span>
</div>
<div class="banking-chart">
<div
v-for="day in chart"
:key="day.date.getTime()"
class="banking-chart__day"
role="img"
:aria-label="phone.t('Apps.banking.chartDaySummary', {
day: day.label,
incoming: formatMoney(day.incoming),
outgoing: formatMoney(day.outgoing),
})"
>
<div>
<i class="is-incoming" :style="{ height: `${day.incomingHeight}%` }"></i>
<i :style="{ height: `${day.outgoingHeight}%` }"></i>
</div>
<span>{{ day.label }}</span>
</div>
</div>
</k-card>
<section class="banking-transactions banking-transactions--all">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.allTransactions') }}</h2>
</div>
<k-card :content-wrap="false" class="banking-card banking-transaction-card">
<k-list inset strong class="banking-transaction-list">
<k-list-item
v-for="transaction in banking.overview.transactions"
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
>
<template #media>
<component :is="transactionIcons[transaction.kind]" :size="17" />
</template>
<template #after>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
</b>
</template>
</k-list-item>
</k-list>
</k-card>
</section>
</template>
</div>
<k-tabbar
v-if="banking.overview"
component="nav"
icons
labels
class="bottom-0 left-0 fixed"
:aria-hidden="Boolean(action)"
:aria-label="phone.t('Apps.banking.navigation')"
:inert="Boolean(action)"
>
<k-toolbar-pane>
<k-tabbar-link
component="button"
:active="activeTab === 'home'"
:link-props="{ type: 'button' }"
@click="activeTab = 'home'"
>
<template #label>{{ phone.t('Apps.banking.home') }}</template>
<template #icon>
<k-icon><House class="w-7 h-7" /></k-icon>
</template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:active="activeTab === 'activity'"
:link-props="{ type: 'button' }"
@click="activeTab = 'activity'"
>
<template #label>{{ phone.t('Apps.banking.activity') }}</template>
<template #icon>
<k-icon><BarChart3 class="w-7 h-7" /></k-icon>
</template>
</k-tabbar-link>
</k-toolbar-pane>
</k-tabbar>
<k-sheet :opened="Boolean(action)" class="banking-sheet" @backdropclick="closeAction">
<section
v-if="action"
class="banking-sheet__content"
role="dialog"
aria-modal="true"
:aria-labelledby="`banking-${action}-title`"
@keydown="handleSheetKeydown"
>
<k-link
component="button"
class="banking-modal__close"
:aria-label="phone.t('Common.close')"
:link-props="{ type: 'button' }"
@click="closeAction"
>
<X :size="17" />
</k-link>
<span class="banking-modal__icon">
<Send :size="23" />
</span>
<h2 :id="`banking-${action}-title`">
{{ phone.t(`Apps.banking.forms.${action}.title`) }}
</h2>
<p>{{ phone.t(`Apps.banking.forms.${action}.body`) }}</p>
<k-list inset strong class="banking-form-list">
<k-list-input
:label="phone.t('Apps.banking.playerId')"
input-id="banking-transfer-target"
inputmode="numeric"
min="1"
outline
:placeholder="phone.t('Apps.banking.playerIdPlaceholder')"
type="number"
:value="target"
@input="updateTarget"
/>
<k-list-input
:label="phone.t('Apps.banking.amount')"
:error="formError || undefined"
input-id="banking-transfer-amount"
inputmode="numeric"
min="1"
outline
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
type="number"
:value="amount"
@input="updateAmount"
@keydown.enter="submitAction"
/>
</k-list>
<p v-if="formError" class="banking-form-error" role="alert">
{{ formError }}
</p>
<k-button
large
rounded
:disabled="banking.isLoading"
@click="submitAction"
>
<k-preloader v-if="banking.isLoading" />
<template v-else>
{{ phone.t(`Apps.banking.forms.${action}.submit`) }}
<ArrowRight :size="17" />
</template>
</k-button>
</section>
</k-sheet>
</k-page>
</template>
+2 -2
View File
@@ -315,8 +315,8 @@ function onMessage(event: MessageEvent): void {
latestMedia.value = result.media
updateCapture(result.correlationId, { status: 'success' })
if (requestedMessageMedia.value === result.media.mediaType) {
messageMedia.complete(result.media)
void router.replace('/apps/messages')
const returnPath = messageMedia.complete(result.media)
if (returnPath) void router.replace(returnPath)
return
}
showCameraNotice(phone.t('Apps.camera.saved'))
+822
View File
@@ -0,0 +1,822 @@
<script setup lang="ts">
import {
ArrowUpCircle,
Bell,
BellOff,
Camera,
Check,
ChevronLeft,
ChevronRight,
Clock3,
Copy,
ImagePlay,
Images,
LockKeyhole,
MessageCirclePlus,
Mic,
Plus,
QrCode,
Reply,
Search,
ShieldCheck,
ShieldOff,
Trash2,
UserMinus,
UserPlus,
Video,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
import DarkChatSelect, {
type DarkChatSelectOption,
} from '@/components/DarkChatSelect.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import { useAccountStore } from '@/stores/account'
import { useDarkChatStore } from '@/stores/darkchat'
import { useMessagesStore } from '@/stores/messages'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { DarkChatConversationSummary, DarkChatMessage, DarkChatNotificationMode } from '@/types/darkchat'
import type { GifSearchResult } from '@/types/messages'
import type { MediaType, PhoneMedia } from '@/types/media'
import { copyText } from '@/utils/clipboard'
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 messageMedia = useMessageMediaStore()
const phone = usePhoneStore()
const router = useRouter()
const screen = ref<'inbox' | 'new' | 'thread' | 'contact' | 'profile'>('inbox')
const search = ref('')
const identifier = ref('')
const pendingIdentifier = ref('')
const safetyOpen = ref(false)
const draft = ref('')
const 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 === 'image') return `📷 ${t('photo')}`
if (conversation.lastMessageType === 'video') return `${t('video')}`
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')
}
const disappearingOptions = computed<DarkChatSelectOption[]>(() =>
timerOptions.map((value) => ({ label: timerLabel(value), value })),
)
const notificationOptions = computed<DarkChatSelectOption[]>(() => [
{ label: t('notificationFull'), value: 'full' },
{ label: t('notificationPrivate'), value: 'private' },
{ label: t('notificationHidden'), value: 'hidden' },
])
const reportOptions = computed<DarkChatSelectOption[]>(() => [
{ label: t('reportSpam'), value: 'spam' },
{ label: t('reportHarassment'), value: 'harassment' },
{ label: t('reportThreats'), value: 'threats' },
{ label: t('reportIllegal'), value: 'illegal' },
{ label: t('reportOther'), value: 'other' },
])
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()
}
function openMediaApp(app: 'camera' | 'photos', mediaType: MediaType): void {
if (!active.value) {
console.error('[DarkChat] Cannot attach media without an active conversation.')
return
}
attachmentOpen.value = false
emojiOpen.value = false
gifOpen.value = false
messageMedia.begin(`darkchat:${active.value.id}`, mediaType, '/apps/darkchat')
void router.push({
path: `/apps/${app}`,
query: { messageAttachment: mediaType },
})
}
async function sendAttachment(media: PhoneMedia): Promise<void> {
if (!active.value || sending.value) return
sending.value = true
const response = await darkchat.send({
mediaAssetId: import.meta.env.DEV ? media.url : String(media.id),
mediaPreviewUrl: media.url,
messageType: media.mediaType === 'photo' ? 'image' : 'video',
})
sending.value = false
if (!response.success) showToast(errorText(response.error))
await scrollBottom()
}
function copyMessage(message: DarkChatMessage): void {
selectedMessage.value = null
showToast(copyText(message.body) ? t('copied') : errorText())
}
async function 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'
}
}
function selectDisappearing(value: number | string): void {
if (!active.value) {
console.error('[DarkChat] Cannot update the timer without an active conversation.')
return
}
active.value.disappearingSeconds = Number(value)
void updateConversation()
}
function selectNotificationMode(value: number | string): void {
notificationMode.value = value as DarkChatNotificationMode
}
function selectReportReason(value: number | string): void {
reportReason.value = String(value)
}
function copyIdentity(value: string): void {
showToast(copyText(value) ? t('copied') : errorText())
}
function recordingMime(): string | null {
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) return 'audio/webm;codecs=opus'
if (MediaRecorder.isTypeSupported('audio/webm')) return 'audio/webm'
return null
}
function sampleMicrophone(): void {
if (!analyser) return
const values = new Uint8Array(analyser.fftSize)
analyser.getByteTimeDomainData(values)
let total = 0
for (const value of values) total += Math.abs(value - 128) / 128
const level = Math.max(0.08, Math.min(1, (total / values.length) * 4.5))
recordingSamples.push(level)
recordingLevels.value = [...recordingLevels.value.slice(1), level]
recordingElapsedMs.value = performance.now() - recordingStartedAt
if (recordingElapsedMs.value >= VOICE_MAX_DURATION_MS) stopRecording()
}
async function startRecording(): Promise<void> {
resetPanels()
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
showToast(t('microphoneUnavailable'))
return
}
const mime = recordingMime()
if (!mime) {
showToast(t('microphoneUnavailable'))
return
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { autoGainControl: true, echoCancellation: true, noiseSuppression: true },
})
recordingChunks = []
recordingSamples = []
recordingBytes = 0
discardRecording = false
mediaRecorder = new MediaRecorder(mediaStream, { audioBitsPerSecond: 32_000, mimeType: mime })
mediaRecorder.addEventListener('dataavailable', (event) => {
if (!event.data.size) return
recordingChunks.push(event.data)
recordingBytes += event.data.size
if (recordingBytes > VOICE_MAX_BYTES) stopRecording()
})
mediaRecorder.addEventListener('stop', () => void finishRecording())
audioContext = new AudioContext()
analyser = audioContext.createAnalyser()
analyser.fftSize = 128
audioContext.createMediaStreamSource(mediaStream).connect(analyser)
recordingStartedAt = performance.now()
recordingElapsedMs.value = 0
recordingLevels.value = Array(32).fill(0.16)
recording.value = true
mediaRecorder.start(250)
recordingTimer = setInterval(sampleMicrophone, 100)
}
catch (error) {
console.error('[DarkChat] Could not start audio recording:', error)
cleanupRecording()
showToast(t('microphoneUnavailable'))
}
}
function stopRecording(): void {
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
}
function cancelRecording(): void {
discardRecording = true
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
else cleanupRecording()
}
function cleanupRecording(): void {
if (recordingTimer) clearInterval(recordingTimer)
recordingTimer = undefined
mediaStream?.getTracks().forEach((track) => track.stop())
void audioContext?.close()
mediaRecorder = undefined
mediaStream = undefined
audioContext = undefined
analyser = undefined
recording.value = false
}
function waveform(): number[] {
if (!recordingSamples.length) return Array(16).fill(0.12)
const result: number[] = []
const count = Math.min(WAVEFORM_SAMPLES, recordingSamples.length)
const bucketSize = recordingSamples.length / count
for (let index = 0; index < count; index += 1) {
const bucket = recordingSamples.slice(Math.floor(index * bucketSize), Math.max(1, Math.floor((index + 1) * bucketSize)))
result.push(Math.max(0.08, Math.min(1, bucket.reduce((sum, value) => sum + value, 0) / bucket.length)))
}
return result
}
async function blobBase64(blob: Blob): Promise<string> {
const bytes = new Uint8Array(await blob.arrayBuffer())
let binary = ''
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
}
return btoa(binary)
}
async function finishRecording(): Promise<void> {
const duration = Math.min(VOICE_MAX_DURATION_MS, Math.max(300, performance.now() - recordingStartedAt))
const mime = mediaRecorder?.mimeType ?? 'audio/webm'
const chunks = recordingChunks
const levels = waveform()
const discard = discardRecording
cleanupRecording()
if (discard) return
const blob = new Blob(chunks, { type: mime })
if (!blob.size || blob.size > VOICE_MAX_BYTES) {
showToast(t('recordingTooLarge'))
return
}
sending.value = true
const response = await darkchat.send({
mediaDurationMs: Math.floor(duration),
mediaMime: mime,
mediaPayload: await blobBase64(blob),
mediaWaveform: levels,
messageType: 'voice',
replyToId: replyTo.value?.id,
})
sending.value = false
replyTo.value = null
if (!response.success) showToast(errorText(response.error))
await scrollBottom()
}
function recordingTime(): string {
const seconds = Math.floor(recordingElapsedMs.value / 1000)
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
onMounted(async () => {
if (signedIn.value) await darkchat.bootstrap()
if (!active.value) return
screen.value = 'thread'
const media = messageMedia.consume(`darkchat:${active.value.id}`)
if (media) await sendAttachment(media)
})
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 darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></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 darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></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' || message.messageType === 'image'" :src="message.mediaPayload || undefined" :alt="message.messageType === 'gif' ? 'GIF' : t('photo')" />
<video v-else-if="message.messageType === 'video'" :src="message.mediaPayload || undefined" controls playsinline preload="metadata" />
<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="openMediaApp('photos', 'photo')"><span><Images :size="18" /></span>{{ t('attachPhoto') }}</button>
<button type="button" @click="openMediaApp('camera', 'photo')"><span><Camera :size="18" /></span>{{ t('takePhoto') }}</button>
<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="18" /></span>{{ t('attachGif') }}</button>
<button type="button" @click="openMediaApp('photos', 'video')"><span><Video :size="18" /></span>{{ t('attachVideo') }}</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 darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></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>
<div class="darkchat-select"><span><Clock3 :size="17" />{{ t('disappearing') }}</span><DarkChatSelect :model-value="active.disappearingSeconds" :options="disappearingOptions" :label="t('disappearing')" @update:model-value="selectDisappearing" /></div>
</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 darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></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">
<div class="darkchat-select"><span><Bell :size="17" />{{ t('notificationPrivacy') }}</span><DarkChatSelect :model-value="notificationMode" :options="notificationOptions" :label="t('notificationPrivacy')" @update:model-value="selectNotificationMode" /></div>
<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><DarkChatSelect class="darkchat-report-select" :model-value="reportReason" :options="reportOptions" :label="t('reportUser')" @update:model-value="selectReportReason" /><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>
+3 -4
View File
@@ -203,8 +203,8 @@ function observeMore(): void {
function openMedia(entry: PhoneMedia): void {
if (requestedMessageMedia.value) {
messageMedia.complete(entry)
void router.replace('/apps/messages')
const returnPath = messageMedia.complete(entry)
if (returnPath) void router.replace(returnPath)
return
}
landscapeViewer.value = false
@@ -215,8 +215,7 @@ function openMedia(entry: PhoneMedia): void {
}
function cancelMessageSelection(): void {
messageMedia.cancel()
void router.replace('/apps/messages')
void router.replace(messageMedia.cancel())
}
function closeMedia(): void {
+1 -5
View File
@@ -44,7 +44,6 @@ import {
import { PHONE_FRAME_COLORS } from '@/config/appearance'
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
import { IFRUIT_AUTH_INPUT_COLORS } from '@/config/ifruit'
import { usePhoneStore } from '@/stores/phone'
import { useAccountStore } from '@/stores/account'
import type {
@@ -597,10 +596,9 @@ onBeforeUnmount(() => {
)
}}
</k-block-title>
<k-list class="bg-black">
<k-list>
<k-list-input
class="relative"
:colors="IFRUIT_AUTH_INPUT_COLORS"
:value="accountEmail"
:label="
phone.t(
@@ -632,7 +630,6 @@ onBeforeUnmount(() => {
</k-list-input>
<k-list-input
type="password"
:colors="IFRUIT_AUTH_INPUT_COLORS"
:value="accountPassword"
:label="phone.t('Apps.mail.password')"
outline
@@ -645,7 +642,6 @@ onBeforeUnmount(() => {
<k-list-input
v-if="accountMode === 'register'"
type="password"
:colors="IFRUIT_AUTH_INPUT_COLORS"
:value="accountConfirm"
:label="phone.t('Apps.mail.confirmPassword')"
outline
+263
View File
@@ -44,6 +44,18 @@ const radioData = {
settings: { autoRejoin: false, notifications: true },
volume: 50,
}
let mockBankBalance = 24787
let mockCashBalance = 2350
let nextBankTransactionId = 7
const mockBankTransactions = [
{ id: 1, kind: 'transfer_in', amount: 3200, label: 'Sofia Turner', reference: 'mock-1', createdAt: Date.now() - 45 * 60 * 1000 },
{ id: 2, kind: 'transfer_out', amount: 680, label: 'Vincent Cole', reference: 'mock-2', createdAt: Date.now() - 5 * 60 * 60 * 1000 },
{ id: 3, kind: 'deposit', amount: 1250, label: '', reference: 'mock-3', createdAt: Date.now() - 25 * 60 * 60 * 1000 },
{ id: 4, kind: 'transfer_out', amount: 420, label: 'Maya Brooks', reference: 'mock-4', createdAt: Date.now() - 50 * 60 * 60 * 1000 },
{ id: 5, kind: 'withdrawal', amount: 300, label: '', reference: 'mock-5', createdAt: Date.now() - 76 * 60 * 60 * 1000 },
{ id: 6, kind: 'transfer_in', amount: 950, label: 'Noah Bennett', reference: 'mock-6', createdAt: Date.now() - 120 * 60 * 60 * 1000 },
]
let contactSequence = 2
const contacts = [
{
@@ -178,6 +190,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',
@@ -899,6 +974,41 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
const bankingOverview = () => ({
bank: mockBankBalance,
cash: mockCashBalance,
currency: '$',
playerId: 42,
playerName: 'Alex Morgan',
transactions: mockBankTransactions,
})
if (endpoint === 'banking:overview') {
response.json({ success: true, data: bankingOverview() })
return
}
if (endpoint === 'banking:transfer') {
const amount = Number(request.body.amount)
if (!Number.isSafeInteger(amount) || amount <= 0) {
response.json({ success: false, error: 'invalid_request' })
return
}
if (mockBankBalance < amount) {
response.json({ success: false, error: 'insufficient_funds' })
return
}
const kind = 'transfer_out'
mockBankBalance -= amount
mockBankTransactions.unshift({
amount,
createdAt: Date.now(),
id: nextBankTransactionId++,
kind,
label: kind === 'transfer_out' ? `Player #${request.body.target}` : '',
reference: `mock-${Date.now()}`,
})
response.json({ success: true, data: bankingOverview() })
return
}
if (endpoint === 'weather:get') {
response.json({
success: true,
@@ -919,6 +1029,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()) {
+32 -2
View File
@@ -14,8 +14,8 @@ Config.Bridge = {
Config.Command = "phone"
Config.Phone = {
Item = "sky_phone",
DevelopmentCommand = false,
Item = "phone",
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -97,6 +97,28 @@ Config.Messages = {
DeleteBatchSize = 20,
}
Config.DarkChat = {
AliasMaxLength = 32,
BodyMaxLength = 2000,
ThreadPageSize = 200,
SendsPerMinute = 30,
ActionsPerMinute = 60,
ReportsPerDay = 10,
VoiceMaxDurationMs = 60000,
VoiceMaxBase64Length = 360000,
VoiceWaveformSamples = 48,
CleanupIntervalSeconds = 30,
AllowedDisappearTimers = {
[0] = true,
[-1] = true, -- after reading
[60] = true,
[300] = true,
[3600] = true,
[86400] = true,
[604800] = true,
},
}
Config.Mail = {
Domain = "ifruit.com",
LocalPartMinLength = 3,
@@ -110,6 +132,14 @@ Config.Mail = {
AuthAttemptsPerMinute = 5,
}
Config.Banking = {
Currency = "$",
MinimumAmount = 1,
MaximumAmount = 1000000,
ActionsPerMinute = 12,
HistoryLimit = 50,
}
Config.Marketplace = {
PageSize = 20,
MessagePageSize = 50,
+48 -1
View File
@@ -25,7 +25,7 @@ Locales["en"] = {
},
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", page = "Page", pages = "Home screen pages",
groups = {
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
@@ -39,6 +39,27 @@ Locales["en"] = {
},
},
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", photo = "Photo", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", searchGifs = "Search GIFs", loadMore = "Load More",
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_attachment = "This photo or video is unavailable.", 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}", compose = "New Message",
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
@@ -123,6 +144,32 @@ Locales["en"] = {
request_failed = "The radio request failed.", default = "The radio request failed.",
},
},
banking = {
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
actions = "Banking actions", send = "Send",
accounts = "Accounts", bankAccount = "Bank account", cash = "Cash",
latestTransactions = "Latest Transactions", allTransactions = "All Transactions", viewAll = "View all",
noTransactions = "Your banking activity will appear here.", home = "Home", activity = "Activity",
chartDaySummary = "{day}: {incoming} incoming, {outgoing} outgoing",
navigation = "Banking navigation", incoming = "Incoming", outgoing = "Outgoing",
refresh = "Refresh banking data", unavailable = "Banking unavailable", tryAgain = "Try Again",
playerId = "Player ID", playerIdPlaceholder = "Enter the recipient ID",
amount = "Amount", amountPlaceholder = "Enter an amount",
transactions = {
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
transfer_in = "Incoming transfer", transfer_out = "Outgoing transfer",
},
forms = {
transfer = { title = "Send money", body = "Transfer money from your bank account to an online player.", submit = "Send transfer" },
},
errors = {
invalid_request = "Enter a valid whole amount and player ID.", insufficient_funds = "There is not enough money in this account.",
target_not_found = "The recipient is not online.", self_transfer = "You cannot send money to yourself.",
rate_limited = "Please wait before making another transaction.", transfer_failed = "The transfer could not be completed.",
banking_unavailable = "Banking is currently unavailable.", request_failed = "The banking request failed.",
default = "The banking request failed.",
},
},
calculator = { name = "Calculator" },
snake = {
name = "Snake", backToMenu = "Back to game menu", board = "Snake game board",
+2
View File
@@ -47,8 +47,10 @@ server_scripts {
'source/server/calls.lua',
'source/server/media.lua',
'source/server/messages.lua',
'source/server/darkchat.lua',
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/calendar.lua',
@@ -21,6 +21,46 @@ function Bridge.Framework.GetIdentifier(source)
return player and player.identifier or nil
end
function Bridge.Framework.GetMoney(source, account)
local player = get_player(source)
if not player then
return nil
end
if account == "cash" then
account = "money"
end
local account_data = player.getAccount(account)
return account_data and account_data.money or nil
end
function Bridge.Framework.AddMoney(source, account, amount)
local player = get_player(source)
if not player then
return false
end
if account == "cash" then
account = "money"
end
player.addAccountMoney(account, amount)
return true
end
function Bridge.Framework.RemoveMoney(source, account, amount)
local player = get_player(source)
if not player then
return false
end
if account == "cash" then
account = "money"
end
local account_data = player.getAccount(account)
if not account_data or account_data.money < amount then
return false
end
player.removeAccountMoney(account, amount)
return true
end
function Bridge.Framework.GetFirstname(source)
local player = get_player(source)
return player and player.get("firstName") or nil
@@ -21,6 +21,27 @@ function Bridge.Framework.GetIdentifier(source)
return player and player.PlayerData and tostring(player.PlayerData.citizenid) or nil
end
function Bridge.Framework.GetMoney(source, account)
local player = get_player(source)
return player and player.PlayerData and player.PlayerData.money[account] or nil
end
function Bridge.Framework.AddMoney(source, account, amount)
local player = get_player(source)
if not player then
return false
end
return player.Functions.AddMoney(account, amount)
end
function Bridge.Framework.RemoveMoney(source, account, amount)
local player = get_player(source)
if not player or not player.PlayerData.money[account] or player.PlayerData.money[account] < amount then
return false
end
return player.Functions.RemoveMoney(account, amount)
end
local function get_character_info(source)
local player = get_player(source)
return player and player.PlayerData and player.PlayerData.charinfo or nil
@@ -19,6 +19,18 @@ function Bridge.Framework.GetIdentifier(source)
return player and player.PlayerData and tostring(player.PlayerData.citizenid) or nil
end
function Bridge.Framework.GetMoney(source, account)
return exports.qbx_core:GetMoney(tonumber(source), account)
end
function Bridge.Framework.AddMoney(source, account, amount)
return exports.qbx_core:AddMoney(tonumber(source), account, amount)
end
function Bridge.Framework.RemoveMoney(source, account, amount)
return exports.qbx_core:RemoveMoney(tonumber(source), account, amount)
end
local function get_character_info(source)
local player = get_player(source)
return player and player.PlayerData and player.PlayerData.charinfo or nil
+39
View File
@@ -70,12 +70,28 @@ local server_callbacks = {
"calls:answer",
"calls:decline",
"calls:hangup",
"banking:overview",
"banking:transfer",
"messages:conversations",
"messages:thread",
"messages:send",
"messages:media",
"messages:delete",
"messages:gifs",
"darkchat:bootstrap",
"darkchat:update-profile",
"darkchat:start",
"darkchat:thread",
"darkchat:send",
"darkchat:media",
"darkchat:react",
"darkchat:message-action",
"darkchat:update-conversation",
"darkchat:add-contact",
"darkchat:remove-contact",
"darkchat:block",
"darkchat:report",
"darkchat:clear",
"gallery:list",
"media:config",
}
@@ -370,6 +386,10 @@ RegisterNetEvent("sky_phone:calls:changed", function()
SendNUIMessage({ type = "calls:changed" })
end)
RegisterNetEvent("sky_phone:banking:changed", function()
SendNUIMessage({ type = "banking:changed" })
end)
RegisterNetEvent("sky_phone:messages:changed", function(data)
SendNUIMessage({ type = "messages:changed", data = data })
end)
@@ -381,6 +401,25 @@ RegisterNetEvent("sky_phone:messages:new", function(data)
SendNUIMessage({ type = "messages:new", data = data })
end)
RegisterNetEvent("sky_phone:darkchat:changed", function(data)
SendNUIMessage({ type = "darkchat:changed", data = data })
end)
RegisterNetEvent("sky_phone:darkchat:new", function(data)
local darkchat_locale = get_locale().Nui.Apps.darkchat
data.title = darkchat_locale.name
if data.notificationMode == "private" then
data.sender = nil
data.text = darkchat_locale.privateNotification
elseif data.notificationMode == "hidden" then
data.sender = nil
data.text = ""
else
data.text = darkchat_locale.newMessage:gsub("{sender}", tostring(data.sender))
end
SendNUIMessage({ type = "darkchat:new", data = data })
end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
notification_focus = true
SetNuiFocus(true, true)
+2 -2
View File
@@ -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-6ojC5e0v.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Ch7PsoqS.css">
<script type="module" crossorigin src="./assets/sky-index-F_IMmGnH.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Sk51e7nJ.css">
</head>
<body>
<div id="app"></div>
+135
View File
@@ -0,0 +1,135 @@
Bridge.Database.AfterMigration("sky_phone", function()
local function valid_amount(value)
local amount = tonumber(value)
if not amount or amount ~= math.floor(amount) then
return nil
end
if amount < Config.Banking.MinimumAmount or amount > Config.Banking.MaximumAmount then
return nil
end
return amount
end
local function player_name(source)
local firstname = Bridge.Framework.GetFirstname(source)
local lastname = Bridge.Framework.GetLastname(source)
local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$")
if name == "" then
name = GetPlayerName(source) or ("Player %s"):format(source)
end
return name
end
local function require_banking_session(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, { success = false, error = "banking_unavailable" }
end
return identifier
end
local function transaction_dto(row)
return {
id = tonumber(row.id),
kind = row.kind,
amount = tonumber(row.amount) or 0,
label = row.label or "",
reference = row.reference or "",
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
}
end
local function transactions(identifier)
local rows = Bridge.Database.Query([[
SELECT `id`, `kind`, `amount`, `label`, `reference`,
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_bank_transactions`
WHERE `owner_identifier` = ?
ORDER BY `id` DESC
LIMIT ?
]], { identifier, Config.Banking.HistoryLimit })
local result = {}
for _, row in ipairs(rows) do
result[#result + 1] = transaction_dto(row)
end
return result
end
local function overview(source, identifier)
return {
bank = math.max(0, tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0),
cash = math.max(0, tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0),
currency = Config.Banking.Currency,
playerId = source,
playerName = player_name(source),
transactions = transactions(identifier),
}
end
local function record_transaction(identifier, kind, amount, label, reference)
Bridge.Database.Query([[
INSERT INTO `sky_phone_bank_transactions`
(`owner_identifier`, `kind`, `amount`, `label`, `reference`)
VALUES (?, ?, ?, ?, ?)
]], { identifier, kind, amount, label or "", reference or "" })
end
local function notify_changed(source)
TriggerClientEvent("sky_phone:banking:changed", source)
end
Bridge.Callbacks.Register("sky_phone:banking:overview", function(source)
local identifier, error_response = require_banking_session(source)
if not identifier then
return error_response
end
return { success = true, data = overview(source, identifier) }
end)
Bridge.Callbacks.Register("sky_phone:banking:transfer", function(source, data)
if not SkyPhone.AllowOperation(source, "banking_transaction", Config.Banking.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = require_banking_session(source)
if not identifier then
return error_response
end
local amount = valid_amount(data and data.amount)
local target_value = tonumber(data and data.target)
if not amount
or not target_value
or target_value ~= math.floor(target_value)
or target_value <= 0
or target_value > 65535
then
return { success = false, error = "invalid_request" }
end
local target = math.floor(target_value)
if target == source then
return { success = false, error = "self_transfer" }
end
local target_identifier = Bridge.Framework.GetIdentifier(target)
if type(target_identifier) ~= "string" or target_identifier == "" then
return { success = false, error = "target_not_found" }
end
if not Bridge.Framework.RemoveMoney(source, "bank", amount) then
return { success = false, error = "insufficient_funds" }
end
if not Bridge.Framework.AddMoney(target, "bank", amount) then
Bridge.Framework.AddMoney(source, "bank", amount)
return { success = false, error = "transfer_failed" }
end
local reference = ("phone-transfer-%s-%s"):format(os.time(), source)
record_transaction(identifier, "transfer_out", amount, player_name(target), reference)
record_transaction(target_identifier, "transfer_in", amount, player_name(source), reference)
notify_changed(target)
return { success = true, data = overview(source, identifier) }
end)
end)
+859
View File
@@ -0,0 +1,859 @@
Bridge.Database.AfterMigration("sky_phone", function()
local allowed_voice_mimes = {
["audio/webm"] = true,
["audio/webm;codecs=opus"] = true,
}
local report_reasons = {
spam = true,
harassment = true,
threats = true,
illegal = true,
other = true,
}
local notification_modes = {
full = true,
private = true,
hidden = true,
}
local function allowed_gif_url(value)
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then
return false
end
local host = value:lower():match("^https://([^/:?#]+)")
if not host then
return false
end
for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do
local suffix = "." .. allowed_host
if host == allowed_host or host:sub(-#suffix) == suffix then
return true
end
end
return false
end
local reaction_values = {
["❤️"] = true,
["👍"] = true,
["👎"] = true,
["😂"] = true,
["‼️"] = true,
[""] = true,
}
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate a DarkChat UUID.")
end
return rows[1].id
end
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function profile_payload(row)
return {
id = tonumber(row.id),
darkId = row.dark_id,
inviteCode = row.invite_code,
alias = row.alias,
avatarSeed = tonumber(row.avatar_seed),
notificationMode = row.notification_mode,
activityVisible = row.activity_visible == 1 or row.activity_visible == true,
createdAt = row.created_at,
}
end
local function load_profile(account_id)
local rows = Bridge.Database.Query([[
SELECT `id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`,
`activity_visible`, `created_at`
FROM `sky_phone_darkchat_profiles`
WHERE `account_id` = ? LIMIT 1
]], { account_id })
return rows[1]
end
local function create_profile(account_id)
for _ = 1, 10 do
local entropy = uuid():gsub("%-", ""):upper()
local dark_id = ("dark:%s-%s"):format(entropy:sub(1, 4), entropy:sub(5, 8))
local invite_code = ("DC-%s-%s"):format(entropy:sub(9, 12), entropy:sub(13, 16))
local seed = tonumber(entropy:sub(17, 23), 16) or 1
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_darkchat_profiles`
(`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`)
VALUES (?, ?, ?, ?, ?)
]], { account_id, dark_id, invite_code, "Shadow " .. entropy:sub(1, 4), seed })
if affected_rows(result) > 0 then
return load_profile(account_id)
end
local existing = load_profile(account_id)
if existing then
return existing
end
end
error("[sky_phone] Could not generate a unique DarkChat profile after 10 attempts.")
end
local function require_profile(source)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return nil, nil, error_response
end
local profile = load_profile(account.id) or create_profile(account.id)
return profile, account
end
local function load_membership(profile_id, conversation_id)
local rows = Bridge.Database.Query([[
SELECT mine.`conversation_id`, mine.`notifications_enabled`, mine.`read_receipts`, mine.`cleared_at`,
conversation.`disappearing_seconds`, conversation.`created_at` AS `conversation_created_at`,
peer.`id` AS `peer_id`, peer.`account_id` AS `peer_account_id`, peer.`dark_id` AS `peer_dark_id`,
peer.`alias` AS `peer_alias`, peer.`avatar_seed` AS `peer_avatar_seed`,
peer.`activity_visible` AS `peer_activity_visible`, peer_member.`last_read_at` AS `peer_last_read_at`,
contact.`id` AS `contact_id`, contact.`alias_override`, block_mine.`id` AS `blocked_by_me`,
block_peer.`id` AS `blocked_by_peer`
FROM `sky_phone_darkchat_members` mine
JOIN `sky_phone_darkchat_conversations` conversation ON conversation.`id` = mine.`conversation_id`
JOIN `sky_phone_darkchat_members` peer_member
ON peer_member.`conversation_id` = mine.`conversation_id` AND peer_member.`profile_id` <> mine.`profile_id`
JOIN `sky_phone_darkchat_profiles` peer ON peer.`id` = peer_member.`profile_id`
LEFT JOIN `sky_phone_darkchat_contacts` contact
ON contact.`profile_id` = mine.`profile_id` AND contact.`contact_profile_id` = peer.`id`
LEFT JOIN `sky_phone_darkchat_blocks` block_mine
ON block_mine.`blocker_profile_id` = mine.`profile_id` AND block_mine.`blocked_profile_id` = peer.`id`
LEFT JOIN `sky_phone_darkchat_blocks` block_peer
ON block_peer.`blocker_profile_id` = peer.`id` AND block_peer.`blocked_profile_id` = mine.`profile_id`
WHERE mine.`profile_id` = ? AND mine.`conversation_id` = ?
LIMIT 1
]], { profile_id, conversation_id })
return rows[1]
end
local function peer_payload(row)
return {
id = tonumber(row.peer_id),
darkId = row.peer_dark_id,
alias = row.alias_override or row.peer_alias,
originalAlias = row.peer_alias,
avatarSeed = tonumber(row.peer_avatar_seed),
activityVisible = row.peer_activity_visible == 1 or row.peer_activity_visible == true,
isContact = row.contact_id ~= nil,
blocked = row.blocked_by_me ~= nil,
}
end
local function decode_array(value, context)
if value == nil then
return {}
end
local decoded = json.decode(value)
if type(decoded) ~= "table" then
error(("[sky_phone] Invalid DarkChat JSON in %s."):format(context))
end
return decoded
end
local function message_payload(row, profile_id)
local deleted_for = decode_array(row.deleted_for_profiles, "deleted_for_profiles")
for _, deleted_profile_id in ipairs(deleted_for) do
if tonumber(deleted_profile_id) == tonumber(profile_id) then
return nil
end
end
local deleted_everywhere = row.deleted_for_everyone == 1 or row.deleted_for_everyone == true
local reactions = decode_array(row.reactions, "reactions")
local waveform = row.media_waveform and decode_array(row.media_waveform, "media_waveform") or nil
return {
id = row.id,
conversationId = row.conversation_id,
direction = tonumber(row.sender_profile_id) == tonumber(profile_id) and "sent" or "received",
senderProfileId = tonumber(row.sender_profile_id),
messageType = deleted_everywhere and "system" or row.message_type,
body = deleted_everywhere and "message_deleted" or row.body,
mediaMime = deleted_everywhere and nil or row.media_mime,
mediaPayload = not deleted_everywhere and row.message_type ~= "voice" and row.media_payload or nil,
mediaDurationMs = deleted_everywhere and nil or tonumber(row.media_duration_ms),
mediaWaveform = deleted_everywhere and nil or waveform,
replyToId = row.reply_to_id,
replyBody = row.reply_body,
reactions = reactions,
expiresAt = row.expires_at,
createdAt = row.created_at,
readAt = row.peer_last_read_at and row.peer_last_read_at >= row.created_at and row.peer_last_read_at or nil,
deletedForEveryone = deleted_everywhere,
}
end
local function list_contacts(profile_id)
local rows = Bridge.Database.Query([[
SELECT target.`id`, target.`dark_id`, target.`alias`, target.`avatar_seed`, contact.`alias_override`,
contact.`created_at`
FROM `sky_phone_darkchat_contacts` contact
JOIN `sky_phone_darkchat_profiles` target ON target.`id` = contact.`contact_profile_id`
WHERE contact.`profile_id` = ?
ORDER BY COALESCE(contact.`alias_override`, target.`alias`) ASC
]], { profile_id })
local contacts = {}
for index, row in ipairs(rows) do
contacts[index] = {
id = tonumber(row.id),
darkId = row.dark_id,
alias = row.alias_override or row.alias,
originalAlias = row.alias,
avatarSeed = tonumber(row.avatar_seed),
createdAt = row.created_at,
}
end
return contacts
end
local function list_conversations(profile_id)
local rows = Bridge.Database.Query([[
SELECT conversation.`id`, conversation.`disappearing_seconds`, conversation.`updated_at`,
peer.`id` AS `peer_id`, peer.`dark_id` AS `peer_dark_id`, peer.`alias` AS `peer_alias`,
peer.`avatar_seed` AS `peer_avatar_seed`, peer.`activity_visible`, contact.`alias_override`,
block_mine.`id` AS `blocked_by_me`, latest.`body` AS `last_body`, latest.`message_type` AS `last_type`,
latest.`created_at` AS `last_at`, latest.`deleted_for_everyone`,
(SELECT COUNT(*) FROM `sky_phone_darkchat_messages` unread
WHERE unread.`conversation_id` = conversation.`id`
AND unread.`sender_profile_id` <> mine.`profile_id`
AND unread.`created_at` > COALESCE(mine.`last_read_at`, '1970-01-01')
AND (unread.`expires_at` IS NULL OR unread.`expires_at` > CURRENT_TIMESTAMP)) AS `unread`
FROM `sky_phone_darkchat_members` mine
JOIN `sky_phone_darkchat_conversations` conversation ON conversation.`id` = mine.`conversation_id`
JOIN `sky_phone_darkchat_members` peer_member
ON peer_member.`conversation_id` = conversation.`id` AND peer_member.`profile_id` <> mine.`profile_id`
JOIN `sky_phone_darkchat_profiles` peer ON peer.`id` = peer_member.`profile_id`
LEFT JOIN `sky_phone_darkchat_contacts` contact
ON contact.`profile_id` = mine.`profile_id` AND contact.`contact_profile_id` = peer.`id`
LEFT JOIN `sky_phone_darkchat_blocks` block_mine
ON block_mine.`blocker_profile_id` = mine.`profile_id` AND block_mine.`blocked_profile_id` = peer.`id`
LEFT JOIN `sky_phone_darkchat_messages` latest ON latest.`id` = (
SELECT message.`id` FROM `sky_phone_darkchat_messages` message
WHERE message.`conversation_id` = conversation.`id`
AND message.`created_at` >= COALESCE(mine.`cleared_at`, '1970-01-01')
AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP)
ORDER BY message.`created_at` DESC, message.`id` DESC LIMIT 1
)
WHERE mine.`profile_id` = ?
ORDER BY COALESCE(latest.`created_at`, conversation.`updated_at`) DESC
]], { profile_id })
local conversations = {}
for index, row in ipairs(rows) do
conversations[index] = {
id = row.id,
peer = {
id = tonumber(row.peer_id),
darkId = row.peer_dark_id,
alias = row.alias_override or row.peer_alias,
avatarSeed = tonumber(row.peer_avatar_seed),
activityVisible = row.activity_visible == 1 or row.activity_visible == true,
},
disappearingSeconds = tonumber(row.disappearing_seconds),
blocked = row.blocked_by_me ~= nil,
lastMessage = row.deleted_for_everyone == 1 and "message_deleted" or row.last_body or "",
lastMessageType = row.deleted_for_everyone == 1 and "system" or row.last_type or "system",
lastMessageAt = row.last_at or row.updated_at,
unread = tonumber(row.unread) or 0,
}
end
return conversations
end
local function notify_profile(profile_id, event_name, data)
local rows = Bridge.Database.Query([[
SELECT `account_id`, `notification_mode` FROM `sky_phone_darkchat_profiles`
WHERE `id` = ? LIMIT 1
]], { profile_id })
local target = rows[1]
if not target then
return
end
if event_name == "sky_phone:darkchat:new" and data.conversationId then
local members = Bridge.Database.Query([[
SELECT `notifications_enabled` FROM `sky_phone_darkchat_members`
WHERE `profile_id` = ? AND `conversation_id` = ? LIMIT 1
]], { profile_id, data.conversationId })
if not members[1] or members[1].notifications_enabled == 0 then
return
end
end
data.notificationMode = target.notification_mode
SkyPhone.NotifyAccountDevices(target.account_id, event_name, data)
end
local function valid_voice(data)
if type(data.mediaPayload) ~= "string" or #data.mediaPayload == 0
or #data.mediaPayload > Config.DarkChat.VoiceMaxBase64Length
or data.mediaPayload:find("[^A-Za-z0-9+/=]") then
return nil
end
if type(data.mediaMime) ~= "string" or not allowed_voice_mimes[data.mediaMime] then
return nil
end
local duration = tonumber(data.mediaDurationMs)
if not duration or duration < 300 or duration > Config.DarkChat.VoiceMaxDurationMs then
return nil
end
if type(data.mediaWaveform) ~= "table" or #data.mediaWaveform < 8
or #data.mediaWaveform > Config.DarkChat.VoiceWaveformSamples then
return nil
end
local waveform = {}
for index, value in ipairs(data.mediaWaveform) do
local sample = tonumber(value)
if not sample or sample < 0 or sample > 1 then
return nil
end
waveform[index] = math.floor(sample * 1000 + 0.5) / 1000
end
return {
duration = math.floor(duration),
mime = data.mediaMime,
payload = data.mediaPayload,
waveform = json.encode(waveform),
}
end
Bridge.Callbacks.Register("sky_phone:darkchat:bootstrap", function(source)
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
return {
success = true,
data = {
profile = profile_payload(profile),
contacts = list_contacts(profile.id),
conversations = list_conversations(profile.id),
},
}
end)
Bridge.Callbacks.Register("sky_phone:darkchat:update-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_profile", 10, 60) then
return { success = false, error = "rate_limited" }
end
local profile, account, error_response = require_profile(source)
if not profile then
return error_response
end
local alias = trim(data and data.alias)
local mode = data and data.notificationMode
if not alias or alias == "" or #alias > Config.DarkChat.AliasMaxLength
or not notification_modes[mode] or type(data.activityVisible) ~= "boolean" then
return { success = false, error = "invalid_profile" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_darkchat_profiles`
SET `alias` = ?, `notification_mode` = ?, `activity_visible` = ?
WHERE `id` = ?
]], { alias, mode, data.activityVisible and 1 or 0, profile.id })
return { success = true, data = profile_payload(load_profile(account.id)) }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:start", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_start", 20, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local identifier = trim(data and data.identifier)
if not identifier or #identifier > 32 then
return { success = false, error = "invalid_dark_id" }
end
local targets = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_darkchat_profiles`
WHERE UPPER(`dark_id`) = UPPER(?) OR UPPER(`invite_code`) = UPPER(?)
LIMIT 1
]], { identifier, identifier })
local target = targets[1]
if not target then
return { success = false, error = "profile_not_found" }
end
if tonumber(target.id) == tonumber(profile.id) then
return { success = false, error = "self_chat" }
end
local existing = Bridge.Database.Query([[
SELECT mine.`conversation_id`
FROM `sky_phone_darkchat_members` mine
JOIN `sky_phone_darkchat_members` peer ON peer.`conversation_id` = mine.`conversation_id`
WHERE mine.`profile_id` = ? AND peer.`profile_id` = ?
LIMIT 1
]], { profile.id, target.id })
local conversation_id = existing[1] and existing[1].conversation_id
if not conversation_id then
conversation_id = uuid()
local created = Bridge.Database.Transaction({
{
query = "INSERT INTO `sky_phone_darkchat_conversations` (`id`) VALUES (?)",
params = { conversation_id },
},
{
query = "INSERT INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`) VALUES (?, ?)",
params = { conversation_id, profile.id },
},
{
query = "INSERT INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`) VALUES (?, ?)",
params = { conversation_id, target.id },
},
})
if not created then
error("[sky_phone] Failed to create a DarkChat conversation transaction.")
end
end
return { success = true, data = { conversationId = conversation_id } }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:thread", function(source, data)
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local conversation_id = data and data.conversationId
if type(conversation_id) ~= "string" or #conversation_id ~= 36 then
return { success = false, error = "invalid_conversation" }
end
local membership = load_membership(profile.id, conversation_id)
if not membership then
return { success = false, error = "conversation_not_found" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_darkchat_members` SET `last_read_at` = CURRENT_TIMESTAMP
WHERE `conversation_id` = ? AND `profile_id` = ?
]], { conversation_id, profile.id })
if tonumber(membership.disappearing_seconds) == -1 then
Bridge.Database.Query([[
UPDATE `sky_phone_darkchat_messages`
SET `expires_at` = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 SECOND)
WHERE `conversation_id` = ? AND `sender_profile_id` <> ? AND `expires_at` IS NULL
]], { conversation_id, profile.id })
end
local rows = Bridge.Database.Query([[
SELECT message.*, reply.`body` AS `reply_body`, peer_member.`last_read_at` AS `peer_last_read_at`
FROM `sky_phone_darkchat_messages` message
JOIN `sky_phone_darkchat_members` mine
ON mine.`conversation_id` = message.`conversation_id` AND mine.`profile_id` = ?
JOIN `sky_phone_darkchat_members` peer_member
ON peer_member.`conversation_id` = message.`conversation_id` AND peer_member.`profile_id` <> ?
LEFT JOIN `sky_phone_darkchat_messages` reply ON reply.`id` = message.`reply_to_id`
WHERE message.`conversation_id` = ?
AND message.`created_at` >= COALESCE(mine.`cleared_at`, '1970-01-01')
AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP)
ORDER BY message.`created_at` DESC, message.`id` DESC LIMIT ?
]], { profile.id, profile.id, conversation_id, Config.DarkChat.ThreadPageSize })
local messages = {}
for index = #rows, 1, -1 do
local formatted = message_payload(rows[index], profile.id)
if formatted then
messages[#messages + 1] = formatted
end
end
return {
success = true,
data = {
conversation = {
id = conversation_id,
peer = peer_payload(membership),
disappearingSeconds = tonumber(membership.disappearing_seconds),
notificationsEnabled = membership.notifications_enabled == 1 or membership.notifications_enabled == true,
readReceipts = membership.read_receipts == 1 or membership.read_receipts == true,
blockedByPeer = membership.blocked_by_peer ~= nil,
createdAt = membership.conversation_created_at,
},
messages = messages,
},
}
end)
Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_send", Config.DarkChat.SendsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local conversation_id = data and data.conversationId
local membership = type(conversation_id) == "string" and load_membership(profile.id, conversation_id) or nil
if not membership then
return { success = false, error = "conversation_not_found" }
end
if membership.blocked_by_me or membership.blocked_by_peer then
return { success = false, error = "blocked" }
end
local message_type = data.messageType
local body = trim(data.body) or ""
local media_payload
local media_mime
local media_duration
local media_waveform
if message_type == "text" or message_type == "emoji" then
if body == "" or #body > Config.DarkChat.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
elseif message_type == "gif" then
if not allowed_gif_url(data.mediaPayload) then
return { success = false, error = "invalid_gif" }
end
media_payload = data.mediaPayload
media_mime = "image/gif"
elseif message_type == "voice" then
local voice = valid_voice(data)
if not voice then
return { success = false, error = "invalid_voice" }
end
media_payload = voice.payload
media_mime = voice.mime
media_duration = voice.duration
media_waveform = voice.waveform
elseif message_type == "image" or message_type == "video" then
local media_type = message_type == "image" and "photo" or "video"
local media_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.mediaAssetId, media_type)
if not media_url then
return { success = false, error = media_error }
end
media_payload = media_url
media_mime = message_type == "image" and "image/jpeg" or "video/mp4"
else
return { success = false, error = "invalid_message" }
end
local reply_to = data.replyToId
if reply_to ~= nil then
local reply = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_darkchat_messages`
WHERE `id` = ? AND `conversation_id` = ? LIMIT 1
]], { reply_to, conversation_id })
if not reply[1] then
return { success = false, error = "invalid_reply" }
end
end
local timer = tonumber(membership.disappearing_seconds) or 0
local id = uuid()
Bridge.Database.Query([[
INSERT INTO `sky_phone_darkchat_messages`
(`id`, `conversation_id`, `sender_profile_id`, `message_type`, `body`, `media_payload`,
`media_mime`, `media_duration_ms`, `media_waveform`, `reply_to_id`, `expires_at`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
IF(? > 0, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND), NULL))
]], {
id, conversation_id, profile.id, message_type, body, media_payload, media_mime,
media_duration, media_waveform, reply_to, timer, timer,
})
Bridge.Database.Query(
"UPDATE `sky_phone_darkchat_conversations` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
{ conversation_id }
)
local rows = Bridge.Database.Query([[
SELECT message.*, reply.`body` AS `reply_body`, NULL AS `peer_last_read_at`
FROM `sky_phone_darkchat_messages` message
LEFT JOIN `sky_phone_darkchat_messages` reply ON reply.`id` = message.`reply_to_id`
WHERE message.`id` = ? LIMIT 1
]], { id })
local message = message_payload(rows[1], profile.id)
TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = conversation_id })
notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:new", {
conversationId = conversation_id,
sender = profile.alias,
messageType = message_type,
preview = (message_type == "text" or message_type == "emoji") and body or message_type,
})
return { success = true, data = message }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:media", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_media", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT message.`media_payload`, message.`media_mime`
FROM `sky_phone_darkchat_messages` message
JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id`
WHERE message.`id` = ? AND member.`profile_id` = ? AND message.`message_type` = 'voice'
AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP)
LIMIT 1
]], { data and data.messageId, profile.id })
if not rows[1] or type(rows[1].media_payload) ~= "string" then
return { success = false, error = "message_not_found" }
end
return { success = true, data = { payload = rows[1].media_payload, mime = rows[1].media_mime } }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:react", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_react", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
if not reaction_values[data and data.reaction] then
return { success = false, error = "invalid_reaction" }
end
local rows = Bridge.Database.Query([[
SELECT message.`id`, message.`conversation_id`, message.`reactions`
FROM `sky_phone_darkchat_messages` message
JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id`
WHERE message.`id` = ? AND member.`profile_id` = ? LIMIT 1
]], { data.messageId, profile.id })
local message = rows[1]
if not message then
return { success = false, error = "message_not_found" }
end
local reactions = decode_array(message.reactions, "reactions")
local key = tostring(profile.id)
if reactions[key] == data.reaction then
reactions[key] = nil
else
reactions[key] = data.reaction
end
Bridge.Database.Query("UPDATE `sky_phone_darkchat_messages` SET `reactions` = ? WHERE `id` = ?", {
json.encode(reactions), message.id,
})
TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = message.conversation_id })
local membership = load_membership(profile.id, message.conversation_id)
notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:changed", {
conversationId = message.conversation_id,
})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:message-action", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_message_action", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT message.* FROM `sky_phone_darkchat_messages` message
JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id`
WHERE message.`id` = ? AND member.`profile_id` = ? LIMIT 1
]], { data and data.messageId, profile.id })
local message = rows[1]
if not message then
return { success = false, error = "message_not_found" }
end
if data.action == "delete_me" then
local deleted_for = decode_array(message.deleted_for_profiles, "deleted_for_profiles")
local already_deleted = false
for _, value in ipairs(deleted_for) do
already_deleted = already_deleted or tonumber(value) == tonumber(profile.id)
end
if not already_deleted then
deleted_for[#deleted_for + 1] = tonumber(profile.id)
end
Bridge.Database.Query("UPDATE `sky_phone_darkchat_messages` SET `deleted_for_profiles` = ? WHERE `id` = ?", {
json.encode(deleted_for), message.id,
})
elseif data.action == "delete_all" then
if tonumber(message.sender_profile_id) ~= tonumber(profile.id) then
return { success = false, error = "not_message_owner" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_darkchat_messages`
SET `deleted_for_everyone` = 1, `body` = '', `media_payload` = NULL,
`media_mime` = NULL, `media_duration_ms` = NULL, `media_waveform` = NULL
WHERE `id` = ?
]], { message.id })
else
return { success = false, error = "invalid_action" }
end
local membership = load_membership(profile.id, message.conversation_id)
TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = message.conversation_id })
notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:changed", {
conversationId = message.conversation_id,
})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:update-conversation", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_conversation_settings", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local membership = load_membership(profile.id, data and data.conversationId)
if not membership then
return { success = false, error = "conversation_not_found" }
end
if type(data.notificationsEnabled) ~= "boolean" or type(data.readReceipts) ~= "boolean" then
return { success = false, error = "invalid_settings" }
end
local timer = tonumber(data.disappearingSeconds)
if not timer or not Config.DarkChat.AllowedDisappearTimers[timer] then
return { success = false, error = "invalid_timer" }
end
Bridge.Database.Transaction({
{
query = "UPDATE `sky_phone_darkchat_members` SET `notifications_enabled` = ?, `read_receipts` = ? WHERE `conversation_id` = ? AND `profile_id` = ?",
params = { data.notificationsEnabled and 1 or 0, data.readReceipts and 1 or 0, data.conversationId, profile.id },
},
{
query = "UPDATE `sky_phone_darkchat_conversations` SET `disappearing_seconds` = ? WHERE `id` = ?",
params = { timer, data.conversationId },
},
{
query = "INSERT INTO `sky_phone_darkchat_messages` (`id`, `conversation_id`, `message_type`, `body`) VALUES (?, ?, 'system', ?)",
params = { uuid(), data.conversationId, "timer_changed:" .. tostring(timer) },
},
})
TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = data.conversationId })
notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:changed", {
conversationId = data.conversationId,
})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:add-contact", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_contact", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local membership = load_membership(profile.id, data and data.conversationId)
local alias = trim(data and data.alias)
if not membership or not alias or alias == "" or #alias > Config.DarkChat.AliasMaxLength then
return { success = false, error = "invalid_contact" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_darkchat_contacts` (`profile_id`, `contact_profile_id`, `alias_override`)
VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `alias_override` = VALUES(`alias_override`)
]], { profile.id, membership.peer_id, alias })
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:remove-contact", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_contact", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local membership = load_membership(profile.id, data and data.conversationId)
if not membership then
return { success = false, error = "conversation_not_found" }
end
Bridge.Database.Query([[
DELETE FROM `sky_phone_darkchat_contacts` WHERE `profile_id` = ? AND `contact_profile_id` = ?
]], { profile.id, membership.peer_id })
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:block", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_block", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local membership = load_membership(profile.id, data and data.conversationId)
if not membership or type(data.blocked) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
if data.blocked then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_darkchat_blocks` (`blocker_profile_id`, `blocked_profile_id`)
VALUES (?, ?)
]], { profile.id, membership.peer_id })
else
Bridge.Database.Query([[
DELETE FROM `sky_phone_darkchat_blocks` WHERE `blocker_profile_id` = ? AND `blocked_profile_id` = ?
]], { profile.id, membership.peer_id })
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:report", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_report", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local membership = load_membership(profile.id, data and data.conversationId)
local reason = data and data.reason
local details = trim(data and data.details) or ""
if not membership or not report_reasons[reason] or #details > 500 then
return { success = false, error = "invalid_report" }
end
local count = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_darkchat_reports`
WHERE `reporter_profile_id` = ? AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)
]], { profile.id })
if tonumber(count[1] and count[1].count) >= Config.DarkChat.ReportsPerDay then
return { success = false, error = "rate_limited" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_darkchat_reports`
(`id`, `reporter_profile_id`, `reported_profile_id`, `conversation_id`, `message_id`, `reason`, `details`)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], { uuid(), profile.id, membership.peer_id, data.conversationId, data.messageId, reason, details })
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:darkchat:clear", function(source, data)
if not SkyPhone.AllowOperation(source, "darkchat_clear", Config.DarkChat.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local membership = load_membership(profile.id, data and data.conversationId)
if not membership then
return { success = false, error = "conversation_not_found" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_darkchat_members` SET `cleared_at` = CURRENT_TIMESTAMP
WHERE `conversation_id` = ? AND `profile_id` = ?
]], { data.conversationId, profile.id })
return { success = true }
end)
CreateThread(function()
while true do
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
Bridge.Database.Query([[
DELETE FROM `sky_phone_darkchat_messages`
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
]], {})
end
end)
end)
+177
View File
@@ -351,6 +351,24 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_bank_transactions",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "owner_identifier", type = "VARCHAR(80) NOT NULL" },
{ name = "kind", type = "ENUM('deposit', 'withdrawal', 'transfer_in', 'transfer_out') NOT NULL" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "label", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "reference", type = "VARCHAR(96) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_bank_owner", columns = "(`owner_identifier`, `id`)" },
{ name = "idx_sky_phone_bank_reference", columns = "(`reference`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_sms_messages",
columns = {
@@ -675,6 +693,161 @@ local schema = {
primaryKey = "identifier",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_profiles",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "dark_id", type = "CHAR(14) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "invite_code", type = "CHAR(11) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "alias", type = "VARCHAR(32) NOT NULL" },
{ name = "avatar_seed", type = "INT UNSIGNED NOT NULL" },
{ name = "notification_mode", type = "ENUM('full', 'private', 'hidden') NOT NULL DEFAULT 'private'" },
{ name = "activity_visible", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_darkchat_profile_account", columns = "(`account_id`)" },
{ name = "uniq_sky_phone_darkchat_profile_dark_id", columns = "(`dark_id`)" },
{ name = "uniq_sky_phone_darkchat_profile_invite", columns = "(`invite_code`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_contacts",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "contact_profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "alias_override", type = "VARCHAR(32) NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_darkchat_contact", columns = "(`profile_id`, `contact_profile_id`)" },
},
foreignKeys = {
{ column = "profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
{ column = "contact_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_conversations",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "disappearing_seconds", type = "INT NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_darkchat_conversation_updated", columns = "(`updated_at`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_members",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "notifications_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "read_receipts", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "last_read_at", type = "DATETIME NULL" },
{ name = "cleared_at", type = "DATETIME NULL" },
{ name = "joined_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_darkchat_member", columns = "(`conversation_id`, `profile_id`)" },
},
indexes = {
{ name = "idx_sky_phone_darkchat_member_profile", columns = "(`profile_id`, `conversation_id`)" },
},
foreignKeys = {
{ column = "conversation_id", references = "`sky_phone_darkchat_conversations` (`id`) ON DELETE CASCADE" },
{ column = "profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_messages",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_profile_id", type = "BIGINT UNSIGNED NULL" },
{ name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'" },
{ name = "body", type = "TEXT NOT NULL" },
{ name = "media_payload", type = "LONGTEXT NULL" },
{ name = "media_mime", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_duration_ms", type = "INT UNSIGNED NULL" },
{ name = "media_waveform", type = "JSON NULL" },
{ name = "reply_to_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "reactions", type = "JSON NULL" },
{ name = "deleted_for_profiles", type = "JSON NULL" },
{ name = "deleted_for_everyone", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "expires_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_darkchat_message_thread", columns = "(`conversation_id`, `created_at`, `id`)" },
{ name = "idx_sky_phone_darkchat_message_expiry", columns = "(`expires_at`)" },
},
foreignKeys = {
{ column = "conversation_id", references = "`sky_phone_darkchat_conversations` (`id`) ON DELETE CASCADE" },
{ column = "sender_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_blocks",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "blocker_profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "blocked_profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_darkchat_block", columns = "(`blocker_profile_id`, `blocked_profile_id`)" },
},
foreignKeys = {
{ column = "blocker_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
{ column = "blocked_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_reports",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "reporter_profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "reported_profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "conversation_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "message_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "reason", type = "ENUM('spam', 'harassment', 'threats', 'illegal', 'other') NOT NULL" },
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_darkchat_report_target", columns = "(`reported_profile_id`, `created_at`)" },
},
foreignKeys = {
{ column = "reporter_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
{ column = "reported_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" },
{ column = "conversation_id", references = "`sky_phone_darkchat_conversations` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
@@ -682,6 +855,10 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_sms_messages`
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
+41 -2
View File
@@ -34,7 +34,7 @@ local function http_request(url, method, body, headers, timeout_ms)
settled = true
request:resolve({ status = 0, body = "request_timeout" })
end)
return Await(request)
return Citizen.Await(request)
end
local function decode_response(response)
@@ -136,6 +136,45 @@ local function owners_match(left, right)
return left.imei == right.imei and left.account_id == right.account_id
end
function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
local id = tonumber(media_id)
if not id or id < 1 or id ~= math.floor(id)
or (media_type ~= "photo" and media_type ~= "video")
then
return nil, "invalid_attachment"
end
local owner, error_response = session_owner(source)
if not owner then
return nil, error_response.error
end
local condition, owner_params = owner_condition(owner)
local params = { id }
for _, value in ipairs(owner_params) do
params[#params + 1] = value
end
local rows = Bridge.Database.Query(([[
SELECT `url`, `media_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
local media = rows[1]
if not media or media.media_type ~= media_type
or type(media.url) ~= "string"
or #media.url > Config.Media.UrlMaxLength
or not media.url:match("^https://")
then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned %s media %s from source %s.",
media_type,
tostring(media_id),
tostring(source)
)
return nil, "invalid_attachment"
end
return media.url
end
local function upload_result(source, correlation_id, success, error_code, media)
TriggerClientEvent("sky_phone:media:upload-result", source, {
correlationId = correlation_id,
@@ -268,7 +307,7 @@ local function await_giphy_http(url)
status = status,
})
end, "GET", "", {})
return Await(request)
return Citizen.Await(request)
end
local function parse_giphy_json(value)
+13
View File
@@ -177,6 +177,19 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_bank_transactions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`owner_identifier` VARCHAR(80) NOT NULL,
`kind` ENUM('deposit', 'withdrawal', 'transfer_in', 'transfer_out') NOT NULL,
`amount` BIGINT UNSIGNED NOT NULL,
`label` VARCHAR(160) NOT NULL DEFAULT '',
`reference` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_bank_owner` (`owner_identifier`, `id`),
KEY `idx_sky_phone_bank_reference` (`reference`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,