mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 17:59:02 +00:00
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:
@@ -1 +1,2 @@
|
||||
/sky_phone/source/html
|
||||
.pnpm-store/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
dev-server*.log
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const IFRUIT_AUTH_INPUT_COLORS = {
|
||||
outlineLabelBgIos: 'bg-black',
|
||||
}
|
||||
@@ -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: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { Component } from 'vue'
|
||||
export type PhoneAppId =
|
||||
| 'phone'
|
||||
| 'messages'
|
||||
| 'darkchat'
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'calendar'
|
||||
| 'weather'
|
||||
| 'banking'
|
||||
| 'mail'
|
||||
| 'map'
|
||||
| 'notes'
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'))
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user